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
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>
495 lines
20 KiB
Python
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
|