Files
inkwell/tests/test_client_dist.py
T
bvandeusenandClaude Opus 5.5 65f004029b
CI & Build / Python lint (push) Successful in 3s
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 / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 49s
Signed-in app downloads are cached privately, not publicly
Quart's send_file marks every file it sends Cache-Control: public. The app
download is behind a login, so a shared cache or proxy could have kept one
account's copy and handed it to anyone. send_artifact now marks it
private, as the attachment route already does. Family idea #5105,
practice 11 (Scribe #5113).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:33:48 -04:00

495 lines
20 KiB
Python

import json
from pathlib import Path
import pytest
from inkwell import client_dist
from inkwell.app import create_app
from inkwell.client_dist import (
BY_ID,
PLATFORMS,
advertisement,
release,
releases,
)
from inkwell.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/inkwell/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_the_image_build_writes():
"""Pinned against `packaging/fetch-clients.sh`, which writes the APK and its
sidecar under these names when it bakes them into the image. The two are a pair
with nothing checking them against each other, so a rename on one side reads as
"no Android client" on the other rather than as an error.
Phones never ask for these names; they poll `/api/client/android`. The names
changed once, with the rename to Inkwell (Scribe note 5071).
"""
assert BY_ID["android"].artifact == "inkwell.apk"
assert BY_ID["android"].sidecar == "inkwell-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 = release("android")
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 release("android")["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 release("android")["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 release("android")["version_code"] == 300
# --- the advertisement -------------------------------------------------------
def test_the_advertisement_carries_the_whole_table():
place("linux-deb")
assert set(advertisement()["clients"]) == {"linux-deb"}
def test_a_server_with_no_clients_advertises_nothing():
assert advertisement() == {}
# --- 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
async def test_the_appimage_has_an_updater_manifest_built_from_the_same_build(app):
"""Tauri's single-build form: the ordering key it compares, the signature it
checks, and an absolute URL on the host that was asked."""
place("linux-appimage", signature="sig-bytes")
resp = await app.test_client().get(
"/api/client/linux-appimage/update.json", headers={"Host": "notes.example.com"}
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["version"] == code_for("linux-appimage")
assert body["signature"] == "sig-bytes"
assert body["url"] == "http://notes.example.com/api/client/linux-appimage/download"
@pytest.mark.parametrize("platform_id", [p.id for p in PLATFORMS if not p.signed])
async def test_an_unsigned_platform_has_no_updater_manifest(app, platform_id):
"""No signature, nothing the updater could verify, so nothing to offer it."""
place(platform_id)
resp = await app.test_client().get(f"/api/client/{platform_id}/update.json")
assert resp.status_code == 404
async def test_a_server_without_the_appimage_404s_its_updater_manifest(app):
"""The desktop turns this into "this server has no update to offer" rather than
"up to date", so the 404 has to be there to turn."""
resp = await app.test_client().get("/api/client/linux-appimage/update.json")
assert resp.status_code == 404
# --- serving the bytes (#5118, family idea #5103 practice 6) -----------------
#
# `send_artifact` is called directly in a request context: the route in front of it
# needs a signed-in account, and this suite has no database to sign one in with.
async def _send(app, headers: dict):
place("android")
found = release("android")
root = Path(Config.client_root())
async with app.test_request_context("/api/client/android/download", headers=headers):
resp = await client_dist.send_artifact(root, BY_ID["android"], found)
return resp, await resp.get_data()
async def test_the_etag_is_the_sha256_the_sidecar_records(app):
"""Content identity, not Quart's mtime-and-path: the same bytes after a redeploy
must still match a partial download a phone is resuming."""
resp, body = await _send(app, {})
assert resp.status_code == 200
assert resp.headers["ETag"] == f'"{"ab" * 32}"'
assert body == PAYLOAD
async def test_signed_in_bytes_are_never_cached_by_a_shared_cache(app):
"""Quart sends files `public`; behind a login that would let a proxy hand one
account's download to anyone."""
resp, _ = await _send(app, {})
directives = {d.strip().split("=")[0] for d in resp.headers["Cache-Control"].split(",")}
assert "private" in directives
assert "public" not in directives
async def test_a_range_request_gets_just_that_range(app):
resp, body = await _send(app, {"Range": "bytes=4-9"})
assert resp.status_code == 206
assert body == PAYLOAD[4:10]
assert resp.headers["Content-Range"] == f"bytes 4-9/{len(PAYLOAD)}"
assert resp.headers["Accept-Ranges"] == "bytes"
async def test_resuming_a_different_build_starts_again_from_the_top(app):
"""If-Range names the build the partial copy came from. A different one must
not be stitched onto it: the whole file comes back instead."""
resp, body = await _send(app, {"Range": "bytes=4-9", "If-Range": f'"{"cd" * 32}"'})
assert resp.status_code == 200
assert body == PAYLOAD
async def test_resuming_the_same_build_continues_it(app):
resp, body = await _send(app, {"Range": "bytes=4-9", "If-Range": f'"{"ab" * 32}"'})
assert resp.status_code == 206
assert body == PAYLOAD[4:10]
async def test_a_client_holding_this_build_is_told_nothing_changed(app):
resp, body = await _send(app, {"If-None-Match": f'"{"ab" * 32}"'})
assert resp.status_code == 304
assert body == b""
def test_the_download_throttle_reads_its_limit_from_settings():
"""The limit an admin saves is the one that applies, without a restart."""
from inkwell import ratelimit
from inkwell.settings import live
assert ratelimit.downloads_by_account.limit == live("client_downloads_per_hour")
assert ratelimit.downloads_by_account.window_s == 3600.0