#!/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()