CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
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 12s
CI & Build / Python tests (push) Successful in 18s
Android / Core and FFI clippy and tests (push) Successful in 45s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s
Three of the idea's practices this project still owed (Scribe #5118): Practice 3, CI fails on the wrong signer. The signing step printed the certificate and went on. It now fails unless the APK has exactly one signer and that signer is the release certificate (SHA-256 408a5835…, pinned from run 8753). The steps that publish come after it in the same job, so a wrongly signed build is never staged or published. Practice 6, app downloads are throttled and carry a sha256 ETag. - The download route counts per account and answers 429 with Retry-After past the limit. The limit is a new Settings → Security value, "App downloads per account per hour" (default 30), live like the sign-in limits. - The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a phone resuming a download across a redeploy is not told its partial copy is stale. Quart's own ETag and conditional handling are off, and the route runs the conditional pass after setting the ETag, so Range and If-Range are judged against the content. Practice 9, the update offer and debug builds. - The install-permission notice re-reads the grant each time the app comes back, as ReminderNotice does. Read once, it stayed up after someone granted the permission in Settings and came back. - A debuggable build says it can't update itself and checks for nothing. Android would refuse the release-signed APK over a debug signature anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
486 lines
19 KiB
Python
486 lines
19 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_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
|