weblib/weblib-viewer#95: every version in the published index claimed it was added at the moment of the *last* publish, so an F-Droid client's version list showed five separate builds all added within seconds of each other. Measured on the live dev repo: index-v2.json gave versionCodes 365139/365516/365666/365710/366099 the added dates 05:50:06/08/10/12/13 on 2026-09-12, while the bucket's own Last-Modified for those objects says they were actually published on 2026-09-11 at 13:53, 20:09, 22:37, 23:23 and 2026-09-12 05:50. It is not an mtime problem, and --use-date-from-apk does not fix it. fdroidserver takes the date from common.KnownApks, whose __init__ loads it out of an already existing repo/index-v2.json (and archive/index-v2.json), and whose recordapk falls back to datetime.now() for any filename it has not seen; --use-date-from-apk only changes that fallback to the local file's mtime, which for a re-downloaded APK is when it was downloaded. KnownApks' own docstring says there is nowhere else to look: the index is "currently the only way to permanently store the 'updated' date of APKs". A CI job that rebuilds repo/ on a fresh runner -- which is exactly what weblib-viewer#32's retention scheme does when it re-downloads the previous APKs -- therefore starts with an empty KnownApks, and every build in it counts as new. So hand fdroid-update.sh the previously published index, optionally, as FDROID_PREVIOUS_INDEX -- a local path, because fetching it needs to know the bucket and per fdroid-repo-ci#1 that stays on the consumer's side; this script still needs no network. preserve_added.py stages it where KnownApks looks, and afterwards *checks* that every build present in both indexes really did keep its date, failing the run if not. That is not belt-and-braces: fdroidserver's own read_added_date_from_all_apks carries a TODO about reading these dates differently, so a future version could drop the behaviour this depends on, and a silent return to flattened history is the failure mode that took a human noticing to catch the first time. Set-but-missing is a hard failure rather than a shrug, and so is a file that is not a parseable index-v2.json (a truncated download, an S3 NoSuchKey error document): KnownApks has no error handling of its own and would abort mid-library with a traceback that never names the file, and a consumer whose fetch silently produced nothing would otherwise flatten its whole history on every publish while looking healthy. A first-ever publish expresses "there is no previous index" by leaving the variable unset. Verified end to end without publishing anything, against the five real APKs and a copy of the live dev index-v2.json pulled over HTTPS: - no previous index (today's behaviour), all five re-stamped: 366099 06:23:19, 365710 06:23:18, 365666 06:23:17, 365516 06:23:15, 365139 06:23:14 -- a five-second spread, all of it "now". - previous index minus 366099, i.e. the real "four retained plus one genuinely new" case: 365139/365516/365666/365710 keep 05:50:06/08/10/12 exactly, 366099 gets 06:24:01, and lastUpdated follows the new build. - a second run on a fresh repo/ with the APK mtimes deliberately touched to now: all five unchanged, "5 already-published build(s) kept their original 'added' timestamp, 0 new". test_preserve_added.py (12 assertions, stdlib only) covers the drift detection in both directions, including the negative control that a drifted date fails; test_fdroid_update.sh covers the shell contract. run_tests.sh: 123 passed, 4 skipped, 0 failed. A weblib-viewer change is needed too -- its workflows have to fetch last publish's index and set FDROID_PREVIOUS_INDEX -- and is a separate PR in that repo. Co-authored-by: bit <bit@das-labor.org>
229 lines
10 KiB
Python
Executable File
229 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Offline tests for preserve_added.py (weblib/weblib-viewer#95).
|
|
|
|
Standard library only, no network, no nix -- like test_next_version_code.py
|
|
and test_check_zip_alignment.py, this is the piece of the change that needs
|
|
none of the pinned toolchain. `fdroid update` itself is exercised by
|
|
test_fdroid_update.sh, which needs fdroidserver; what is tested here is the
|
|
part that has to be right *before* fdroidserver is ever reached and the part
|
|
that judges what fdroidserver produced.
|
|
|
|
Run as a *subprocess*, same reasoning the neighbouring suites give: the
|
|
contract includes the exit code (fdroid-update.sh aborts a publish on a
|
|
non-zero `verify`) and the messages, not just the return value of a function.
|
|
|
|
The one property that matters, and the one a refactor could quietly break
|
|
while still looking correct: `verify` must FAIL when a build that exists in
|
|
both indexes changed its date. A checker that only ever passes is worth
|
|
nothing, so every "carried" assertion below is paired with a drift case.
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
SCRIPT = os.path.join(HERE, "preserve_added.py")
|
|
APP_ID = "de.example.app"
|
|
|
|
|
|
def index(entries, app_id=APP_ID):
|
|
"""Build a minimal index-v2.json shaped exactly like fdroidserver's.
|
|
|
|
`entries` is {versionCode: added_ms}. The map key under `versions` is a
|
|
sha256 in a real index; nothing here reads it, and using the versionCode
|
|
keeps the fixtures readable -- what both sides key on is `file.name`,
|
|
which is what `common.KnownApks` itself keys on.
|
|
"""
|
|
versions = {}
|
|
for code, added in entries.items():
|
|
versions["%064x" % code] = {
|
|
"added": added,
|
|
"file": {"name": "/%s_%d.apk" % (app_id, code), "size": 1},
|
|
"manifest": {"versionCode": code, "versionName": "1.0"},
|
|
}
|
|
return {
|
|
"repo": {"timestamp": 0, "address": "https://example.invalid/repo"},
|
|
"packages": {app_id: {"metadata": {"added": 0}, "versions": versions}},
|
|
}
|
|
|
|
|
|
class PreserveAddedTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
|
|
def write(self, name, obj):
|
|
path = os.path.join(self.tmp.name, name)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
if isinstance(obj, str):
|
|
f.write(obj)
|
|
else:
|
|
json.dump(obj, f)
|
|
return path
|
|
|
|
def run_script(self, *args):
|
|
return subprocess.run(
|
|
[sys.executable, SCRIPT, *args],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
# --- install ---------------------------------------------------------
|
|
|
|
def test_install_puts_the_index_where_knownapks_looks_for_it(self):
|
|
"""fdroidserver reads repo/index-v2.json and nothing else.
|
|
|
|
common.KnownApks.__init__ globs exactly os.path.join(part,
|
|
'index-v2.json') for part in ('repo', 'archive'), relative to the
|
|
repo root. A file staged anywhere else, or under any other name, is
|
|
invisible to it -- so the filename is the whole contract here.
|
|
"""
|
|
prev = self.write("prev.json", index({1: 1000, 2: 2000}))
|
|
repo = os.path.join(self.tmp.name, "repo")
|
|
os.mkdir(repo)
|
|
|
|
result = self.run_script("install", prev, repo)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
staged = os.path.join(repo, "index-v2.json")
|
|
self.assertTrue(os.path.isfile(staged))
|
|
with open(staged, encoding="utf-8") as f:
|
|
self.assertEqual(json.load(f), index({1: 1000, 2: 2000}))
|
|
self.assertIn("2 known build(s)", result.stdout)
|
|
|
|
def test_install_refuses_a_missing_repo_directory(self):
|
|
prev = self.write("prev.json", index({1: 1000}))
|
|
result = self.run_script("install", prev, os.path.join(self.tmp.name, "nope"))
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("no such repo directory", result.stderr)
|
|
|
|
def test_install_refuses_a_missing_previous_index(self):
|
|
repo = os.path.join(self.tmp.name, "repo")
|
|
os.mkdir(repo)
|
|
result = self.run_script(
|
|
"install", os.path.join(self.tmp.name, "gone.json"), repo
|
|
)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("no such previous index", result.stderr)
|
|
|
|
def test_install_refuses_a_truncated_index(self):
|
|
"""KnownApks has no error handling at all around json.load().
|
|
|
|
A half-written download would abort `fdroid update` from inside a
|
|
library, with a traceback that never names the file. Refuse it here,
|
|
naming the path, before the run starts.
|
|
"""
|
|
prev = self.write("prev.json", '{"packages": {"a":')
|
|
repo = os.path.join(self.tmp.name, "repo")
|
|
os.mkdir(repo)
|
|
result = self.run_script("install", prev, repo)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("not valid JSON", result.stderr)
|
|
self.assertFalse(os.path.exists(os.path.join(repo, "index-v2.json")))
|
|
|
|
def test_install_refuses_valid_json_that_is_not_an_index(self):
|
|
"""An S3 error document, or entry.json fetched by mistake, parses fine."""
|
|
for payload in ('{"error": "NoSuchKey"}', "[]", '{"packages": []}'):
|
|
with self.subTest(payload=payload):
|
|
prev = self.write("prev.json", payload)
|
|
repo = os.path.join(self.tmp.name, "repo")
|
|
os.makedirs(repo, exist_ok=True)
|
|
result = self.run_script("install", prev, repo)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("does not look like an index-v2.json", result.stderr)
|
|
|
|
# --- verify ----------------------------------------------------------
|
|
|
|
def test_verify_passes_when_every_shared_build_kept_its_date(self):
|
|
prev = self.write("prev.json", index({1: 1000, 2: 2000}))
|
|
new = self.write("new.json", index({1: 1000, 2: 2000, 3: 9000}))
|
|
result = self.run_script("verify", prev, new)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("2 already-published build(s) kept", result.stdout)
|
|
self.assertIn("1 new", result.stdout)
|
|
self.assertIn("_3.apk is new", result.stdout)
|
|
|
|
def test_verify_fails_when_a_shared_build_drifted(self):
|
|
"""The negative control: this is exactly weblib-viewer#95.
|
|
|
|
Both builds re-stamped with the current wall clock, which is what a
|
|
run with no previous index produces.
|
|
"""
|
|
prev = self.write("prev.json", index({1: 1000, 2: 2000}))
|
|
new = self.write("new.json", index({1: 1789194194000, 2: 1789194195000}))
|
|
result = self.run_script("verify", prev, new)
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("lost their original 'added' timestamp", result.stderr)
|
|
self.assertIn("_1.apk was added at 1000", result.stderr)
|
|
self.assertIn("_2.apk was added at 2000", result.stderr)
|
|
|
|
def test_verify_ignores_a_build_that_aged_out_of_retention(self):
|
|
"""Retention deletes old APKs on purpose; that is not drift.
|
|
|
|
Only builds present in BOTH indexes are judged.
|
|
"""
|
|
prev = self.write("prev.json", index({1: 1000, 2: 2000, 3: 3000}))
|
|
new = self.write("new.json", index({3: 3000, 4: 4000}))
|
|
result = self.run_script("verify", prev, new)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("1 already-published build(s) kept", result.stdout)
|
|
|
|
def test_verify_accepts_a_first_publish_shaped_empty_index(self):
|
|
prev = self.write("prev.json", {"packages": {}})
|
|
new = self.write("new.json", index({1: 1000}))
|
|
result = self.run_script("verify", prev, new)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("0 already-published build(s) kept", result.stdout)
|
|
|
|
def test_verify_tolerates_sub_second_rounding_only(self):
|
|
"""fdroidserver stores ms but only ever emits whole seconds.
|
|
|
|
index.py serialises a datetime as
|
|
`int(calendar.timegm(obj.timetuple()) * 1000)` and KnownApks reads it
|
|
back as `version["added"] // 1000`, so a hand-made previous index
|
|
carrying a sub-second value comes back rounded through no fault of
|
|
the mechanism. That must not fail a publish -- but one whole second
|
|
of drift still must.
|
|
"""
|
|
prev = self.write("prev.json", index({1: 1789194194678}))
|
|
result = self.run_script(
|
|
"verify", prev, self.write("new.json", index({1: 1789194194000}))
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
result = self.run_script(
|
|
"verify", prev, self.write("new2.json", index({1: 1789194195000}))
|
|
)
|
|
self.assertEqual(result.returncode, 1)
|
|
|
|
def test_verify_ignores_a_version_entry_with_no_added(self):
|
|
"""A missing date is not a date; do not default it to anything."""
|
|
broken = index({1: 1000})
|
|
del list(broken["packages"][APP_ID]["versions"].values())[0]["added"]
|
|
prev = self.write("prev.json", broken)
|
|
new = self.write("new.json", index({1: 5000}))
|
|
result = self.run_script("verify", prev, new)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
def test_leading_slash_on_file_name_is_stripped_consistently(self):
|
|
"""index-v2 writes "/name.apk"; KnownApks strips the slash ([1:]).
|
|
|
|
Both sides of verify have to agree, or every build looks new.
|
|
"""
|
|
prev_obj = index({1: 1000})
|
|
for v in prev_obj["packages"][APP_ID]["versions"].values():
|
|
v["file"]["name"] = v["file"]["name"].lstrip("/") # no leading slash
|
|
prev = self.write("prev.json", prev_obj)
|
|
new = self.write("new.json", index({1: 1000})) # with leading slash
|
|
result = self.run_script("verify", prev, new)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("1 already-published build(s) kept", result.stdout)
|
|
self.assertNotIn("is new", result.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|