import json from pathlib import Path import pytest from thoughtsync import client_dist from thoughtsync.app import create_app from thoughtsync.client_dist import ( APK_NAME, BY_ID, MANIFEST_NAME, PLATFORMS, advertisement, android_release, release, releases, ) from thoughtsync.config import Config # DB-free, like the rest of this suite — the test lane runs no Postgres. That is # why the advertisement is asserted through `advertisement()` rather than through # `/api/config`: the route is a one-line merge of this dict into a payload whose # other half needs a database, and testing it here tests the part that can be wrong. # # The routes below ARE exercised, because none opens a session: the metadata route # only stats files, and the download's 401 is returned before any token lookup. PAYLOAD = b"not really a client, but the server only ever stats it" # Every test that is about the MECHANISM rather than about one platform runs # against all of them. The bugs this module can have — a sidecar describing a # different build, a half-finished copy shadowing a good one — are not # platform-specific, and a suite that only ever exercised Android is how the other # four would ship untested. ALL_IDS = [p.id for p in PLATFORMS] @pytest.fixture(autouse=True) def _empty_baked_client(tmp_path, monkeypatch): """Point the baked-in copy at an empty directory. In a source checkout `src/thoughtsync/client/` does not exist, so these tests would pass anyway — but only by accident of where they are run. A built image has real clients there, and a test that silently depends on which tree it is in is one that will eventually lie. """ monkeypatch.setattr(client_dist, "BAKED_ROOT", tmp_path / "baked") yield @pytest.fixture def app(): return create_app() def place( platform_id: str = "android", payload: bytes = PAYLOAD, root: Path | None = None, signature: str | None = "a signature", **overrides, ) -> dict: """Put one platform's client + sidecar where the server looks. Overrides corrupt the pair. `signature=None` withholds the `.sig` a signed bundle needs, which is its own failure mode rather than a variant of the others. """ platform = BY_ID[platform_id] root = root if root is not None else Path(Config.client_root()) root.mkdir(parents=True, exist_ok=True) (root / platform.artifact).write_bytes(payload) meta = { "version_name": "2026.08.30.0307", "version_code": 3503708, "size": len(payload), "sha256": "ab" * 32, } meta.update(overrides) (root / platform.sidecar).write_text(json.dumps(meta), encoding="utf-8") if platform.signed and signature is not None: (root / platform.signature).write_text(signature, encoding="utf-8") return meta # --- the table --------------------------------------------------------------- def test_every_platform_has_its_own_filenames(): """Two platforms sharing an artifact or a sidecar name would overwrite each other in the one directory they all live in — silently, and the survivor would be whichever was copied last.""" artifacts = [p.artifact for p in PLATFORMS] sidecars = [p.sidecar for p in PLATFORMS] assert len(set(artifacts)) == len(artifacts) assert len(set(sidecars)) == len(sidecars) assert not set(artifacts) & set(sidecars) def test_the_android_names_are_the_ones_already_published(): """Pinned because renaming them is a tidy-up that strands every installed phone. The Android lane publishes these exact names and CI bakes them in under them. """ assert APK_NAME == "thoughtsync.apk" assert MANIFEST_NAME == "thoughtsync-android.json" # --- absence is an ordinary answer ------------------------------------------- @pytest.mark.parametrize("platform_id", ALL_IDS) def test_an_absent_client_is_no_client(platform_id): assert release(platform_id) is None def test_a_server_holding_nothing_advertises_no_keys_at_all(): """The KEYS are missing, not null. A client testing for one then gets an unambiguous answer rather than having to tell "this server has no client" apart from "this server predates the feature". """ assert releases() == {} assert advertisement() == {} @pytest.mark.parametrize("platform_id", ALL_IDS) def test_a_sidecar_describing_a_different_build_counts_as_no_client(platform_id): """The likeliest real corruption: a new artifact copied over an old sidecar. Serving one build while advertising another is worse than serving none — the client would compare versions against a promise the bytes do not keep. """ place(platform_id, size=999_999) assert release(platform_id) is None @pytest.mark.parametrize("platform_id", ALL_IDS) def test_an_unreadable_sidecar_counts_as_no_client(platform_id): place(platform_id) sidecar = Path(Config.client_root()) / BY_ID[platform_id].sidecar sidecar.write_text("{ this is not json", encoding="utf-8") assert release(platform_id) is None @pytest.mark.parametrize("platform_id", ALL_IDS) def test_a_sidecar_missing_a_field_counts_as_no_client(platform_id): platform = BY_ID[platform_id] root = Path(Config.client_root()) root.mkdir(parents=True, exist_ok=True) (root / platform.artifact).write_bytes(PAYLOAD) (root / platform.sidecar).write_text(json.dumps({"version_name": "x"}), encoding="utf-8") assert release(platform_id) is None @pytest.mark.parametrize("platform_id", ALL_IDS) def test_a_sidecar_with_no_artifact_beside_it_counts_as_no_client(platform_id): platform = BY_ID[platform_id] root = Path(Config.client_root()) root.mkdir(parents=True, exist_ok=True) (root / platform.sidecar).write_text( json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""}), encoding="utf-8", ) assert release(platform_id) is None def test_an_unknown_platform_is_not_a_client(): assert release("blackberry") is None # --- what a present client reports ------------------------------------------- @pytest.mark.parametrize("platform_id", ALL_IDS) def test_a_present_client_reports_what_a_comparator_reads(platform_id): place(platform_id) found = release(platform_id) assert found["version"] == "2026.08.30.0307" # The integer is what decides "is this newer", not the name — a name is a string # and sorts like one. assert found["version_code"] == 3503708 assert found["size"] == len(PAYLOAD) assert found["platform"] == platform_id # A PATH, not an absolute URL: the client joins it to the base it is already # linked to, so a server cannot redirect the download elsewhere. assert found["url"] == f"/api/client/{platform_id}/download" assert not found["url"].startswith("http") def test_the_android_payload_still_carries_every_field_it_used_to(): """Phones in the field parse this. Fields may be ADDED — `ClientRelease` in core/src/sync/client.rs is a plain serde struct and ignores what it does not know — but none of these may move or change meaning.""" place("android") found = android_release() for key in ("version", "version_code", "size", "sha256", "url"): assert key in found, key assert found["url"] == "/api/client/android/download" # --- the signed bundle ------------------------------------------------------- def test_the_appimage_publishes_its_signature_with_its_version(): """One request returns both, so an updater cannot pair a version with a signature belonging to a different build.""" place("linux-appimage", signature="minisign output here") assert release("linux-appimage")["signature"] == "minisign output here" def test_an_appimage_without_a_signature_is_absent_rather_than_unsigned(): """It is the only bundle that replaces itself in place, and an update the app cannot verify is one it will refuse. Offering it unverifiable would turn a missing file into a failed install on the user's machine.""" place("linux-appimage", signature=None) assert release("linux-appimage") is None def test_an_empty_signature_file_is_not_a_signature(): """A truncated copy leaves a zero-byte file, which reads as present.""" place("linux-appimage", signature=" \n") assert release("linux-appimage") is None def test_only_the_appimage_carries_a_signature(): """A package-manager install cannot replace itself in place, so nothing verifies one and claiming a signature would imply an update path that does not exist.""" for platform_id in ALL_IDS: place(platform_id) found = releases() assert "signature" in found["linux-appimage"] for platform_id in ALL_IDS: if platform_id != "linux-appimage": assert "signature" not in found[platform_id], platform_id # --- the set, and precedence within it --------------------------------------- def test_a_platform_the_server_lacks_is_simply_not_in_the_set(): """Not null, not an error — a server holding some clients and not others is the ordinary state, and the UI hides what is absent.""" place("android") place("windows") found = releases() assert set(found) == {"android", "windows"} def test_the_baked_in_copy_is_used_when_nothing_was_dropped_in(): """The ordinary case for a self-hoster who just pulled the image.""" place("android", root=client_dist.BAKED_ROOT, version_code=300) assert android_release()["version_code"] == 300 def test_a_dropped_in_build_beats_the_one_the_image_shipped(): """Someone who deliberately put a build on the volume wants that build.""" place("android", root=client_dist.BAKED_ROOT, version_code=300) place("android", version_code=99) # Lower version and all — precedence is about intent, not about newness. An # operator pinning an older client is doing it on purpose. assert android_release()["version_code"] == 99 def test_precedence_is_decided_per_platform_not_for_the_whole_set(): """THE trap this table introduces. Dropping in one client must not retract the other four — "first directory holding anything wins" would mean overriding the APK silently takes the desktop downloads offline.""" for platform_id in ALL_IDS: place(platform_id, root=client_dist.BAKED_ROOT, version_code=300) place("android", version_code=99) found = releases() assert found["android"]["version_code"] == 99 assert found["linux-deb"]["version_code"] == 300 assert set(found) == set(ALL_IDS) def test_a_broken_drop_in_does_not_shadow_the_baked_copy(): """A half-finished copy onto the volume must not take the app offline. This is the failure the copy-order advice in docs/android-distribution.md is about, and the server should ride it out rather than go dark. """ place("android", root=client_dist.BAKED_ROOT, version_code=300) place("android", size=999_999) # sidecar describing a different build assert android_release()["version_code"] == 300 # --- the advertisement ------------------------------------------------------- def test_the_advertisement_carries_the_whole_table(): place("linux-deb") assert set(advertisement()["clients"]) == {"linux-deb"} def test_the_advertisement_still_carries_the_key_phones_already_read(): """Not deprecated in the change that introduces its replacement. An installed Android client reads `android_client`, and one duplicated dict is what it costs to not strand it.""" place("android") data = advertisement() assert data["android_client"] == data["clients"]["android"] def test_no_android_client_means_no_android_key_even_when_others_are_present(): place("windows") data = advertisement() assert "android_client" not in data assert set(data["clients"]) == {"windows"} # --- routes ------------------------------------------------------------------ @pytest.mark.parametrize("platform_id", ALL_IDS) async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app, platform_id): place(platform_id) resp = await app.test_client().get(f"/api/client/{platform_id}") assert resp.status_code == 200 assert (await resp.get_json())["version_code"] == 3503708 @pytest.mark.parametrize("platform_id", ALL_IDS) async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app, platform_id): resp = await app.test_client().get(f"/api/client/{platform_id}") assert resp.status_code == 404 async def test_an_unknown_platform_404s_like_an_absent_one(app): """Same answer to the caller either way, and telling them apart would only tell an unauthenticated stranger which platforms this build of the server knows.""" resp = await app.test_client().get("/api/client/blackberry") assert resp.status_code == 404 async def test_the_index_returns_everything_in_one_request(app): place("android") place("linux-appimage") resp = await app.test_client().get("/api/client") assert resp.status_code == 200 assert set((await resp.get_json())["clients"]) == {"android", "linux-appimage"} async def test_the_index_is_an_empty_set_rather_than_a_404(app): """A server with no clients has an answer; it is just an empty one. 404 here would make the UI treat "nothing to offer" as a broken endpoint.""" resp = await app.test_client().get("/api/client") assert resp.status_code == 200 assert (await resp.get_json())["clients"] == {} @pytest.mark.parametrize("platform_id", ALL_IDS) async def test_the_bytes_need_authentication_even_though_the_version_does_not(app, platform_id): """Anyone who can reach the port may ask what version exists; only an account or a linked device may pull the payload.""" place(platform_id) resp = await app.test_client().get(f"/api/client/{platform_id}/download") assert resp.status_code == 401