Server comments describe the code to a stranger (#6221)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 13s
Android / Build the server image (push) Skipped
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 / integration (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 32s

Rule 32 "Comments describe the product to a stranger" sweep over
src/, alembic/ and tests/. Comments and docstrings now say what the code
does and the reason as a present-tense constraint, briefly.

Removed:
- issue, task, note, rule, milestone and family-idea references
- "used to" / before-and-after narration
- quotes of decisions and incident stories from the deploying instance
- paragraph-length arguments, where a line or two carries the fact

Kept every hazard as a present-tense fact, e.g. timing oracles, the
proxy hop direction, and migrations that must stay frozen.

Also:
- Three test names lose "still"/"used to"/"pre_m304" phrasing.
- pyproject's description drops "FabledSword family".

The history these comments carried stays in Scribe and in git log.
A comment-stripping diff (Scribe snippet #6226 "codeonly.py — prove a
diff changed only comments") shows no code changed apart from the
renamed tests and the description string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:07:47 -04:00
co-authored by Claude Opus 5.5
parent 5556bb2136
commit a51a563fe7
108 changed files with 779 additions and 1610 deletions
+2 -3
View File
@@ -6,9 +6,8 @@ from inkwell.models.user import User
def test_visible_to_user_builds_owner_or_shared_predicate():
"""DB-free smoke test: the predicate compiles and references both the direct
(shares) and group (group_members) grant paths. Functional row-visibility is
verified against a live database in M1, once notes are a real shareable
resource (family practice: DB-backed tests run against the dev image, not CI).
(shares) and group (group_members) grant paths. Row visibility against a real
database is in test_integration.py.
"""
uid = uuid.uuid4()
# User stands in as a shareable resource purely to exercise the SQL builder.
+2 -5
View File
@@ -31,11 +31,8 @@ async def test_unknown_api_route_404s(app):
# --- the version a running server reports ------------------------------------
#
# Note 3127 §5 removed version tags, so this string is the only answer to "which
# build is this?" and nothing exists to contradict it when it is wrong. That makes
# the FALLBACK the interesting case rather than the happy path: it used to be
# `__version__`, so a server run from a checkout reported `0.2.0` — a real-looking
# version naming no build anybody could obtain.
# The only answer to "which build is this?", so the fallback matters most: it must
# be "unknown", never a plausible-looking `__version__`.
async def reported_version() -> str:
+2 -3
View File
@@ -1,8 +1,7 @@
"""The channel -> release tag mapping — `packaging/channel-tag.sh`.
A channel's release tag must never be spelled like a branch. The dev channel's tag
was `dev`, which shadowed the `dev` branch: once a clone had fetched tags,
`git push origin dev` failed as an ambiguous refspec (Scribe #2184).
A channel's release tag must never be spelled like a branch: a `dev` tag shadows
the `dev` branch, and `git push origin dev` then fails as an ambiguous refspec.
These run the script itself rather than reading it, so a mapping that is correct
on the page but broken in execution still fails here.
+16 -34
View File
@@ -14,28 +14,17 @@ from inkwell.client_dist import (
)
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.
# DB-free, so the advertisement is asserted through `advertisement()` rather than
# `/api/config`, whose other half needs a database. The routes below are exercised:
# none opens a session.
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.
# Tests about the mechanism rather than one platform run against every platform.
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.
# `version_code` is an integer for Android and Tauri's semver key for the desktop,
# so the tests write both shapes.
ANDROID_CODE = 3503708
DESKTOP_CODE = "1.0.3503707"
@@ -58,10 +47,8 @@ def coded(platform_id: str, value: int):
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.
A built image has real clients in `src/inkwell/client/`, so the tests must not
depend on which tree they run in.
"""
monkeypatch.setattr(client_dist, "BAKED_ROOT", tmp_path / "baked")
yield
@@ -121,8 +108,7 @@ def test_the_android_names_are_the_ones_the_image_build_writes():
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).
Phones never ask for these names; they poll `/api/client/android`.
"""
assert BY_ID["android"].artifact == "inkwell.apk"
assert BY_ID["android"].sidecar == "inkwell-android.json"
@@ -139,8 +125,7 @@ def test_an_absent_client_is_no_client(platform_id):
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".
A client testing for one gets an unambiguous answer.
"""
assert releases() == {}
assert advertisement() == {}
@@ -234,7 +219,7 @@ def test_a_present_client_reports_what_a_comparator_reads(platform_id):
assert not found["url"].startswith("http")
def test_the_android_payload_still_carries_every_field_it_used_to():
def test_the_android_payload_carries_every_field_phones_read():
"""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."""
@@ -303,15 +288,13 @@ 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.
# Even at a lower version: dropping in an older client is deliberate.
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."""
"""Dropping in one client must not retract the other four, as "first directory
holding anything wins" would."""
for platform_id in ALL_IDS:
place(platform_id, root=client_dist.BAKED_ROOT, version_code=300)
place("android", version_code=99)
@@ -326,8 +309,7 @@ def test_precedence_is_decided_per_platform_not_for_the_whole_set():
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.
The failure the copy-order advice in docs/android-distribution.md is about.
"""
place("android", root=client_dist.BAKED_ROOT, version_code=300)
place("android", size=999_999) # sidecar describing a different build
@@ -424,7 +406,7 @@ async def test_a_server_without_the_appimage_404s_its_updater_manifest(app):
assert resp.status_code == 404
# --- serving the bytes (#5118, family idea #5103 practice 6) -----------------
# --- serving the bytes ------------------------------------------------------
#
# `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.
+5 -7
View File
@@ -34,18 +34,16 @@ async def test_revoke_self_requires_auth(app):
def test_revoke_self_routes_to_the_self_revoke_view(app):
# The static rule must win over `/devices/<device_id>`: if "self" fell through to
# the id-keyed route it would be read as a malformed UUID and answer 404. Checked
# on the URL map, because every route behind login_required now reads the
# account's session epoch (#5173). The view's 400 for a caller with no bearer
# token is in test_integration.py.
# The static rule must win over `/devices/<device_id>`, or "self" would be read as
# a malformed UUID and 404. Checked on the URL map, because every route behind
# login_required reads the database. The view's 400 is in test_integration.py.
endpoint, _ = app.url_map.bind("localhost").match("/api/auth/devices/self", method="DELETE")
assert endpoint == "auth.revoke_own_device"
async def test_device_login_validates_input(app):
# Missing credentials → 400 BEFORE any DB access, so it's checkable in the
# DB-free unit lane (invalid-cred and success paths are operator-verified).
# Missing credentials → 400 before any DB access, so it's checkable without a
# database. The other paths are in test_integration.py.
client = app.test_client()
resp = await client.post("/api/auth/device-login", json={})
assert resp.status_code == 400
+1 -1
View File
@@ -76,7 +76,7 @@ def test_a_script_without_exactly_one_placeholder_is_not_served(script):
def test_the_public_address_setting_beats_the_request_host():
"""Behind a reverse proxy the Host this request arrived with may be an internal
one; Settings → Public address is what the operator says people use."""
one; Settings → Public address is the one people use."""
assert server_address("https://notes.example.com", "http", "inkwell:5000") == "https://notes.example.com"
+64 -120
View File
@@ -1,17 +1,11 @@
"""The real-Postgres lane (family rule 6).
Everything else in this suite is deliberately DB-free, which means the schema the
migrations build has never been checked against the models that read it. That gap is
what this file closes, and it is not theoretical: M13 dropped three columns and
rebuilt a generated column, and until now `alembic upgrade head` ran for the first
time when the operator's container started.
"""The real-Postgres lane: the schema the migrations build, against the models and
routes that use it.
Marked `integration` and excluded from the unit lane by `-m "not integration"`, so a
workstation without Postgres runs the rest of the suite unchanged.
The schema comes from real migrations, never `metadata.create_all` (rule 82) — the
point is to test what actually ships, and `create_all` would build a schema no
deployment has ever seen.
The schema comes from real migrations, never `metadata.create_all`, so it is the one
that ships.
"""
from __future__ import annotations
@@ -92,7 +86,7 @@ async def owner(db):
async def test_the_migrated_schema_matches_the_models(db, owner):
"""The check that has never run: insert through the ORM, read it back.
"""Insert through the ORM, read it back.
A column the models expect and the migrations never created — or the reverse —
fails right here, instead of when a container starts.
@@ -109,7 +103,7 @@ async def test_the_migrated_schema_matches_the_models(db, owner):
async def test_the_dropped_columns_are_actually_gone(db):
"""M13 dropped three. If a migration silently no-opped, this is where it shows."""
"""Dropped columns are really gone; a migration that silently no-opped shows here."""
cols = set(
(
await db.execute(
@@ -143,11 +137,7 @@ async def test_the_dropped_columns_are_actually_gone(db):
async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
"""0026 had to drop and recreate a STORED GENERATED column.
Postgres refuses to drop a column another generated column depends on, so getting
this wrong doesn't produce a subtly wrong ranking — it produces a migration that
won't run at all. Worth proving the replacement actually indexes something.
"""0026 recreates a stored generated column; the replacement must index something.
"""
note = Note(owner_id=owner.id, body="ferry tickets\nbook before friday", display_title="ferry tickets")
db.add(note)
@@ -173,12 +163,7 @@ async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
async def test_a_note_keeps_its_prose_on_both_sides_of_its_list(db, owner):
"""The shape M304 made expressible at all.
The old model could not hold this: a row had a position in a table and none in the
text, so a checklist could only ever render AFTER the body. Prose, list, prose is
the case that proves the storage changed, not just the styling.
"""
"""Prose, list, prose: a checklist can sit between two paragraphs."""
body = "weekend shop\n\n- [ ] milk\n- [x] eggs\n\nback before six"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note)
@@ -191,12 +176,7 @@ async def test_a_note_keeps_its_prose_on_both_sides_of_its_list(db, owner):
async def test_ticking_an_item_is_a_body_edit(db, owner):
"""What replaced `_apply_note_items`: there is no separate thing left to apply.
The regression that function guarded against — a sync silently eating a checklist
off a note that also had a body — cannot recur, because there is nothing to delete.
A pushed body either has the lines or it does not.
"""
"""A pushed body carries its checklist; there is nothing separate to apply."""
body = "packing\n\n- [ ] socks"
note = Note(owner_id=owner.id, body=body, display_title="packing")
db.add(note)
@@ -213,11 +193,10 @@ async def test_ticking_an_item_is_a_body_edit(db, owner):
async def test_a_standalone_tag_leaves_the_body_and_becomes_an_ordinary_label(db, owner):
"""M311. The tag was being shown twice — as text and as a chip — so the text goes.
"""A tag on its own line leaves the text and shows as a chip.
`via_tag=False` is the load-bearing half. It is what makes the chip's × appear in
both editors (they gate it on exactly this), which matters because deleting the
text is no longer a way to remove the tag: there is no text.
`via_tag=False` is what makes the chip's × appear in both editors, the only way
to remove a tag with no text.
"""
note = Note(owner_id=owner.id, body="#todo\nreorganize the homepage", display_title="#todo")
db.add(note)
@@ -226,8 +205,7 @@ async def test_a_standalone_tag_leaves_the_body_and_becomes_an_ordinary_label(db
await db.commit()
assert note.body == "reorganize the homepage"
# Re-derived by the lift itself. Every caller sets display_title BEFORE calling,
# so if the function did not do this the note would be named after a line it had
# Re-derived by the lift itself, or the note would be named after a line it had
# just deleted.
assert note.display_title == "reorganize the homepage"
@@ -237,11 +215,8 @@ async def test_a_standalone_tag_leaves_the_body_and_becomes_an_ordinary_label(db
async def test_a_tag_moved_onto_its_own_line_graduates_instead_of_vanishing(db, owner):
"""The bug a naive lift has, pinned.
A tag that is still in prose stays derived. Move it to its own line and it must
become an ordinary label — NOT be detached for no longer appearing in the body,
which is what happens if the row is dropped before it is graduated.
"""A tag in prose stays derived. Moved to its own line it becomes an ordinary
label, rather than being detached for no longer appearing in the body.
"""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
@@ -265,8 +240,7 @@ async def test_a_tag_moved_onto_its_own_line_graduates_instead_of_vanishing(db,
async def test_deleting_an_inline_tag_still_detaches_it(db, owner):
"""The old behaviour, unchanged where the text is unchanged. A tag still living in
prose is still owned by that prose."""
"""A tag still in prose is still owned by that prose."""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
@@ -281,11 +255,7 @@ async def test_deleting_an_inline_tag_still_detaches_it(db, owner):
async def test_a_note_with_only_a_list_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe, still closed — by a different
mechanism. There is no item table to fall back to any more; the name comes from
the first line with its marker stripped, because calling the note "- [ ] milk"
would show someone the storage instead of the note.
"""
"""A checklist-only note is named by its first item, without the marker."""
body = "- [ ] milk\n- [ ] eggs"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note)
@@ -297,10 +267,9 @@ async def test_a_note_with_only_a_list_still_has_a_name(db, owner):
async def test_auto_unfurl_stores_a_preview_and_skips_what_is_cached(db, owner, monkeypatch):
"""The background pass, run inline so the assertions are deterministic.
The network is stubbed — this is about what reaches the DATABASE, not about
parsing someone's OpenGraph tags (unfurl.py's own tests cover that). What matters
here is the part only a real database can show: the unique constraint holding, the
upsert going to the right row, and a second pass not re-fetching.
The network is stubbed; this is about what reaches the database: the unique
constraint holding, the upsert going to the right row, and a second pass not
re-fetching.
"""
note = Note(
owner_id=owner.id,
@@ -373,11 +342,10 @@ async def test_detection_agrees_with_what_gets_stored(db, owner, monkeypatch):
async def test_registration_closes_itself_once_an_admin_exists(app_client, db):
"""The gap this removes: registration was open between "my account exists" and
"I remembered to turn it off", and on a public host that gap starts at DNS.
"""Registration closes as soon as the admin account exists.
Runs against a real database because it is the interaction between two writes —
the user row and the settings row — inside one transaction.
Real database: it is two writes, the user row and the settings row, in one
transaction.
"""
# The instance is empty (the fixture truncated it), so this is the first account:
# allowed unconditionally, and it becomes the admin.
@@ -413,11 +381,9 @@ async def test_registration_closes_itself_once_an_admin_exists(app_client, db):
async def test_security_settings_are_live_and_bounded(app_client, db):
"""The security values are settings now, not constants — so saving one has to take
effect without a restart, and a dangerous value has to be refused.
Real database because the whole point is the round trip: write through the admin
API, re-read into the cache the throttle consults, observe the new number.
"""Saving a security setting takes effect without a restart, and a dangerous
value is refused: write through the admin API, re-read into the throttle's cache,
observe the new number.
"""
# An admin to authenticate as. First account, so it is allowed and becomes admin.
reset_live()
@@ -433,9 +399,7 @@ async def test_security_settings_are_live_and_bounded(app_client, db):
assert live("trusted_proxy_hops") == 1
assert live("signin_limit_per_account") == 10
# A value that would disable the protection is REFUSED, not clamped — storing a
# different number than the one typed is how somebody ends up believing a limit
# is set to something it is not.
# A value that would disable the protection is refused, not clamped.
bad = await app_client.patch("/api/settings", json={"signin_limit_per_account": 0})
assert bad.status_code == 400
assert "at least" in (await bad.get_json())["error"]
@@ -498,14 +462,8 @@ async def _revision_count(db, note_id) -> int:
async def test_a_session_of_edits_costs_one_revision(db, owner):
"""The change that makes autosave affordable.
Version history used to snapshot on EVERY body write, so the clients saved as
rarely as they could — only when an editor closed — and a crash mid-session lost
everything typed. Durability was paying for history. Now a sitting earns one
revision no matter how many times it is written, so a client can write whenever
it likes.
"""
"""A sitting earns one revision however often it is written, so clients can
autosave."""
note = Note(owner_id=owner.id, body="one", display_title="one")
db.add(note)
await db.commit()
@@ -563,13 +521,8 @@ async def test_renaming_a_tag_onto_an_existing_one_merges_into_the_older(app_cli
"""Renaming a tag onto a name another tag holds merges them, and the OLDER row
is the survivor — whichever side the caller happened to be renaming.
Runs against a real database because the whole question is about `created_at`
ordering and the note_labels rows moving, neither of which a unit test sees.
Age decides, rather than "the one that already held the name", so that renaming
A→B and renaming B→A land on the same row. If the incumbent won, the survivor
would depend on which way round someone typed it, and two clients racing the
same tidy-up would disagree about which id still exists.
Age decides, so renaming A→B and B→A land on the same row and two clients racing
the same tidy-up agree on which id survives.
"""
reg = await app_client.post(
"/api/auth/register",
@@ -626,9 +579,8 @@ async def test_the_rename_merge_survivor_does_not_depend_on_the_direction(app_cl
async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one(app_client, db):
"""A case-sensitive match here used to mint "Groceries" beside "groceries". No
synced client can hold both — their `labels` index is unique on `lower(name)` —
so the pair was a pull that would fail later, on a phone, with no UI in the path.
"""Case-insensitive: no synced client can hold both "Groceries" and "groceries",
since their `labels` index is unique on `lower(name)`.
"""
reg = await app_client.post(
"/api/auth/register",
@@ -646,12 +598,10 @@ async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one
assert len(listing) == 1
# --- A deleted tag is a tombstone on every path (#5382) ---------------------------
# --- A deleted tag is a tombstone on every path ----------------------------------
#
# The web deleted the row outright, so the change feed never carried the delete and
# devices kept the tag. A device's delete left a tombstone the web still listed and
# still matched by name. One definition now: `labeling.tombstone_label`, and every
# read of the catalog goes through `labeling.live`.
# So the change feed tells devices. One definition, `labeling.tombstone_label`, and
# every read of the catalog goes through `labeling.live`.
async def _label_in_feed(app_client, lid: str) -> dict | None:
@@ -666,7 +616,7 @@ async def _push_label(app_client, lid: str, op: str, edited_at: str, name: str =
async def test_a_tag_listing_says_when_each_was_last_used(app_client, db):
"""The rail keeps the most recent few (#5931): a tag is as recent as the newest
"""The rail keeps the most recent few: a tag is as recent as the newest
note carrying it, and a note in the trash does not count."""
await _signed_in(app_client, "lastused")
used = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
@@ -751,11 +701,10 @@ async def test_a_device_can_make_a_deleted_tags_name_again(app_client, db):
assert [lb["id"] for lb in listing] == [new]
# --- One save path for a note's text (#5164) ---------------------------------
# --- One save path for a note's text -----------------------------------------
#
# A body edit is a sequence: keep a revision, rename the note, lift #tags, commit,
# queue link previews. It was written out once per route, and the copies drifted —
# restoring a revision skipped the previews. These pin the sequence at each door.
# queue link previews. These pin the sequence at each route.
async def _signed_in(app_client, who: str):
@@ -774,9 +723,7 @@ def _record_previews(monkeypatch, module: str) -> list[tuple[str, str]]:
async def test_restoring_a_revision_queues_previews_for_its_links(app_client, db, monkeypatch):
"""The bug that motivated the shared path: restore was the one copy of the edit
sequence without the unfurl step, so a link brought back by a restore stayed a
bare URL on every surface."""
"""A link brought back by a restore gets its preview."""
await _signed_in(app_client, "restore")
queued = _record_previews(monkeypatch, "inkwell.notes")
@@ -845,8 +792,8 @@ async def test_a_pushed_note_is_named_tagged_and_queued_like_a_typed_one(app_cli
async def test_a_pushed_edit_keeps_the_clients_edit_time_when_a_tag_is_lifted(app_client, db):
"""Last-write-wins compares edit times, so the stored one has to be the client's.
Lifting a tag rewrites the body in a second flush, and the column's onupdate
used to stamp the server's clock over the time the client sent."""
Lifting a tag rewrites the body in a second flush, where the column's onupdate
would stamp the server's clock over the time the client sent."""
await _signed_in(app_client, "edited")
nid = str(uuid.uuid4())
await app_client.post(
@@ -858,7 +805,7 @@ async def test_a_pushed_edit_keeps_the_clients_edit_time_when_a_tag_is_lifted(ap
assert datetime.fromisoformat(note["updated_at"]) == datetime(2026, 1, 1, tzinfo=timezone.utc)
# --- attachments as a sync entity (#5168) ---------------------------------------
# --- attachments as a sync entity -----------------------------------------------
async def _note_revision(app_client, nid: str) -> int:
@@ -912,8 +859,7 @@ async def test_an_offline_attachment_uploads_once_under_its_own_id(app_client, d
async def test_a_purged_note_and_its_files_read_as_gone(app_client, db):
"""A purged note reads as absent (#2128). Opening it and its attachment selected
with the ACL inline and skipped that filter, so a tombstone came back 200 (#5383)."""
"""A purged note reads as absent, opened directly or through its attachment."""
await _signed_in(app_client, "purged")
nid = await _pushed_note(app_client)
aid = str(uuid.uuid4())
@@ -1008,7 +954,7 @@ async def test_a_preview_arriving_or_leaving_moves_its_note_in_the_feed(app_clie
assert await _note_revision(app_client, nid) > arrived
# --- the export format, pinned against the shared fixture (#5170) ---------------
# --- the export format, pinned against the shared fixture ----------------------
async def test_an_export_writes_the_keys_the_shared_fixture_names(app_client, db):
@@ -1043,7 +989,7 @@ async def test_an_export_writes_the_keys_the_shared_fixture_names(app_client, db
async def test_a_keep_note_that_is_only_a_photo_imports(app_client, db):
"""It used to be skipped as empty. The core's importer keeps it as well."""
"""A photo-only note is a note, as in the core's importer."""
import io
import json
import zipfile
@@ -1066,7 +1012,7 @@ async def test_a_keep_note_that_is_only_a_photo_imports(app_client, db):
assert att.mime == "image/png"
# ---- invites (#5172) ---------------------------------------------------------------
# ---- invites -----------------------------------------------------------------------
_PASSWORD = "a-long-enough-password"
@@ -1082,8 +1028,8 @@ async def _admin_with_invite(app_client, **body) -> str:
async def test_the_first_account_is_refused_once_the_setup_window_has_closed(db):
"""A fresh server on a public address must not belong to whoever finds it first
(family idea #5105, practice 8). Past the window, nobody becomes admin."""
"""A fresh server on a public address must not belong to whoever finds it first.
Past the window, nobody becomes admin."""
from inkwell.auth import SETUP_WINDOW_S
ratelimit.reset_all()
@@ -1217,8 +1163,7 @@ async def test_two_people_racing_one_invite_cannot_both_get_in(app_client, db):
assert count == 2, "the admin and exactly one of the two"
# --- Admin-issued password reset links (#5173) ---------------------------------
# --- Admin-issued password reset links -----------------------------------------
async def _admin_and_guest(app_client):
@@ -1323,7 +1268,7 @@ async def test_a_short_password_leaves_the_reset_link_usable(app_client, db):
assert ok.status_code == 200
# --- Storage per account (#2939 §4) ---------------------------------------------
# --- Storage per account --------------------------------------------------------
_GB = 1024**3
@@ -1417,7 +1362,7 @@ async def _back_to_default(key: str) -> None:
await fresh.commit()
# --- Changing a password, and signing out everywhere else (#5105, practice 4) ---
# --- Changing a password, and signing out everywhere else ----------------------
async def _elsewhere(app_client):
@@ -1505,14 +1450,13 @@ async def test_sign_out_elsewhere_from_a_device_keeps_that_device(app_client, db
async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_client, db):
"""A session-cookie caller holds no device token, so "revoke the one I'm using"
has nothing to name. Moved here from the unit lane when the session check began
reading the account (#5173)."""
has nothing to name."""
await _signed_in(app_client, "web")
resp = await app_client.delete("/api/auth/devices/self")
assert resp.status_code == 400
# --- The audit log (#2939 §5) --------------------------------------------------
# --- The audit log -------------------------------------------------------------
async def _activity(client) -> list[dict]:
@@ -1580,7 +1524,7 @@ async def test_activity_older_than_its_retention_is_swept(app_client, db):
assert emails == {"owner@example.test"}
# --- Sharing a note (#5174) ---------------------------------------------------
# --- Sharing a note -----------------------------------------------------------
#
# Owner, a recipient, and a stranger who is on the instance but not shared with.
@@ -1656,7 +1600,7 @@ async def test_a_view_share_writes_nothing_and_an_edit_share_writes_only_text(ap
nid = await _owners_note(app_client, "first line\n- [ ] milk")
await _share(app_client, nid, people["recipient"], "view")
# View: every write is a 404, the same answer a stranger gets (#1984).
# View: every write is a 404, the same answer a stranger gets.
writes = [
recipient.patch(f"/api/notes/{nid}", json={"body": "mine now"}),
recipient.patch(f"/api/notes/{nid}/items/0", json={"checked": True}),
@@ -1684,7 +1628,7 @@ async def test_a_view_share_writes_nothing_and_an_edit_share_writes_only_text(ap
assert "fromguest" in owner_tags
assert (await (await recipient.get("/api/labels")).get_json())["labels"] == []
# Everything else but their own pin and archive (#5176) stays the owner's.
# Everything but their own pin and archive stays the owner's.
assert (await recipient.patch(f"/api/notes/{nid}", json={"remind_at": "2099-01-01T00:00:00Z"})).status_code == 403
assert (await recipient.patch(f"/api/notes/{nid}", json={"body": "x", "recurrence": "daily"})).status_code == 403
assert (await recipient.post(f"/api/notes/{nid}/trash")).status_code == 404
@@ -1769,7 +1713,7 @@ async def test_a_share_names_someone_else_on_this_instance(app_client, db):
assert (await app_client.delete(f"/api/notes/{other}/shares/{sid}")).status_code == 404
# --- Shared notes over sync (#5175) -------------------------------------------
# --- Shared notes over sync ---------------------------------------------------
async def _feed(client, since: int = 0, shares: bool = True) -> dict:
@@ -1794,7 +1738,7 @@ async def test_a_share_reaches_the_recipients_feed_and_a_revoke_takes_it_back(ap
assert held is not None, "the grant moved the note past the recipient's cursor"
assert (held["permission"], held["shared_by"]["display_name"], held["labels"]) == ("view", "owner", [])
assert granted["revoked"] == []
# A client that never asked for shares gets only its own notes, as before.
# A client that never asked for shares gets only its own notes.
assert _feed_note(await _feed(recipient, shares=False), nid) is None
assert "revoked" not in await _feed(recipient, shares=False)
assert _feed_note(await _feed(stranger), nid) is None
@@ -1902,7 +1846,7 @@ async def test_deleting_a_shared_note_reaches_the_recipients_devices(app_client,
assert _feed_note(gone, nid) is None
# --- Groups (#5177) ------------------------------------------------------------
# --- Groups --------------------------------------------------------------------
async def _group(app_client, name: str, *member_ids: str) -> str:
@@ -1982,7 +1926,7 @@ async def test_deleting_a_group_ends_every_share_made_to_it(app_client, db):
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["shared"] is False
# --- Password reset by email (#5266) ------------------------------------------
# --- Password reset by email --------------------------------------------------
#
# The SMTP hand-off is replaced by a list; everything up to it is real.
@@ -2024,7 +1968,7 @@ async def test_the_smtp_password_never_leaves_the_server(app_client, db):
assert (await app_client.patch("/api/settings", json={"smtp_password": ""})).status_code == 200
async with session_scope() as fresh:
assert await get_setting(fresh, "smtp_password") == "hunter2"
# And the row itself holds it sealed, not as text (#5927).
# And the row itself holds it sealed, not as text.
row = await fresh.get(Setting, "smtp_password")
assert "hunter2" not in row.value and sealing.PREFIX in row.value
@@ -2035,7 +1979,7 @@ async def test_the_smtp_password_never_leaves_the_server(app_client, db):
async def test_a_credential_stored_before_sealing_is_sealed_and_a_lost_key_asks_again(app_client, db):
await _mail_off()
await _admin_with_invite(app_client)
# As an instance from before #5927 left it: the password as plain JSON.
# A password stored in the clear, as plain JSON.
async with session_scope() as fresh:
fresh.add(Setting(key="smtp_password", value='"hunter2"'))
await fresh.commit()
@@ -2150,7 +2094,7 @@ async def test_the_test_email_goes_to_the_admin_and_reports_a_failure(app_client
assert "bad credentials" in (await failed.get_json())["error"]
# --- backups (#5918–#5921) --------------------------------------------------------------
# --- backups ------------------------------------------------------------------------
async def _backup_settings_off() -> None:
+29 -56
View File
@@ -58,8 +58,8 @@ def test_all_note_routes_registered(app):
)
}
assert expected <= registered, f"unregistered note routes: {expected - registered}"
# Removed in #5178: an item is added, reworded and removed by editing the body,
# and previews arrive in the background after a save. Saved views went in #5180.
# Items are edited in the body, previews arrive after a save, and there are no
# saved views.
gone = {"notes.add_item", "notes.delete_item", "notes.unfurl_link", "saved_filters.list_saved"}
assert not gone & registered, f"removed note routes are back: {gone & registered}"
@@ -80,7 +80,7 @@ def test_normalize_color():
def test_palette_has_core_colors():
# A LABEL's vocabulary since M315 — a note has no colour to be one of these.
# A label's vocabulary; a note has no colour.
for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"):
assert c in NOTE_COLORS
@@ -88,7 +88,7 @@ def test_palette_has_core_colors():
def test_serialize_shape():
n = Note(body="b", pinned=True, archived=False)
s = n.serialize()
assert "title" not in s # there is no title field any more (M13 step 3)
assert "title" not in s # a note's name is its first line, not a field
assert s["body"] == "b"
assert s["pinned"] is True
assert s["archived"] is False
@@ -131,13 +131,6 @@ async def test_reorder_requires_auth(app):
assert resp.status_code == 401
# [[wiki-links]] are gone entirely (note 2897), and with them backlinks, the graph,
# the name index and the `[[` autocomplete. So are the two helpers that used to keep
# links alive across a rename, and the id-binding that briefly replaced them. Nothing
# here asserts their absence — `test_all_note_routes_registered` below is what would
# notice a route coming back, and the removal is one commit rather than a fossil.
def test_derive_display_title_is_the_first_body_line():
assert derive_display_title("first line\nsecond line") == "first line"
assert derive_display_title(" spaced first \nnext") == "spaced first"
@@ -146,10 +139,7 @@ def test_derive_display_title_is_the_first_body_line():
def test_derive_display_title_names_a_list_only_note():
# The same property the old `first_item` fallback protected — a note that is only
# a checklist still has a name — reached a different way. Items ARE body lines now
# (M304), so the first one is simply the first line, with its marker stripped:
# calling the note "- [ ] milk" would show someone the storage instead of the note.
# A checklist-only note is named by its first item, without the marker.
assert derive_display_title("- [ ] milk\n- [ ] eggs") == "milk"
assert derive_display_title(" * [x] eggs ") == "eggs"
# Prose still wins when it comes first, because it IS the first line.
@@ -177,11 +167,8 @@ def test_split_body_tags_lifts_only_a_line_that_is_nothing_else():
"""The rule, in the cases it exists to get right.
A tag on a line of its own is filing and the line can go. A tag sharing a line
with words is part of what was written, and taking it out would leave "remember to
call" — so the line is left exactly as typed. The trailing-tag case (`buy milk
#grocery`) is deliberately on the conservative side of the line: it reads like
filing, but nothing in the text distinguishes it from `remember to call #mom`, and
guessing wrong mangles a sentence to save a duplicate chip.
with words is left as typed, including `buy milk #grocery`: nothing distinguishes
it from `remember to call #mom`.
"""
assert split_body_tags("#todo\nreorganize the homepage") == (["todo"], [], "reorganize the homepage")
assert split_body_tags("needs a tauri app\n#todo") == (["todo"], [], "needs a tauri app")
@@ -224,14 +211,11 @@ def test_split_body_tags_keeps_a_tag_derived_when_prose_still_carries_it():
assert body == "remember the #todo list"
def test_migration_0028_lifts_exactly_what_the_app_lifts_today():
def test_migration_0028_lifts_exactly_what_the_app_lifts():
"""The 0028 data migration rewrites note bodies, and that is not undoable.
It carries its OWN frozen copy of the rule rather than importing
`split_body_tags`, on 0027's principle that a migration must keep producing what
it produced the day it ran. This does not assert the two agree — they are allowed
to diverge later, which is the entire point of freezing one. It pins the frozen
copy against fixed expectations, so nobody can "tidy" it into eating prose.
It carries its own frozen copy of the rule, which may diverge from
`split_body_tags` later. This pins the frozen copy against fixed expectations.
"""
import importlib.util
from pathlib import Path
@@ -286,7 +270,7 @@ def test_parse_list_items():
def test_parse_dt():
# A full ISO instant round-trips (used to validate the Timeline date range).
# A full ISO instant round-trips (validates the creation-date range).
d = parse_dt("2026-07-19T12:30:00+00:00")
assert (d.year, d.month, d.day, d.hour, d.minute) == (2026, 7, 19, 12, 30)
assert d.tzinfo is not None
@@ -440,9 +424,7 @@ def test_usec_to_dt():
def test_keep_spec_list_note_keeps_its_text_too():
# Keep's own notes carry one or the other, but its textContent used to be
# DISCARDED whenever a note also had listContent, because a note could only be
# one kind. A note holds both now, so nothing is dropped on the way in.
# A Keep note with both textContent and listContent keeps both.
kn = {
"title": "Groceries",
"textContent": "for the weekend",
@@ -477,8 +459,7 @@ def test_keep_spec_text_note_folds_annotation_urls_and_drops_color():
}
spec = _keep_spec(kn, "Takeout/Keep")
assert "https://example.com" in spec["body"]
# BROWN used to map to the nearest hue we had. There is no hue to map TO now, so a
# Keep import brings across everything except the one thing this app stopped having.
# A note has no colour, so a Keep note's colour is not imported.
assert "color" not in spec
# attachment path is resolved relative to the note JSON's folder
assert spec["attachments"] == [{"file": "Takeout/Keep/img.jpg", "mime": "image/jpeg"}]
@@ -488,8 +469,7 @@ def test_native_spec_roundtrip_fields():
n = {
"title": "T",
"body": "b",
# An export taken before M315 still carries this. Reading past it rather than
# rejecting the file is the whole point — old exports must still import.
# Older exports carry a note colour; reading past it keeps them importable.
"color": "blue",
"pinned": True,
"archived": False,
@@ -499,8 +479,8 @@ def test_native_spec_roundtrip_fields():
"attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}],
}
spec = _native_spec(n)
# The spec still CARRIES a title — an export taken before M13 has one, and
# _create_imported_note folds it into the body rather than dropping it.
# The spec carries a title (Keep notes and older exports have one), which
# _create_imported_note folds into the body.
assert spec["title"] == "T"
assert spec["body"] == "b"
assert "color" not in spec
@@ -530,12 +510,10 @@ def test_detect_urls_ignores_non_http():
assert detect_urls("") == []
# --- checklist items: the body IS the checklist (M304) -----------------------
# --- checklist items: the body is the checklist ------------------------------
#
# The same table of cases as core/src/local/derive.rs. Deliberately duplicated
# rather than shared: the point of three implementations is that each is checked
# against the same grammar, and a test that only ran once would not catch the two
# drifting apart.
# The same cases as core/src/local/derive.rs, duplicated so each implementation is
# checked against the grammar.
def test_parse_items_reads_a_list_out_of_prose():
@@ -544,7 +522,7 @@ def test_parse_items_reads_a_list_out_of_prose():
def test_parse_items_between_paragraphs():
# The case a side table could not express, which is the whole reason for M304.
# A list between two paragraphs.
assert [i.text for i in parse_items("before\n- [ ] middle\nafter")] == ["middle"]
@@ -603,9 +581,8 @@ def test_strip_marker():
def test_the_migration_folds_exactly_like_the_app():
"""0027 inlines its own copy of append_item, deliberately — a migration has to keep
producing what it produced the day it ran, so it must not follow the app if the
app's spacing ever changes. This is what keeps the copy honest until then."""
"""0027 inlines its own copy of append_item, frozen; this keeps the two in step
while the app's spacing is unchanged."""
import importlib.util
from pathlib import Path
@@ -628,9 +605,8 @@ def test_the_migration_folds_exactly_like_the_app():
def test_note_markdown_writes_a_checklist_once():
"""The export used to append the items after the body. The body IS them now
(M304), so the old branch would have doubled every checklist in an export — and
doubled it again on the next re-import."""
"""The checklist is written once, as part of the body, so a re-import doesn't
double it."""
note = Note(
display_title="shopping",
body="shopping\n\n- [ ] milk\n- [x] eggs",
@@ -650,11 +626,9 @@ def test_native_spec_of_a_current_export_folds_nothing():
assert _native_spec({"body": "a\n\n- [ ] milk"})["items"] == []
def test_native_spec_of_a_pre_m304_export_still_carries_its_items():
# An export taken BEFORE this milestone has a body with no task lines and a
# separate items array. Importing one has to put the checklist back — which is
# the same fold the Keep importer does, and the reason _insert_note still accepts
# items at all.
def test_native_spec_of_an_export_with_separate_items_folds_them():
# An older export has a separate items array; importing folds it into the body,
# as the Keep importer does.
old = {"body": "shopping", "items": [{"text": "milk", "checked": True}]}
assert _native_spec(old)["items"] == [{"text": "milk", "checked": True}]
@@ -668,9 +642,8 @@ def _export_zip(app_marker: str) -> zipfile.ZipFile:
@pytest.mark.parametrize("marker", ["inkwell", "thoughtsync"])
def test_an_export_imports_under_either_name(marker):
# "thoughtsync" is what every export written before the rename to Inkwell says.
# Those zips are backups, so they have to keep restoring as native exports rather
# than falling through to the Keep importer, which would find nothing in them.
# Exports from when the app was called ThoughtSync say "thoughtsync", and must
# import as native exports rather than fall through to the Keep importer.
specs, source = _read_import_specs(_export_zip(marker), _ImportBudget())
assert source == "inkwell"
assert [s["body"] for s in specs] == ["kept"]
+7 -13
View File
@@ -11,13 +11,9 @@ PEER = "10.0.0.1" # the socket address: our own proxy, or the caller when unpro
def test_default_is_one_hop():
# One reverse proxy terminating TLS — this deployment, and the only shape that is
# safe to assume. A wrong default here is a silent security bug, not a preference.
#
# Read through live() rather than the registry: live() is what proxy.py actually
# calls, and it is seeded from the defaults at import time so the value is right
# before the first database read. A boot that never reached the DB must still
# count one hop, not zero.
# One reverse proxy terminating TLS, the only shape safe to assume. Read through
# live(), which proxy.py calls and which is seeded from the defaults at import, so
# a boot that never reached the database still counts one hop.
assert live("trusted_proxy_hops") == 1
@@ -32,9 +28,8 @@ def test_one_hop_reads_what_our_proxy_wrote():
def test_a_forged_prefix_is_never_selected():
# THE test. A caller sends `X-Forwarded-For: 1.2.3.4`; our proxy appends the
# address it actually saw. Reading from the left would hand the caller a fresh
# rate-limit bucket for every value they invent.
# A caller sends `X-Forwarded-For: 1.2.3.4`; our proxy appends what it saw.
# Reading from the left would give the caller a fresh bucket per invented value.
assert forwarded_for("1.2.3.4, 203.0.113.7", PEER, 1) == "203.0.113.7"
# …and padding it doesn't help either.
assert forwarded_for("a, b, c, d, 203.0.113.7", PEER, 1) == "203.0.113.7"
@@ -47,9 +42,8 @@ def test_two_hops_sees_past_a_cdn():
def test_a_short_header_falls_back_rather_than_reaching_left():
# Fewer proxies than configured. Reaching further left would start believing
# entries no proxy of ours wrote, so the safe direction is the socket address —
# at worst several callers share one bucket.
# Fewer proxies than configured: the socket address, rather than an entry no
# proxy of ours wrote.
assert forwarded_for("203.0.113.7", PEER, 2) == PEER
assert forwarded_for("", PEER, 1) == PEER
+5 -10
View File
@@ -1,12 +1,8 @@
"""The credential throttle. DB-free, like the rest of this suite.
The window itself is exercised directly with an injected clock, so nothing here
sleeps: a 15-minute window tested in real time is a test nobody runs twice.
The routes are exercised only as far as they get WITHOUT a database — a throttled
request returns 429 before any session is opened, which is the whole point of
checking the limit before the password. The happy path can't be reached here and is
not pretended at.
The window is exercised with an injected clock, so nothing sleeps. The routes are
exercised only as far as they get without a database: a throttled request returns
429 before any session is opened.
"""
import time
@@ -101,9 +97,8 @@ async def test_login_starts_refusing(app):
# Pre-load the account's counter to its limit rather than posting that many
# times: every real attempt would need a database to reach the password check.
#
# On the REAL clock, not an injected one. The window is trailing, so hits stamped
# at t=0..9 are fifteen minutes stale the moment the route reads
# `time.monotonic()` and get pruned before they can refuse anything.
# On the real clock: hits stamped at t=0..9 would be pruned as stale the moment
# the route reads `time.monotonic()`.
now = time.monotonic()
for _ in range(live("signin_limit_per_account")):
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
+3 -4
View File
@@ -26,9 +26,8 @@ def test_zero_means_keep_forever():
def test_a_negative_window_also_means_never():
# Reachable by typing a stray minus into the Settings field. The dangerous reading
# of -1 would be "expired a day in the FUTURE", which purges the entire trash on
# the next sweep; refusing to run is the only safe interpretation.
# A stray minus in Settings. Read as "expired a day in the future", -1 would purge
# the whole trash, so it means "never".
assert expired_before(NOW, -1) is None
assert expired_before(NOW, -3650) is None
@@ -45,7 +44,7 @@ def test_retention_setting_is_registered_with_a_30_day_default():
assert defn is not None, "the setting must appear in the admin Settings UI"
assert defn.type == "int"
assert defn.default == 30
# The operator has to be able to tell what it does without reading the code.
# An admin can tell what it does without reading the code.
assert "0" in defn.description, "the keep-forever escape hatch must be documented"
+1 -1
View File
@@ -21,7 +21,7 @@ def test_validate_coerces_bool_and_int():
def test_each_integer_with_a_dangerous_range_is_bounded():
"""Session length 0 expired every session at once; an attachment limit above the
body ceiling allowed files no request could carry; a negative one refused all (#5384)."""
body ceiling allowed files no request could carry; a negative one refused all."""
for key, bad, good in (
("session_ttl_days", 0, 1),
("trash_retention_days", -1, 0),
+1 -1
View File
@@ -101,7 +101,7 @@ def test_page_cursor_a_full_revocation_stream_holds_the_cursor_back():
assert more is True
# --- protocol handshake (M10.6) ---------------------------------------------
# --- protocol handshake -----------------------------------------------------
def test_protocol_advertisement_shape():
+33 -69
View File
@@ -1,17 +1,8 @@
"""The version derivation — `packaging/version.sh`.
These tests build their OWN git repo in a tmpdir rather than reading this one's
history. Two reasons, and the second is the important one:
* They then need no `fetch-depth: 0` on the test lane, and cannot start passing or
failing because somebody pushed.
* They can commit to ONE artifact's file set at a time, which is the only way to
assert the property this whole change exists for: that a Kotlin-only commit
leaves the desktop's version alone. Against real history you can only observe
whatever the last commits happened to touch.
Note 3127 §3 cites this repo as its example of the failure being fixed here — one
generator feeding three artifacts, so a Rust-only commit re-versioned the phone.
These tests build their own git repo in a tmpdir rather than reading this one's
history, so they need no `fetch-depth: 0`, and can commit to one artifact's file set
at a time: a Kotlin-only commit must leave the desktop's version alone.
"""
from __future__ import annotations
@@ -26,9 +17,8 @@ SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "version.sh"
GUARD = Path(__file__).resolve().parent.parent / "packaging" / "guard-forward.sh"
SHOULD_BUILD = Path(__file__).resolve().parent.parent / "packaging" / "should-build.sh"
# 2020-01-01T00:00:00Z, the counter epoch. Duplicated from the script deliberately:
# a test that imported the value could not catch the value being changed, and moving
# this epoch renumbers every artifact downwards (note 3127 §6.4).
# 2020-01-01T00:00:00Z, the counter epoch. Duplicated from the script so a change to
# it fails here: moving it renumbers every artifact downwards.
EPOCH = 1577836800
@@ -53,9 +43,8 @@ def commit(repo: Path, path: str, when: int) -> None:
["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-q", "-m", f"touch {path}"],
check=True, capture_output=True, text=True,
# EXTEND the environment rather than replacing it: a minimal env is enough
# for git here but not necessarily inside the CI container, and a test that
# fails only there is worse than no test.
# Extend the environment rather than replacing it; the CI container may need
# more than git does here.
env={**os.environ,
"GIT_AUTHOR_DATE": f"@{when} +0000", "GIT_COMMITTER_DATE": f"@{when} +0000"},
)
@@ -121,18 +110,17 @@ def test_a_shared_core_commit_moves_both(repo: Path) -> None:
def test_the_server_set_contains_the_android_set(repo: Path) -> None:
"""The image BAKES IN the APK, so an APK-only change changes what the image
ships. Note 3127 §3's bundled-artifact trap; FC's web image embeds the extension
the same way, and Roundtable needed a bespoke workflow for want of modelling it."""
"""The image bakes in the APK, so an APK-only change changes what the image
ships."""
commit(repo, "android/app/src/Main.kt", 1787911200) # 10:00
assert version(repo, "display", "server") == "2026.08.28.1000"
assert "android" in version(repo, "paths", "server")
def test_the_build_recipe_is_in_the_set(repo: Path) -> None:
"""A workflow file is not shipped, but change a build flag and the bytes change
while the source does not. Once step 6 skips a build whose version already
exists, that combination serves the OLD artifact on a green run."""
"""A workflow file is not shipped, but a build flag changes the bytes while the
source does not. A build skipped because its version exists would then serve the
old artifact on a green run."""
commit(repo, ".forgejo/workflows/desktop.yml", 1787911200) # 10:00
assert version(repo, "display", "desktop") == "2026.08.28.1000"
@@ -146,12 +134,8 @@ def test_the_answer_does_not_depend_on_the_caller_s_directory(repo: Path, subdir
`desktop/src-tauri`, the Android build from `android`, the manifest from the root
— so without an anchor the same request answers differently per caller.
This is not hypothetical and it is not a loud failure. Run 4796 produced THREE
versions from one push: the desktop build said 1.0.3494522 while the manifest and
the pacman packager said 1.0.3502131, because the build's pathspec matched
`desktop/src-tauri/Cargo.toml` — a real file, six days stale. Non-empty, so the
shallow-clone guard could not fire. The Android job failed loudly in the same run
only because ITS pathspec happened to match nothing; same bug, luckier symptom."""
It fails quietly: a relative pathspec can match a real, stale file from the
desktop's directory and give one push several versions."""
(repo / "desktop/src-tauri").mkdir(parents=True, exist_ok=True)
(repo / "core").mkdir(parents=True, exist_ok=True)
assert version(repo, "display", "desktop", subdir=subdir) == "2026.08.28.0900"
@@ -171,7 +155,7 @@ def test_every_artifact_agrees_across_directories(repo: Path) -> None:
# --- the ordering keys -------------------------------------------------------
def test_the_desktop_key_is_valid_semver(repo: Path) -> None:
"""THE test that keeps the update channel alive. Tauri parses `latest.json`'s
"""What keeps the update channel alive. Tauri parses `latest.json`'s
version with the semver crate AT DESERIALIZATION — a string it cannot parse does
not sort low, it makes the whole feed fail to load and every client report "no
update available" forever. Exactly three numeric segments, no leading zeros."""
@@ -188,9 +172,8 @@ def test_the_desktop_key_and_display_describe_one_build(repo: Path) -> None:
def test_the_desktop_key_clears_what_is_already_installed(repo: Path) -> None:
"""`0.0.<minutes>` reads best as "not a version" and would have stranded every
dev user: minor 0 < 2 puts it below the installed `0.2.466` line, and "up to
date" forever is the direction you cannot recover from."""
"""Not `0.0.<minutes>`: minor 0 sorts below installed `0.2.x` builds, which would
then read "up to date" forever."""
key = tuple(int(p) for p in version(repo, "key", "desktop").split("."))
assert key > (0, 2, 466)
assert key > (0, 2, 999999) # and above any run number that line could reach
@@ -206,8 +189,8 @@ def test_the_android_key_is_an_int_android_will_accept(repo: Path) -> None:
def test_the_server_has_no_ordering_key(repo: Path) -> None:
"""Nothing compares a server image — no updater, no install gate. §2: do not add
an ordering key because the other artifacts have one."""
"""Nothing compares a server image (no updater, no install gate), so it has no
ordering key just because the other artifacts do."""
r = subprocess.run(["sh", str(SCRIPT), "key", "server"],
cwd=repo, capture_output=True, text=True)
assert r.returncode != 0
@@ -218,13 +201,9 @@ def test_the_server_has_no_ordering_key(repo: Path) -> None:
@pytest.mark.parametrize("what", ["display", "key", "paths"])
def test_an_unknown_artifact_is_rejected(repo: Path, what: str) -> None:
"""It was not. `paths_for`'s `exit 2` ran inside `$(paths_for ...)`, so it printed
the error, returned an EMPTY pathspec — which matches everything — and answered
`2026.08.28.0900` with exit 0. A confident version for an artifact that does not
exist, which is the worst kind of wrong for a value nothing else can contradict.
Asserting on stdout as well as the exit code, because the exit code alone passed
for `display` in the first version of this file while stdout carried a lie."""
"""An `exit` inside `$(...)` ends only the subshell, so an empty pathspec (which
matches everything) would answer with a confident version and exit 0. Asserts on
stdout as well as the exit code."""
r = subprocess.run(["sh", str(SCRIPT), what, "nope"],
cwd=repo, capture_output=True, text=True)
assert r.returncode != 0, f"{what} exited 0 with stdout={r.stdout!r}"
@@ -233,17 +212,11 @@ def test_an_unknown_artifact_is_rejected(repo: Path, what: str) -> None:
@pytest.mark.parametrize("what", ["display", "key"])
def test_no_matching_history_fails_rather_than_guessing(tmp_path: Path, what: str) -> None:
"""The shallow-clone failure (note 3127 §6.1), which is the one that matters:
depth-1 sees one commit, `git log -- <paths>` finds nothing for most artifacts,
and a script that shrugged would emit a too-LOW version with the lane green.
Too-low is unrecoverable — every installed client is stranded.
"""A shallow clone sees one commit, so `git log -- <paths>` finds nothing for
most artifacts, and a script that shrugged would emit a too-low version with the
lane green. Too low is unrecoverable: every installed client is stranded.
BOTH REQUESTS, and the parametrize is the point rather than thoroughness. The
first version of this guard `exit 1`-ed inside a function called as `$(...)`,
which ends the SUBSHELL and not the script. `display` still failed — but only
because `date` then choked on an empty string. `key` printed the error, emitted
`1.0.-26297280`, and exited ZERO. One path was covered and the other was broken
in exactly the way the guard existed to prevent."""
Both requests, because an `exit` inside `$(...)` can fail one and not the other."""
git(tmp_path, "init", "-q", "-b", "dev")
commit(tmp_path, "README.md", 1787900400) # in no artifact's set
r = subprocess.run(["sh", str(SCRIPT), what, "desktop"],
@@ -267,9 +240,8 @@ def compare(a: str, b: str) -> bool:
@pytest.mark.parametrize("a,b,expect_lt", [
# THE trap: as text, "1.0.9" > "1.0.10". The comparison must be numeric
# per dot-segment, which is what note 3127 §1 spells out and what a naive
# `[ "$a" \< "$b" ]` would get exactly backwards.
# As text, "1.0.9" > "1.0.10". The comparison must be numeric per dot-segment;
# `[ "$a" \< "$b" ]` gets it backwards.
("1.0.9", "1.0.10", True),
("1.0.10", "1.0.9", False),
# Equal is NOT less. Under commit time an unchanged source derives what it
@@ -309,18 +281,11 @@ def test_the_guard_rejects_bad_invocations(args: list[str]) -> None:
# --- the backwards guard against a channel ------------------------------------
#
# The half of the guard that talks to a release feed, which the `compare` tests above
# deliberately do not reach. Hermetic anyway: `curl` is shadowed on PATH by a stub, so
# these assert what the guard does with an answer rather than what the forge returns.
# The half of the guard that talks to a release feed. Hermetic: `curl` is shadowed on
# PATH by a stub.
#
# WHY THIS SECTION EXISTS AT ALL. The empty-channel case — a channel that has never
# published this artifact — is one the guard is written to PASS, and it says so in a
# branch of its own. It did not: `published="$(published_for ...)"` under `set -e`
# died on the substitution before that branch could run, because Android's lookup
# ended in a `grep` that exits 1 on no match while its three siblings ended in a `sed`
# that exits 0. Silent, because everything the pipeline printed went into the capture.
# It took the Android lane down on the first merge to `main` (run 4857) and nothing
# here would have caught it.
# A channel that has never published an artifact must pass. Under `set -e`, a lookup
# ending in a `grep` with no match kills the substitution before that branch runs.
@pytest.fixture
@@ -361,8 +326,7 @@ def guard(repo: Path, env: dict[str, str], *args: str) -> subprocess.CompletedPr
def test_an_empty_channel_passes_the_guard(repo: Path, curl_stub, artifact: str) -> None:
"""Nothing published yet is not a backwards move — it is the first publish.
Failing here would block the very first build to reach a channel, which is exactly
what happened to Android on `stable`.
Failing here would block the first build to reach a channel.
"""
r = guard(repo, curl_stub(None), artifact, "stable")
assert r.returncode == 0, f"exit={r.returncode} stdout={r.stdout!r} stderr={r.stderr!r}"