diff --git a/booth/benches.py b/booth/benches.py index bb8fc8e..e998858 100644 --- a/booth/benches.py +++ b/booth/benches.py @@ -174,9 +174,21 @@ def _bench_from(bench_id: str, row: object) -> Bench: state = row.get("state", "live") if state not in BENCH_STATES: raise ValueError(f"{bench_id}: unknown state {state!r}") + url = row.get("url", bench_id) + if not isinstance(url, str): + raise ValueError(f"{bench_id}: url must be text, not {type(url).__name__}") + if len(url) > URL_MAX: + # REFUSED, NOT TRUNCATED — unlike `name` and `owner`. Those are display + # budgets and clipping one costs a few characters in a panel row. A + # clipped URL is a DEAD ANCHOR, and INV-7 promises the click goes to the + # posted address byte for byte; silently shortening it keeps the promise + # in the type system and breaks it in the browser. Nothing this code + # writes can get here (normalize refuses over-long input); a hand-edited + # registry can, and it is damage, which is what the reader reports. + raise ValueError(f"{bench_id}: url is longer than {URL_MAX} characters") return Bench( id=bench_id, - url=_cap(row.get("url", bench_id), URL_MAX, "url"), + url=url, name=_cap(row.get("name", ""), NAME_MAX, "name"), owner=_cap(row.get("owner", ""), OWNER_MAX, "owner"), state=state, @@ -275,9 +287,21 @@ def _write_all(root: Path, benches: dict[str, Bench]) -> None: # Per-pid scratch name so two writers cannot share it: the atomic-replace # promise is that a READER never sees a partial file, not that two writers # never collide on the way there. + body = json.dumps(payload, indent=2, sort_keys=True) + "\n" + # THE WRITER RESPECTS THE READER'S CAP. Without this, a successful + # registration can push the file past BENCHES_MAX_BYTES and every + # subsequent read fails — so the LAST bench somebody added is the one that + # makes all the others invisible, and the write that did it reported + # success. The reader is lenient about damage; it is not lenient about + # size, and a writer that ignores a limit its own reader enforces is + # manufacturing exactly the state the leniency exists to survive. + if len(body.encode("utf-8")) > BENCHES_MAX_BYTES: + raise ValueError( + f"that registration would push the registry past {BENCHES_MAX_BYTES} " + f"bytes, which its own reader refuses; nothing was written") tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}") try: - tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + tmp.write_text(body) os.replace(tmp, path) except BaseException: # A write that dies between create and replace would otherwise strand diff --git a/docs/contracts/u6_benches.contract.md b/docs/contracts/u6_benches.contract.md index b133892..53e273f 100644 --- a/docs/contracts/u6_benches.contract.md +++ b/docs/contracts/u6_benches.contract.md @@ -123,8 +123,26 @@ upsert" literally and found the code bumping on a state change: the code is right (a promotion is a change to the record and "last touched" should say so) and the earlier wording was narrower than what anyone wants the field to mean. -Caps: `name` 120, `owner` 64, `url` 2048, `state` one of three — applied at the -write and again at the read. Each is a display budget, not a storage limit. +**Caps, and what "applied" means for each — stated per field, because it is +not the same verb for all of them.** The cold paraphrase panel found "applied at +the write and again at the read" readable three ways (refuse / clip-for-display +/ truncate-and-store) with a different build behind each, and 4-of-4 arms +flagged it. + +| field | cap | at the write | at the read | +|---|---|---|---| +| `name` | 120 | **truncated** | **truncated** | +| `owner` | 64 | **truncated** | **truncated** | +| `url` | 2048 | **refused** (`normalize_bench_url` raises) | **damage** — reported, never clipped | +| `state` | one of three | **refused** | **damage** | +| `id` | 2048 | **refused**, via the url it is derived from | **not applied** — see below | + +`name` and `owner` are display budgets: clipping one costs a few characters in +a panel row. **`url` is not a budget and must never be clipped**, at either end +— INV-7 promises the click goes to the posted address byte for byte, and a +shortened URL keeps that promise in the type system while breaking it in the +browser. Nothing this code writes can store an over-long one; a hand-edited +registry can, and that is damage. **`id` is capped at the WRITE ONLY, and that asymmetry is deliberate.** `normalize_bench_url` refuses an input over `URL_MAX`, so nothing this code @@ -228,9 +246,14 @@ Three properties this refusal must have, each of which is an invariant below: - **It names the alternative.** The teaching moment belongs at the point of use; 17 handles have the muscle memory and a bare "refused" would send them to a human. -- **It writes nothing.** Not the row, and not the board's `.booth.json` - announcement that `booth link` creates on first use — a refused call must not - leave a new booth behind as a side effect. +- **It writes nothing — nothing at all.** Not the row, not the board + directory, not the `.booth.json` announcement `booth link` creates on first + use, not a lock file. The test asserts the data root's entries are unchanged, + not merely that `links.md` lacks the row. + + *(Amended: this listed two items while INV-3 listed four, so a reader of the + prose alone could conclude a lock file was permissible. One list now, and it + is the strict one.)* - **It is the ONLY new refusal.** A reference bookmark is still a link. ## What renders @@ -244,6 +267,15 @@ On the standing board's page, above the rows: checkbox pre-reachable by the existing select-all, so the operator can tick and use the `unlink-many` control already on the page. **No new delete path.** + **Dead means exactly this, and both halves are load-bearing:** + `booth_target(row.url)` is not None **AND** the name it returns is not a live + directory in the data root. A row that is not a booth link is never dead, no + matter what it points at — the Booth cannot know whether a gitea repo still + exists and must not guess. A booth link whose booth is alive is not dead. No + lifetime arithmetic is involved: a booth one minute from expiry is alive. + *(Stated after 3-of-4 cold arms read the rule two ways — predicate-driven vs + existence-driven — with 221 rows riding on which.)* + A registry that cannot be read renders as a panel carrying its error, never as an absent panel and never as a 500 — the v0.2.2 lesson, which this repo learned by returning 500 for `/` and `/healthz` across all 25 booths. @@ -263,18 +295,29 @@ booth bench add register or update; prints registered/updated booth bench ls list, in the rendered order, with ids booth bench state live | promoted | retired booth bench rm remove one -booth bench import [--apply] classify the board's rows; WRITES NOTHING - without --apply, and never touches links.md +booth bench import classify the board's rows; WRITES NOTHING +booth bench import --apply ... register ONLY the ids you name + +`` takes EITHER form because the input is normalized before the lookup, +and normalization is idempotent — an id normalizes to itself. So the id `ls` +prints and the raw URL in the operator's scrollback both address the same row. +Pinned by a test, because it is the property that makes the two-form promise +true rather than merely intended. ``` `import` prints three groups — **booth rows** (skipped; `booth_target` matched), -**candidates** (would be registered, with the normalized id beside the raw URL -so a collapse is visible before it happens), and **refused** (normalization -raised, with the reason). Under `--apply` it upserts the candidates and prints -the same three groups plus what it did. It is not automatic and it is not run by -this unit: roughly 14 of 35 candidates are reference bookmarks that belong on the -board, and a machine cannot tell a bench from a bookmark by its URL. The -operator seeds the registry by reviewing that list. +**candidates** (the normalized id beside the raw URL, so a collapse is visible +before it happens), and **refused** (normalization raised, with the reason). + +**`--apply` REQUIRES THE IDS. A bare `--apply` is refused.** This is the +unit's sharpest correction and it came from all four arms of the cold paraphrase +panel independently: the first draft registered every candidate, which made the +write path do the exact thing this document's own rationale calls impossible — +**tell a bench from a bookmark by its URL** — silently, to roughly 14 of 35 rows +that belong on the board. The dry run prints ids; the operator names the ones +that are benches; an id that is not a candidate is refused and nothing is +written. There was no selection mechanism between the report and the write, and +the report existed precisely because the decision is not mechanizable. ## The migration @@ -282,7 +325,7 @@ operator seeds the registry by reviewing that list. **and committed to this repo**, before anything else. Nothing the operator wrote is destroyed, and the archive is version-controlled rather than living only on one box. -2. `booth bench import` proposes; the operator applies. +2. `booth bench import` proposes; the operator applies **by naming ids**. 3. The 156 dead booth rows are marked, and removed by him or not at all. ## Scope — the blast-radius pass @@ -330,12 +373,20 @@ that the panel renders would pass under exactly that change. the only implementation, and the CLI's refusal, the dead marker and the import's classifier all call it. *Falsifiable:* the defeating change is a second implementation — a `/b/` check -inlined in the shell for speed, or a regex in `app.py`. The test asserts -AGREEMENT rather than behaviour: a table of URLs (trailing slash, no slash, -nested path, query, uppercase host, a non-Booth host with a `/b/` path, a `/b/` -with no name) is run through the CLI's refusal AND the render's dead marker, and -the two must classify every row identically. A test that only checked the -refusal would stay green while the marker drifted. +inlined in the shell for speed, or a regex in `app.py`. One table of URLs +(trailing slash, no slash, nested path, query, uppercase host, a non-Booth host +with a `/b/` path, a `/b/` with no name, a percent-encoded name, a decoded `..` +and a decoded separator) runs through the predicate, the CLI's refusal AND the +render's dead marker. + +**AGREEMENT IS THE WEAKER HALF AND IS NOT THE TEST.** Three callers of one +wrong predicate agree perfectly, so agreement alone pins nothing — the table's +**expected values** are the independent check, and the agreement rows exist to +catch a second implementation drifting from the first. Both are asserted; only +one of them would survive `booth_target` itself being wrong. *(Named after a +cold arm pointed out that the falsifier reads as though agreement were +sufficient.)* **A bare `/b/` with no name is NOT a booth link**, and the table +pins that. **INV-3 — a refused link writes nothing.** No row, no board directory, no `.booth.json`, no lock file. @@ -367,8 +418,16 @@ error rather than an empty panel. Case (d) is the one that matters: it is the shape that is currently 500ing the gallery elsewhere in this service. **INV-6 — the identity collapses a re-post and nothing else.** Upserting the -same normalized URL updates one row; upserting two URLs that differ in path, -query or host creates two. +same normalized URL updates one row; upserting two URLs that differ in **scheme, +host, non-default port, path, or query** creates two. **That list is +EXHAUSTIVE** — the only things normalization discards are a fragment, a +scheme-default port, letter case in the scheme and host, a bare `/` path, and +surrounding whitespace. + +*(Amended: this said "path, query or host" with no "only", which reads as +illustrative and left an implementer free to "fix" the rule from the +invariant's wording — and it omitted scheme and port, two of the five. 3-of-4 +cold arms flagged it; the falsifier now carries vectors for both.)* *Falsifiable:* the defeating change is normalizing to the origin. The test registers the eight gitea URLs measured on the live board and asserts **eight** benches, then registers `talk`'s five rows and asserts **one** — the same @@ -378,9 +437,15 @@ pass under origin normalization, which is precisely the wrong rule. **INV-7 — `url` is what a click goes to; `id` is never rendered as an href.** *Falsifiable:* the defeating change is rendering `bench.id` in the anchor because it is "the clean one". The test registers a URL whose normalization -differs from its raw form (a trailing slash on a non-empty path, an uppercase -host, a fragment) and asserts the anchor's `href` is the raw string, byte for -byte. +differs from its raw form — **an uppercase host, an explicit default port, and +a fragment** — and asserts the anchor's `href` is the raw string, byte for byte. + +*(Amended: this parenthetical used to name "a trailing slash on a non-empty +path" as one of the differences. **It is not one** — the rule list keeps a +non-empty path verbatim, slash included, and INV-6 makes `…/p` and `…/p/` two +benches. Two passages of this document disagreed about the same character, and +3-of-4 cold arms found the contradiction. The rule list is correct; this +sentence was wrong.)* **INV-8 — nothing this unit ships removes a board row.** The dead marker is a render-time stamp; `import` without `--apply` writes nothing anywhere; `import` diff --git a/scripts/booth b/scripts/booth index 885bafa..513c39f 100755 --- a/scripts/booth +++ b/scripts/booth @@ -359,6 +359,19 @@ case "$cmd" in echo "booth link: the booth check answered something unrecognised; nothing was posted." >&2 exit 3 ;; esac + # CREDENTIALS DO NOT GO ON THE BOARD, through any door. `normalize_bench_url` + # refuses userinfo for a bench; `booth link` is the door this unit did not + # touch, and the board renders on an unauthenticated LAN surface. A small, + # deliberate widening of the unit -- named rather than smuggled. + case "$link_url" in + *://*@*) + { + echo "booth link: that URL carries credentials (user:pass@host) and the board" + echo " is readable by anyone who can reach this service. Nothing was posted." + echo " strip the credentials and post it again." + } >&2 + exit 2 ;; + esac if [ -n "$refused_name" ]; then { echo "booth link: that is a booth, and a booth announces itself now." @@ -523,6 +536,7 @@ elif sub in ("state", "rm"): print("%s is now %s" % (moved.url, moved.state)) elif sub == "import": apply = "--apply" in argv + picked = [a for a in argv if a != "--apply"] board = root / os.environ["BOOTH_BOARD"] / "links.md" if not board.is_file(): die("no link board at %s" % board, 1) skipped, candidates, refused = [], [], [] @@ -554,16 +568,38 @@ elif sub == "import": print(" %-52s %s" % (e["url"], why)) if not apply: print() - print("nothing was written. re-run with --apply to register the candidates.") - print("NOTE: a machine cannot tell a bench from a bookmark by its URL —") - print(" roughly 14 of 35 live candidates are repos, model cards and docs,") - print(" for which the board is the right home. Review before applying.") + print("nothing was written.") + print(" booth bench import --apply ... register ONLY the ids you name") + print() + print("A MACHINE CANNOT TELL A BENCH FROM A BOOKMARK BY ITS URL. On the live") + print("board roughly 14 of 35 candidates are repos, model cards and docs, for") + print("which the board is the right and only home. So `--apply` takes the ids") + print("YOU pick from the list above; it will not register the whole set.") raise SystemExit(0) - for i, e in candidates: + # SELECTION IS MANDATORY. A bare `--apply` would do exactly the thing this + # rationale of this very unit says is impossible -- decide bench-vs-bookmark + # URL -- and it would do it silently, to ~14 rows that belong on the board. + # The dry-run prints the ids; the operator names the ones that are benches. + if not picked: + print() + print("booth bench import --apply needs the ids to register.", file=sys.stderr) + print(" nothing was written. copy the ids you want from the list above:", + file=sys.stderr) + print(" booth bench import --apply [...]", file=sys.stderr) + raise SystemExit(2) + by_id = {i: e for i, e in candidates} + unknown = [i for i in picked if i not in by_id] + if unknown: + print() + for i in unknown: + print("not a candidate id: %s" % i, file=sys.stderr) + print("nothing was written.", file=sys.stderr) + raise SystemExit(2) + for i in picked: + e = by_id[i] upsert_bench(root, e["url"], e["desc"], e["who"] or who) print() - print("applied: %d distinct benches registered. links.md was NOT modified." - % len({i for i, _ in candidates})) + print("applied: %d bench(es) registered. links.md was NOT modified." % len(set(picked))) ' "$@" ;; ask) diff --git a/tests/test_benches.py b/tests/test_benches.py index 47f3e2b..5d91ff5 100644 --- a/tests/test_benches.py +++ b/tests/test_benches.py @@ -245,10 +245,28 @@ def test_oversized_registry_is_refused_by_size_before_parsing(tmp_path): """INV-5. Defeating change: parsing first and checking length after, which costs the caller the whole file. A FIFO has st_size 0, so the guard must bound the READ, not trust the stat — the 2026-09-22 hang lesson.""" - from booth.benches import BENCHES_MAX_BYTES - _write_raw(tmp_path, '{"http://a/": {"name": "' + "x" * BENCHES_MAX_BYTES + '"}}') + import booth.benches as B + _write_raw(tmp_path, '{"http://a/": {"name": "' + "x" * B.BENCHES_MAX_BYTES + '"}}') benches, err = read_benches(tmp_path) assert err and benches == [] + # AND PROVE THE PARSE WAS NEVER REACHED. Asserting only the eventual result + # passes an implementation that loads the whole document and checks its + # length afterwards — which costs the caller exactly what the cap exists to + # save. Booby-trap json.loads: if it runs, the test says so. Cold panel, + # hulda F11. + import json as _json + tripped = [] + real = _json.loads + + def trap(*a, **k): + tripped.append(True) + return real(*a, **k) + B.json.loads = trap + try: + benches, err = read_benches(tmp_path) + finally: + B.json.loads = real + assert err and not tripped, "the oversized registry was parsed before it was refused" @pytest.mark.skipif(os.geteuid() == 0, reason="root ignores the mode bit") @@ -328,6 +346,13 @@ BOOTH_URL_TABLE = [ ("http://10.100.10.50:8090/b/a%2Fb/", None), # decoded separator ("https://gitea.phasefinal.com/vh/peedlar", None), ("not a url at all", None), + # THE ACCEPTED COST, MADE EXPLICIT. The predicate is host-agnostic on + # purpose — a host allowlist fails OPEN on whichever name somebody reaches + # this service by next — so a third-party URL with a `/b/` path reads as + # a booth link and is refused. The contract names this trade-off; the table + # had no row exercising it, so nothing pinned the behaviour either way. + # Cold panel, hulda F10. + ("https://example.com/b/not-ours/", "not-ours"), ] @@ -355,6 +380,7 @@ def test_added_survives_reregistration_updated_does_not(tmp_path): assert not created assert second.added == first.added assert second.name == "two" and second.owner == "o2" + assert second.updated >= first.updated def test_state_survives_reregistration(tmp_path): @@ -729,3 +755,88 @@ def test_the_dead_marker_classifies_the_SAME_table(tmp_path, url, expected): _board(tmp_path, f"- [r]({url}) · x · 2026-09-01 00:00\n") marked = bool(_dead_rows(_client(tmp_path).get("/b/links/").text)) assert marked is (expected is not None), (url, expected) + + +def test_updated_is_replaced_and_added_is_not(tmp_path, monkeypatch): + """The other half of `test_added_survives_reregistration_updated_does_not`, + which asserted only the half in the first clause of its own name. + + The stamp has SECOND resolution, so a fast test cannot tell a replaced + `updated` from a frozen one by comparing real clocks — `>=` passes either + way, which is a falsifier that cannot fail. The clock is driven instead, so + "was it rewritten" is answerable. Cold panel, hulda F12. + + Defeating change: carrying `updated` forward from the prior record the way + `added` is carried, which every real-clock assertion in this file survives. + """ + import booth.benches as B + ticks = iter(["2026-01-01T00:00:00+00:00", + "2026-06-06T06:06:06+00:00", + "2026-12-31T23:59:59+00:00"]) + monkeypatch.setattr(B, "_now", lambda: next(ticks)) + + first, _ = upsert_bench(tmp_path, "http://a.test/", "one", "o") + assert first.added == first.updated == "2026-01-01T00:00:00+00:00" + + second, _ = upsert_bench(tmp_path, "http://a.test/", "two", "o") + assert second.added == "2026-01-01T00:00:00+00:00", "added must survive an upsert" + assert second.updated == "2026-06-06T06:06:06+00:00", "updated must be replaced" + + # A STATE CHANGE IS A MUTATION and bumps it too — this is what the contract + # was amended to say, after the panel read "most recent upsert" literally. + third = set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "retired") + assert third.added == "2026-01-01T00:00:00+00:00" + assert third.updated == "2026-12-31T23:59:59+00:00" + + +def test_a_registration_cannot_make_the_registry_unreadable(tmp_path): + """Cold contract panel, hulda solo: the write path permitted a file the + reader then refuses on size — so the LAST bench somebody added would be the + one that made every other bench invisible, and the write that did it + reported success. + + Defeating change: dropping the size check from `_write_all`. The reader is + lenient about damage and deliberately NOT lenient about size; a writer + ignoring a limit its own reader enforces manufactures exactly the state + that leniency exists to survive.""" + from booth.benches import BENCHES_MAX_BYTES, NAME_MAX + n = 0 + while True: + n += 1 + try: + upsert_bench(tmp_path, f"http://h{n}.test/{'p' * 1800}", "x" * NAME_MAX, "o") + except ValueError as exc: + assert "past" in str(exc) and str(BENCHES_MAX_BYTES) in str(exc) + break + assert n < 500, "never hit the cap; widen the fixture" + # THE REGISTRY IS STILL READABLE, and still holds everything that fit. + benches, err = read_benches(tmp_path) + assert err is None, err + assert len(benches) == n - 1 + + +def test_an_over_long_stored_url_is_damage_not_a_silent_clip(tmp_path): + """Cold contract panel, 4-of-4 on cap semantics: "applied at the read" did + not say TRUNCATE or REFUSE, and the code had picked truncate for every + field. For `name` and `owner` that is right — they are display budgets and + clipping costs a few characters in a panel row. For `url` it is wrong: + INV-7 promises the click goes to the posted address byte for byte, and a + clipped URL keeps that promise in the type system while breaking it in the + browser. Defeating change: routing `url` back through `_cap`.""" + from booth.benches import URL_MAX + long_url = "http://a/" + "p" * (URL_MAX + 10) + _write_raw(tmp_path, json.dumps({"http://a/": { + "url": long_url, "name": "n", "owner": "o", "state": "live"}})) + benches, err = read_benches(tmp_path) + assert err and benches == [], "an over-long url was clipped into a dead anchor" + + +def test_name_and_owner_ARE_clipped_at_the_read(tmp_path): + """The other half of the same rule, so the asymmetry is pinned in both + directions rather than asserted in one.""" + from booth.benches import NAME_MAX, OWNER_MAX + _write_raw(tmp_path, json.dumps({"http://a/": { + "url": "http://a/", "name": "n" * 500, "owner": "o" * 500, "state": "live"}})) + benches, err = read_benches(tmp_path) + assert err is None + assert len(benches[0].name) == NAME_MAX and len(benches[0].owner) == OWNER_MAX diff --git a/tests/test_cli.py b/tests/test_cli.py index c516e77..9c569ea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -440,11 +440,28 @@ def test_bench_verbs_round_trip(booth): def test_bench_state_and_rm_take_an_id_or_a_url(booth): - """`bench ls` prints ids; the operator has the URL. Both must address.""" + """`bench ls` prints ids; the operator has the URL. BOTH must address. + + This used to invoke both verbs with the URL only, twice, while its docstring + claimed it covered the id — the same claim-not-evidence shape as the `ls` + docstring. A raw URL whose normalization DIFFERS from it is used, so the two + columns are genuinely distinct inputs. Cold panel, regin F8. + """ data, _ = booth - run(data, "bench", "add", "http://x.test/p/", "ex") - assert run(data, "bench", "state", "http://x.test/p/", "retired").returncode == OK - assert run(data, "bench", "rm", "http://x.test/p/").returncode == OK + raw = "HTTP://X.Test:80/p/?b=2&a=1#frag" + bid = normalize_bench_url_cli(raw) + assert bid != raw, "pick a URL whose normalization actually differs" + run(data, "bench", "add", raw, "ex") + # by the ID the registry stores + assert run(data, "bench", "state", bid, "retired").returncode == OK + assert "retired" in run(data, "bench", "ls").stdout + # and by the RAW URL the operator has in their scrollback + assert run(data, "bench", "state", raw, "live").returncode == OK + assert "live" in run(data, "bench", "ls").stdout + assert run(data, "bench", "rm", raw).returncode == OK + run(data, "bench", "add", raw, "ex again") + assert run(data, "bench", "rm", bid).returncode == OK + assert "ex" not in run(data, "bench", "ls").stdout def test_bench_add_refuses_a_bad_url_with_the_reason(booth): @@ -502,10 +519,51 @@ def test_import_classifies_into_three_groups(booth): assert out.count("https://talk.nh3.phasefinal.com:8092/") >= 2, out +def test_bare_apply_refuses_and_writes_nothing(booth): + """THE SELECTION GAP — all four cold contract-review arms, independently. + + `--apply` used to register every candidate, while the same contract says + roughly 14 of 35 are reference bookmarks that must STAY on the board. That + made the write path do the exact thing the unit's own rationale calls + impossible — tell a bench from a bookmark by its URL — silently, to rows + that belong where they are. The dry-run prints ids; `--apply` takes the + ones the operator names, and refuses without them. + + Defeating change: restoring the register-everything branch.""" + data, _ = booth + _seed_board(data) + r = run(data, "bench", "import", "--apply") + assert r.returncode == REFUSED + assert "needs the ids" in r.stderr + assert not (data / ".benches.json").exists(), "a bare --apply wrote the registry" + + +def test_apply_refuses_an_id_that_is_not_a_candidate(booth): + data, _ = booth + _seed_board(data) + r = run(data, "bench", "import", "--apply", "http://not-on-the-board/") + assert r.returncode == REFUSED + assert "not a candidate id" in r.stderr + assert not (data / ".benches.json").exists() + + +def test_apply_registers_ONLY_the_named_ids(booth): + """The bookmark stays a bookmark unless the operator says otherwise.""" + data, _ = booth + _seed_board(data) + talk = normalize_bench_url_cli("https://talk.nh3.phasefinal.com:8092/") + assert run(data, "bench", "import", "--apply", talk).returncode == OK + ls = run(data, "bench", "ls").stdout + assert "peedlar" not in ls, "an unnamed candidate was registered anyway" + assert len([l for l in ls.splitlines() if "talk" in l]) == 1 + + def test_import_apply_collapses_the_repost(booth): data, _ = booth _seed_board(data) - assert run(data, "bench", "import", "--apply").returncode == OK + talk = normalize_bench_url_cli("https://talk.nh3.phasefinal.com:8092/") + repo = normalize_bench_url_cli("https://gitea.phasefinal.com/vh/peedlar") + assert run(data, "bench", "import", "--apply", talk, repo).returncode == OK ls = run(data, "bench", "ls").stdout # ONE ROW, counted by line: "talk" appears in both the name and the # hostname, so a substring count would read 2 for a correctly collapsed row. @@ -524,7 +582,9 @@ def test_nothing_in_the_unit_touches_links_md(booth): before = hashlib.sha256((board / "links.md").read_bytes()).hexdigest() run(data, "link", "http://10.100.10.50:8090/b/x/", "refused") run(data, "bench", "import") - run(data, "bench", "import", "--apply") + run(data, "bench", "import", "--apply") # refused, writes nothing + run(data, "bench", "import", "--apply", + normalize_bench_url_cli("https://talk.nh3.phasefinal.com:8092/")) run(data, "bench", "add", "http://new.test/", "new") run(data, "bench", "state", "http://new.test/", "retired") run(data, "bench", "ls") # a read verb can truncate too @@ -553,3 +613,15 @@ def test_link_fails_CLOSED_when_the_booth_check_cannot_run(booth, tmp_path): assert r.returncode != OK assert "could not check" in r.stderr, r.stderr assert not (data / "links" / "links.md").exists(), "a row landed despite an unusable check" + + +def test_a_credential_never_reaches_the_board(booth): + """`normalize_bench_url` refuses userinfo for a bench; `booth link` was the + door this unit did not touch, and the board renders on an unauthenticated + LAN surface. Cold contract panel, groa solo. A deliberate small widening of + the unit, named rather than smuggled.""" + data, _ = booth + r = run(data, "link", "https://user:hunter2@x.test/p", "leaky") + assert r.returncode != OK + assert "credentials" in r.stderr + assert not (data / "links").exists(), "a credentialed URL created the board"