#!/usr/bin/env python3 """Offline tests for next_version_code.py (fdroid-repo-ci#6, including its "Superseded" sections: the candidate is derived from the COMMITTER date of the commit being built, clamped on both sides, not from the build clock). Standard library only, no network, no nix -- like test_check_zip_alignment.py, this is the piece of the change that needs none of the pinned toolchain. Runs next_version_code.py as a *subprocess* against fixture index-v2.json files written to a temp dir, the same reasoning test_check_zip_alignment.py gives: a test that only imports the function would miss an argv/exit-code/ stdout-pollution regression, and "prints the result; do nothing else" is itself part of the contract (README/issue #6: a caller does `versionCode=$(next-version-code.sh ...)`, so stray stdout output would be silently captured as part of the number). Two clocks are pinned independently, deliberately kept distinct so a test can move either one without the other: * the COMMIT's committer date -- the third CLI argument, exactly as a real caller passes `git log -1 --format=%ct`; * "now" -- the runner's clock, used only for the forwards clamp, pinned via the NEXT_VERSION_CODE_NOW testing seam (see the script's main()). The property that actually matters -- and the one a future refactor could quietly break while still "looking" correct -- is that the result is *strictly greater than every versionCode already in the fixture*, not merely equal to some formula. Every clamp case below asserts that directly, not just the arithmetic, and test_property_result_exceeds_every_published_code checks it again across every scenario in one place. """ import datetime 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, "next_version_code.py") APP_ID = "de.example.app" EPOCH = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) TOLERANCE_MINUTES = 6 * 60 # must track next_version_code.py's TOLERANCE_MINUTES INT32_MAX = 2**31 - 1 # "Now" is pinned at minute 100 after the epoch throughout, unless a test # overrides it -- an arbitrary, fixed instant so expected candidates are # known integers instead of whatever minute the suite happens to run in. NOW_MINUTES = 100 NOW_ISO = (EPOCH + datetime.timedelta(minutes=NOW_MINUTES)).isoformat() def minutes_to_unix_seconds(minutes): """Convert "minutes since the 2026 epoch" (what the script's candidate is expressed in) to Unix seconds (what its CLI argument, and real `git log -1 --format=%ct`, actually take).""" return int((EPOCH + datetime.timedelta(minutes=minutes)).timestamp()) def index_with_versions(application_id, version_codes): """Build an index-v2.json fixture with one package with each versionCode in a separate version entry, matching fdroidserver's own shape: packages..versions..manifest.versionCode.""" versions = {} for i, vc in enumerate(version_codes): versions[f"fakehash{i}"] = {"manifest": {"versionCode": vc}} return {"packages": {application_id: {"versions": versions}}} class NextVersionCodeTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) def _write_index(self, name, obj_or_text): path = os.path.join(self.tmp.name, name) with open(path, "w") as f: if isinstance(obj_or_text, str): f.write(obj_or_text) else: json.dump(obj_or_text, f) return path def _run(self, index_path, committer_minutes, application_id=APP_ID, now_iso=NOW_ISO): env = dict(os.environ) if now_iso is not None: env["NEXT_VERSION_CODE_NOW"] = now_iso else: env.pop("NEXT_VERSION_CODE_NOW", None) committer_ts = str(minutes_to_unix_seconds(committer_minutes)) return subprocess.run( [sys.executable, SCRIPT, index_path, application_id, committer_ts], capture_output=True, text=True, env=env, ) # -- a normal commit yields its own minute ----------------------------- def test_normal_commit_yields_its_own_minute(self): # Committer date deliberately different from "now" (250 vs 100), and # well inside tolerance, to prove the result tracks the COMMIT, not # the clock the script happens to run under. path = self._write_index("empty.json", {"packages": {}}) result = self._run(path, committer_minutes=250) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, "250\n") self.assertEqual(result.stderr, "") # no warning: well within tolerance def test_application_id_missing_entirely_is_not_an_error(self): path = self._write_index( "other-app.json", index_with_versions("some.other.app", [5])) result = self._run(path, committer_minutes=100) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, "100\n") # -- backwards: a commit older than published still exceeds it --------- def test_commit_older_than_published_max_still_yields_published_plus_one(self): # The rebase/amend/cherry-pick case: committer date (10) is far # older than what is already published (500). Fault this catches if # removed: the script would print 10, a number every device that # saw 500 has already rejected as a downgrade. path = self._write_index("higher.json", index_with_versions(APP_ID, [500])) result = self._run(path, committer_minutes=10) self.assertEqual(result.returncode, 0, result.stderr) got = int(result.stdout) self.assertEqual(got, 501) self.assertGreater(got, 500) def test_equal_prior_is_bumped_by_one(self): # committer minute == published: the same-minute collision case. # The clamp must still move strictly past it. path = self._write_index("equal.json", index_with_versions(APP_ID, [100])) result = self._run(path, committer_minutes=100) self.assertEqual(result.returncode, 0, result.stderr) got = int(result.stdout) self.assertEqual(got, 101) def test_highest_of_several_versions_is_the_one_that_matters(self): path = self._write_index( "several.json", index_with_versions(APP_ID, [10, 999, 50])) result = self._run(path, committer_minutes=100) self.assertEqual(result.returncode, 0, result.stderr) got = int(result.stdout) self.assertGreater(got, 999) def test_version_entry_without_a_versionCode_is_ignored_not_fatal(self): # A version whose manifest lacks versionCode (or has no manifest at # all) must not crash the read -- it is simply not a candidate for # "published". obj = index_with_versions(APP_ID, [7]) obj["packages"][APP_ID]["versions"]["nomanifest"] = {} obj["packages"][APP_ID]["versions"]["emptymanifest"] = {"manifest": {}} path = self._write_index("partial.json", obj) result = self._run(path, committer_minutes=100) self.assertEqual(result.returncode, 0, result.stderr) got = int(result.stdout) self.assertGreater(got, 7) # -- forwards: a future-dated commit is clamped, and warns ------------- def test_future_dated_commit_within_tolerance_is_not_clamped(self): # Negative control: honest clock skew, well inside TOLERANCE, must # pass through untouched and silently -- proves the forwards clamp # is not simply "always clamp to now". just_ahead = NOW_MINUTES + TOLERANCE_MINUTES - 1 path = self._write_index("empty.json", {"packages": {}}) result = self._run(path, committer_minutes=just_ahead) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, f"{just_ahead}\n") self.assertEqual(result.stderr, "") def test_future_dated_commit_beyond_tolerance_is_clamped_and_warns(self): # A commit dated far ahead of "now" (attacker- or typo-controlled, # per the module docstring) must be clamped to roughly now, not # trusted outright -- and the clamp must be visible, not silent. far_ahead = NOW_MINUTES + TOLERANCE_MINUTES + 100_000 path = self._write_index("empty.json", {"packages": {}}) result = self._run(path, committer_minutes=far_ahead) self.assertEqual(result.returncode, 0, result.stderr) got = int(result.stdout) expected_ceiling = NOW_MINUTES + TOLERANCE_MINUTES self.assertEqual(got, expected_ceiling) self.assertLess(got, far_ahead) # actually clamped, not passed through self.assertIn("warning", result.stderr.lower()) self.assertIn("clamping", result.stderr.lower()) def test_commit_dated_near_int32_does_not_produce_a_near_int32_result(self): # The unrecoverable failure mode from issue #6: a commit dated to # land just below the int32 ceiling must NOT be allowed through, # since that would publish a versionCode no later build could ever # exceed. This uses a real distant year (2999) via a raw Unix # timestamp, not a minutes-since-epoch shortcut, to exercise the # actual conversion path a malicious or badly-typo'd commit date # would take. year_2999 = datetime.datetime(2999, 1, 1, tzinfo=datetime.timezone.utc) ts = str(int(year_2999.timestamp())) path = self._write_index("empty.json", {"packages": {}}) env = dict(os.environ) env["NEXT_VERSION_CODE_NOW"] = NOW_ISO result = subprocess.run( [sys.executable, SCRIPT, path, APP_ID, ts], capture_output=True, text=True, env=env, ) self.assertEqual(result.returncode, 0, result.stderr) got = int(result.stdout) self.assertLess(got, INT32_MAX - 1_000_000) # Tighter than "not overflowing": it must be near the clamp ceiling, # i.e. near *now*, not merely somewhere short of int32. self.assertEqual(got, NOW_MINUTES + TOLERANCE_MINUTES) self.assertIn("warning", result.stderr.lower()) # -- the property that actually matters --------------------------------- def test_property_result_exceeds_every_published_code(self): """Across a spread of scenarios -- ordinary, backwards, forwards, far-forwards -- the result must always exceed every versionCode already in the fixture. This is the property a future refactor could quietly break while every individual arithmetic case still happens to look right.""" scenarios = [ ([], 5), ([1, 2, 3], 5), ([100], 100), ([500], 10), # backwards ([1], NOW_MINUTES + TOLERANCE_MINUTES - 1), # forwards, in tolerance ([1], NOW_MINUTES + TOLERANCE_MINUTES + 100_000), # forwards, clamped ] for published, committer_minutes in scenarios: with self.subTest(published=published, committer_minutes=committer_minutes): path = self._write_index( f"scenario-{committer_minutes}.json", index_with_versions(APP_ID, published) if published else {"packages": {}}, ) result = self._run(path, committer_minutes=committer_minutes) self.assertEqual(result.returncode, 0, result.stderr) got = int(result.stdout) ceiling = max(published) if published else float("-inf") self.assertGreater(got, ceiling) # -- failure modes: fail loudly, never guess --------------------------- def test_unreadable_index_fails_loudly(self): missing = os.path.join(self.tmp.name, "does-not-exist.json") result = self._run(missing, committer_minutes=100) self.assertNotEqual(result.returncode, 0) self.assertEqual(result.stdout, "") # never a guessed number on stdout self.assertIn("could not read the index", result.stderr) def test_malformed_index_fails_loudly(self): path = self._write_index("malformed.json", "{not valid json") result = self._run(path, committer_minutes=100) self.assertNotEqual(result.returncode, 0) self.assertEqual(result.stdout, "") self.assertIn("could not read the index", result.stderr) # -- output contract: nothing but the integer on stdout ----------------- def test_stdout_is_only_the_integer(self): path = self._write_index("clean.json", {"packages": {}}) result = self._run(path, committer_minutes=100) self.assertEqual(result.returncode, 0, result.stderr) # Exactly one line, and it parses as an int with no surrounding text. self.assertEqual(int(result.stdout.strip()), 100) self.assertEqual(result.stdout.count("\n"), 1) if __name__ == "__main__": unittest.main()