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, MANIFEST_NAME, advertisement, android_release 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 two routes below ARE exercised, because neither opens a session: the metadata # route only stats files, and the download's 401 is returned before any token # lookup. PAYLOAD = b"not really an apk, but the server only ever stats it" @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 a real APK 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_client(payload: bytes = PAYLOAD, root: Path | None = None, **overrides) -> dict: """Put a client + sidecar where the server looks. Overrides corrupt the pair.""" root = root if root is not None else Config.client_root() root.mkdir(parents=True, exist_ok=True) (root / APK_NAME).write_bytes(payload) meta = { "version_name": "0.1.216", "version_code": 216, "size": len(payload), "sha256": "ab" * 32, } meta.update(overrides) (root / MANIFEST_NAME).write_text(json.dumps(meta), encoding="utf-8") return meta def test_absent_client_is_advertised_as_nothing_at_all(): """The KEY is missing, not null. A client testing for it then gets one unambiguous answer rather than having to tell "this server has no APK" apart from "this server predates the feature". """ assert android_release() is None assert advertisement() == {} def test_a_present_client_is_advertised_with_what_android_compares(): place_client() advertised = advertisement()["android_client"] assert advertised["version"] == "0.1.216" # The integer is what decides "is this newer", not the name — a name is a # string and sorts like one. assert advertised["version_code"] == 216 assert advertised["size"] == len(PAYLOAD) assert advertised["url"].endswith("/download") def test_a_sidecar_describing_a_different_build_counts_as_no_client(): """The likeliest real corruption: a new APK copied over an old sidecar. Serving one build while advertising another is worse than serving none — the phone would compare versions against a promise the bytes do not keep. """ place_client(size=999_999) assert android_release() is None assert advertisement() == {} def test_an_unreadable_sidecar_counts_as_no_client(): place_client() (Config.client_root() / MANIFEST_NAME).write_text("{ this is not json", encoding="utf-8") assert android_release() is None def test_a_sidecar_missing_a_field_counts_as_no_client(): root = Config.client_root() root.mkdir(parents=True, exist_ok=True) (root / APK_NAME).write_bytes(PAYLOAD) (root / MANIFEST_NAME).write_text(json.dumps({"version_name": "0.1.216"}), encoding="utf-8") assert android_release() is None def test_a_sidecar_with_no_apk_beside_it_counts_as_no_client(): root = Config.client_root() root.mkdir(parents=True, exist_ok=True) (root / MANIFEST_NAME).write_text(json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""})) assert android_release() is None async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app): place_client() resp = await app.test_client().get("/api/client/android") assert resp.status_code == 200 assert (await resp.get_json())["version_code"] == 216 async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app): resp = await app.test_client().get("/api/client/android") assert resp.status_code == 404 async def test_the_bytes_need_authentication_even_though_the_version_does_not(app): """Anyone who can reach the port may ask what version exists; only an account or a linked device may pull the 55 MiB.""" place_client() resp = await app.test_client().get("/api/client/android/download") assert resp.status_code == 401 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_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", 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_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300) place_client(version_name="0.1.99", version_code=99) advertised = android_release() # Lower version and all — precedence is about intent, not about newness. An # operator pinning an older client is doing it on purpose. assert advertised["version_code"] == 99 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_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300) place_client(size=999_999) # sidecar describing a different build assert android_release()["version_code"] == 300