Files
thoughtsync/tests/test_client_dist.py
T
bvandeusen d77a79859c
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
server: hand out the Android client this server syncs with (2726)
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.

It also keeps the pair in step by construction. Client and server negotiate a
sync protocol version before linking, so a server that also serves the client
cannot hand out a phone it is unable to talk to.

**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.

**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.

**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.

**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.

**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.

The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.

Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
2026-08-20 20:11:32 -04:00

113 lines
4.0 KiB
Python

import json
import pytest
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
def app():
return create_app()
def place_client(payload: bytes = PAYLOAD, **overrides) -> dict:
"""Put a client + sidecar where the server looks. Overrides corrupt the pair."""
root = 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