CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 41s
The precedence test wrote `version_code=300` for all five platforms and compared the desktop's against the int it wrote. It comes back as `"300"`, because the module preserves each platform's own comparator type instead of flattening both to int — which is the behaviour the change it was testing had just introduced. A `coded()` helper now says which shape to expect and why, and the assertion runs over every non-Android platform rather than spot-checking `linux-deb`. The test caught a real inconsistency in itself precisely because it compared against a concrete value rather than round-tripping what it wrote.
407 lines
16 KiB
Python
407 lines
16 KiB
Python
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]
|
|
|
|
# `version_code` is "whatever this platform's comparator reads", and that is not one
|
|
# type. Android's install gate compares an integer; the desktop's updater compares
|
|
# Tauri's semver key. The tests carry both shapes for the same reason the module
|
|
# does — a suite that only ever wrote integers would pass while every desktop
|
|
# sidecar CI writes was being rejected.
|
|
ANDROID_CODE = 3503708
|
|
DESKTOP_CODE = "1.0.3503707"
|
|
|
|
|
|
def code_for(platform_id: str):
|
|
return ANDROID_CODE if BY_ID[platform_id].code_is_int else DESKTOP_CODE
|
|
|
|
|
|
def coded(platform_id: str, value: int):
|
|
"""`value` in the shape that platform's sidecar carries.
|
|
|
|
A test writing `version_code=300` gets `300` back from Android and `"300"` from
|
|
a desktop platform, because the module preserves each platform's own comparator
|
|
type rather than flattening both to int.
|
|
"""
|
|
return value if BY_ID[platform_id].code_is_int else str(value)
|
|
|
|
|
|
@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": code_for(platform_id),
|
|
"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_androids_code_must_be_an_integer():
|
|
"""`ClientRelease` in core/src/sync/client.rs declares it `i64`. A string here
|
|
would fail to deserialize on every phone in the field, so a sidecar carrying one
|
|
is not a client this server can honestly offer."""
|
|
place("android", version_code="1.0.3503707")
|
|
assert release("android") is None
|
|
|
|
|
|
def test_the_desktop_keeps_tauris_semver_key_verbatim():
|
|
"""It is not an integer and must not be coerced into one: this is the value the
|
|
desktop updater compares, and `1.0.3503707` truncated to `1` orders against
|
|
nothing."""
|
|
place("linux-deb")
|
|
assert release("linux-deb")["version_code"] == "1.0.3503707"
|
|
|
|
|
|
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
|
def test_an_empty_version_name_counts_as_no_client(platform_id):
|
|
"""A sidecar can be well-formed and still say nothing. A blank would render as
|
|
an empty space on the download card, which reads as a layout bug."""
|
|
place(platform_id, version_name="")
|
|
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"] == code_for(platform_id)
|
|
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
|
|
for platform_id in ALL_IDS:
|
|
if platform_id != "android":
|
|
assert found[platform_id]["version_code"] == coded(platform_id, 300), platform_id
|
|
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"] == code_for(platform_id)
|
|
|
|
|
|
@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
|