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
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:
+2
-3
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user