/heid-bug-hunt panel 01M35CRRK2RTVWWF1BN09AFQG3, diff-scoped against 91fd8bc.
The most severe of the three rounds, and three of its four convergent findings
were already closed by our own adversarial pass before the reply landed. Three
were not.
- The benches panel was nested inside the booth header's <span class="sub">.
The insertion had matched the first `{% if board %}` in the template rather
than the block-level one. A div inside a span is invalid HTML: the parser
closes the span implicitly and hoists the div out, orphaning the rest of the
sub-line. Nothing 500s, which is precisely why no test in this suite could
see it. Moved to block level, pinned by an offset assertion, and verified
with a real HTML parser.
- _booth_exists used a bare is_dir() while resolve_booth resolves and requires
the parent to BE the data root. They disagreed on a symlink: the marker
called a booth pointing outside the root alive while the page 404s it, so the
row rendered healthy and the link was dead. Same containment now, and
ValueError joins OSError in the guard -- one bad row must never cost the
other 220.
- The board append opened its fd OUTSIDE the lock. `flock LOCK printf ... >>
board` reads as locked and is not: the shell opens the append fd while
parsing, before flock acquires. A concurrent unlink replaces the inode via
os.replace, the old fd still points at the unlinked one, and the append
succeeds, reports success, and vanishes. Pre-existing rather than this
unit's, but it is silent data loss in the file this unit lives in. Proved by
holding the lock and asserting nothing is written.
- The atomic write used a predictable .tmp.<pid> name; a pre-planted symlink
there redirects the write straight through the replace. mkstemp with O_EXCL
in the same directory, and an fsync before the replace -- os.replace orders
the rename, not the data behind it.
Declined and recorded: on a host where booth.links cannot be imported, `booth
link` now refuses every URL rather than only booth ones. True, and kept. A
guard that fails open is not a guard, and that state is a broken install in
which most of the CLI is equally broken.
The sharpest line in the reply is one three arms found independently: this repo
had ALREADY paid for the RecursionError class in marks.py, and the new module
re-introduced the unguarded parse. Reading the new module in isolation would
never have surfaced that.
604 -> 607 tests.
890 lines
40 KiB
Python
890 lines
40 KiB
Python
"""U6 — benches: a running thing, registered.
|
|
|
|
The contract is docs/contracts/u6_benches.contract.md. Every test here names
|
|
the invariant it falsifies, and each is written to go RED under the change that
|
|
defeats that invariant — not merely to assert the outcome the author had in
|
|
mind. (The U4 round shipped seven falsifiers of which five stayed green under
|
|
the very change they forbade; see persistent-memory.d/2026-09-22-vacuous-falsifiers.md.)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
|
|
from booth.benches import ( # noqa: E402
|
|
BENCHES_FILE,
|
|
BENCH_STATES,
|
|
Bench,
|
|
normalize_bench_url,
|
|
order_benches,
|
|
read_benches,
|
|
remove_bench,
|
|
set_bench_state,
|
|
upsert_bench,
|
|
)
|
|
from booth.links import booth_target # noqa: E402
|
|
|
|
|
|
# ---- INV-6: the identity collapses a re-post and NOTHING else ---------------
|
|
#
|
|
# Both directions from ONE fixture. A test that only checked the talk collapse
|
|
# would pass under origin normalization, which is the measurably wrong rule:
|
|
# on the live board it merges eight distinct gitea repositories into one row.
|
|
|
|
# Measured on the live board, 2026-09-22.
|
|
GITEA_EIGHT = [
|
|
"https://gitea.phasefinal.com/vh/bifrost/issues/17",
|
|
"https://gitea.phasefinal.com/vh/brokkr-smithy/src/commit/6adcde6/research/landscape-scans/open-weight-releases-2026-09-15.md",
|
|
"https://gitea.phasefinal.com/vh/cicada",
|
|
"https://gitea.phasefinal.com/vh/draupnir",
|
|
"https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.0",
|
|
"https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.1",
|
|
"https://gitea.phasefinal.com/vh/peedlar",
|
|
"https://gitea.phasefinal.com/vh/peedlar/releases/tag/v0.3.0",
|
|
]
|
|
TALK_FIVE = ["https://talk.nh3.phasefinal.com:8092/"] * 5
|
|
|
|
|
|
def test_eight_distinct_repos_stay_eight(tmp_path):
|
|
"""INV-6, the direction origin-normalization gets WRONG. Defeating change:
|
|
normalizing to scheme://host:port. This goes red under it; the collapse
|
|
test below does not."""
|
|
for i, u in enumerate(GITEA_EIGHT):
|
|
upsert_bench(tmp_path, u, f"repo {i}", "vh")
|
|
benches, err = read_benches(tmp_path)
|
|
assert err is None
|
|
assert len(benches) == 8, [b.id for b in benches]
|
|
|
|
|
|
def test_five_reposts_of_one_bench_collapse(tmp_path):
|
|
"""INV-6, the direction the IA doc names. `talk` is on the live board five
|
|
times; the registry must hold one row, carrying the LAST name."""
|
|
for i, u in enumerate(TALK_FIVE):
|
|
_, created = upsert_bench(tmp_path, u, f"talk v{i}", "nh3-dev")
|
|
assert created is (i == 0)
|
|
benches, _ = read_benches(tmp_path)
|
|
assert len(benches) == 1
|
|
assert benches[0].name == "talk v4"
|
|
|
|
|
|
def test_two_lrpg_surfaces_on_one_origin_stay_two(tmp_path):
|
|
"""INV-6. The IA doc's OWN example of two real benches shares an origin."""
|
|
upsert_bench(tmp_path, "http://10.100.10.50:8321/Authoring%20Studio.dc.html", "authoring", "ldp-dev")
|
|
upsert_bench(tmp_path, "http://10.100.10.50:8321/GM%20Playback.dc.html", "gm", "ldp-dev")
|
|
assert len(read_benches(tmp_path)[0]) == 2
|
|
|
|
|
|
def test_query_is_part_of_the_identity(tmp_path):
|
|
"""INV-6. Three ShutterChute rows differ ONLY by `?token=`; they are three
|
|
links, not one bench posted three times. Defeating change: dropping query."""
|
|
base = "http://10.100.10.50:8477/?token="
|
|
for tok in ("aaa", "bbb", "ccc"):
|
|
upsert_bench(tmp_path, base + tok, "shutterchute", "nh3-dev")
|
|
assert len(read_benches(tmp_path)[0]) == 3
|
|
|
|
|
|
@pytest.mark.parametrize("a,b", [
|
|
("http://x.test/", "http://X.TEST"), # host case + bare-slash path
|
|
("http://x.test:80/p", "http://x.test/p"), # default port
|
|
("https://x.test:443/p", "https://x.test/p"),
|
|
("http://x.test/p#frag", "http://x.test/p"), # fragment dropped
|
|
(" http://x.test/p ", "http://x.test/p"), # whitespace
|
|
("http://[::1]:80/a", "http://[::1]/a"), # default port, bracketed
|
|
("http://[::1]/A", "http://[::1]/A"), # bracket round-trips
|
|
])
|
|
def test_these_pairs_are_one_bench(a, b):
|
|
"""INV-6. Each pair is the SAME resource reached two ways."""
|
|
assert normalize_bench_url(a) == normalize_bench_url(b)
|
|
|
|
|
|
@pytest.mark.parametrize("a,b", [
|
|
("http://x.test/p", "http://x.test/p/"), # trailing slash on a REAL path
|
|
("http://x.test/p", "http://x.test/P"), # path case
|
|
("http://x.test/?a=1&b=2", "http://x.test/?b=2&a=1"), # query order is opaque
|
|
("http://x.test:8092/", "https://x.test:8092/"), # scheme
|
|
# A NON-DEFAULT PORT IS PART OF THE IDENTITY. Without this vector, "always
|
|
# omit the port" passes every other row in this file — caught by the cold
|
|
# panel's per-invariant "what would still pass" pass, not by us.
|
|
("http://x.test:8092/p", "http://x.test/p"),
|
|
("https://x.test:8443/p", "https://x.test/p"),
|
|
])
|
|
def test_these_pairs_are_two_benches(a, b):
|
|
"""INV-6, the other direction. Each pair MAY be two different resources, and
|
|
the registry must not decide otherwise on the operator's behalf."""
|
|
assert normalize_bench_url(a) != normalize_bench_url(b)
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [
|
|
"", " ", "not a url", "ftp://x.test/f", "file:///etc/passwd",
|
|
"http://", "https:///path", "//x.test/p", "javascript:alert(1)",
|
|
])
|
|
def test_refused_urls_raise_with_a_reason(bad):
|
|
with pytest.raises(ValueError) as e:
|
|
normalize_bench_url(bad)
|
|
assert str(e.value).strip(), "a refusal with no reason is a refusal the CLI cannot print"
|
|
|
|
|
|
def test_credentials_are_refused_not_stripped():
|
|
"""Stripping would register a bench whose URL no longer works while telling
|
|
the poster it succeeded — and put a credential on an unauthenticated LAN
|
|
surface on the way. Defeating change: `netloc.rpartition('@')[2]`."""
|
|
with pytest.raises(ValueError):
|
|
normalize_bench_url("https://user:hunter2@x.test/p")
|
|
|
|
|
|
# ---- INV-7: `url` is what a click goes to; `id` is never the href -----------
|
|
|
|
|
|
def test_the_stored_url_is_the_raw_string(tmp_path):
|
|
"""INV-7. Defeating change: storing the normalized form as `url` because it
|
|
is 'the clean one'. Every field that differs is asserted, byte for byte."""
|
|
raw = " HTTP://X.Test:80/Some%20Path/?b=2&a=1#frag "
|
|
bench, _ = upsert_bench(tmp_path, raw, "n", "o")
|
|
assert bench.url == raw.strip()
|
|
assert bench.id != bench.url
|
|
assert bench.id == "http://x.test/Some%20Path/?b=2&a=1"
|
|
assert read_benches(tmp_path)[0][0].url == raw.strip()
|
|
|
|
|
|
# ---- INV-4: the rendered order is TOTAL and stated --------------------------
|
|
|
|
|
|
def test_same_name_benches_do_not_swap():
|
|
"""INV-4. Defeating change: dropping the `id` tie-break.
|
|
|
|
THIS TEST USED TO GO THROUGH THE REGISTRY AND COULD NOT FAIL. `_write_all`
|
|
serializes with `sort_keys=True`, so whatever order two benches were
|
|
inserted in, they came back off disk already id-sorted — and removing the
|
|
tie-break from `order_benches` left it green. A vacuous falsifier of
|
|
exactly the shape persistent-memory.d/2026-09-22-vacuous-falsifiers.md
|
|
describes: it asserted the outcome the author had in mind rather than the
|
|
discriminator the invariant names. Caught by the cold panel (hulda, solo),
|
|
not by us.
|
|
|
|
So it calls `order_benches` DIRECTLY, with records that tie on both prior
|
|
keys, presented in both orders. Nothing upstream can pre-sort them.
|
|
"""
|
|
def recs(order):
|
|
pair = [
|
|
Bench(id="http://a.test/", url="http://a.test/", name="same name",
|
|
owner="o", state="live", added="", updated=""),
|
|
Bench(id="http://b.test/", url="http://b.test/", name="same name",
|
|
owner="o", state="live", added="", updated=""),
|
|
]
|
|
return pair if order else list(reversed(pair))
|
|
assert [b.id for b in order_benches(recs(0))] == \
|
|
[b.id for b in order_benches(recs(1))]
|
|
assert [b.id for b in order_benches(recs(1))] == ["http://a.test/", "http://b.test/"]
|
|
|
|
|
|
def test_state_ranks_before_name(tmp_path):
|
|
"""INV-4. live → promoted → retired, THEN name. Defeating change: ordering
|
|
by name alone, which a fixture of three same-state benches cannot see."""
|
|
upsert_bench(tmp_path, "http://a.test/", "aaa", "o") # would sort first by name
|
|
upsert_bench(tmp_path, "http://z.test/", "zzz", "o")
|
|
set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "retired")
|
|
assert [b.name for b in read_benches(tmp_path)[0]] == ["zzz", "aaa"]
|
|
|
|
|
|
def test_order_is_case_insensitive_on_name(tmp_path):
|
|
upsert_bench(tmp_path, "http://b.test/", "Bravo", "o")
|
|
upsert_bench(tmp_path, "http://a.test/", "alpha", "o")
|
|
assert [b.name for b in read_benches(tmp_path)[0]] == ["alpha", "Bravo"]
|
|
|
|
|
|
def test_order_benches_is_pure(tmp_path):
|
|
"""INV-4. Defeating change: `order_benches` doing I/O or sorting in place.
|
|
Called with records belonging to NO root, it must still answer."""
|
|
made = [Bench(id=f"http://{c}.test/", url=f"http://{c}.test/", name=c,
|
|
owner="o", state="live", added="", updated="") for c in "ba"]
|
|
assert [b.name for b in order_benches(made)] == ["a", "b"]
|
|
assert [b.name for b in made] == ["b", "a"], "input was mutated"
|
|
|
|
|
|
# ---- INV-5: the read cannot raise, and cannot cost the caller unboundedly ---
|
|
|
|
|
|
def _write_raw(root: pathlib.Path, payload: str) -> None:
|
|
(root / BENCHES_FILE).write_text(payload)
|
|
|
|
|
|
def test_absent_registry_is_not_an_error(tmp_path):
|
|
benches, err = read_benches(tmp_path)
|
|
assert benches == [] and err is None
|
|
|
|
|
|
@pytest.mark.parametrize("payload,label", [
|
|
("this is not json", "non-JSON bytes"),
|
|
("[]", "valid JSON of the wrong top-level shape"),
|
|
('{"benches": []}', "the list shape this unit deliberately does not use"),
|
|
('{"http://a/": "a string, not a record"}', "a value of the wrong type"),
|
|
('{"http://a/": {"name": [], "owner": "o", "state": "live"}}', "a FIELD of the wrong type"),
|
|
('{"http://a/": {"name": "n", "owner": "o", "state": "invented"}}', "an unknown state"),
|
|
])
|
|
def test_damaged_registries_report_rather_than_raise(tmp_path, payload, label):
|
|
"""INV-5. Defeating change: `json.load` with no guard, or `except: pass`
|
|
which would report absent. The error must be NON-EMPTY — 'damaged' and
|
|
'absent' must not render the same, because only one of them needs a human.
|
|
The wrong-typed-FIELD row is the shape currently 500ing the gallery
|
|
elsewhere in this service."""
|
|
_write_raw(tmp_path, payload)
|
|
benches, err = read_benches(tmp_path)
|
|
assert err, f"{label} reported no error"
|
|
assert benches == []
|
|
|
|
|
|
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."""
|
|
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")
|
|
def test_unreadable_registry_reports_rather_than_raises(tmp_path):
|
|
p = tmp_path / BENCHES_FILE
|
|
p.write_text("{}")
|
|
p.chmod(0o000)
|
|
try:
|
|
benches, err = read_benches(tmp_path)
|
|
assert err and benches == []
|
|
finally:
|
|
p.chmod(0o644)
|
|
|
|
|
|
# ---- INV-1: one module knows the registry's filename ------------------------
|
|
|
|
|
|
def test_only_benches_py_names_the_registry_file():
|
|
"""INV-1. Defeating change: a route reading `.benches.json` directly to save
|
|
an import. Asserting that the panel renders would pass under exactly that."""
|
|
root = pathlib.Path(__file__).parent.parent
|
|
offenders = []
|
|
for f in list((root / "booth").rglob("*.py")) + [root / "scripts" / "booth"]:
|
|
if f.name == "benches.py":
|
|
continue
|
|
if ".benches.json" in f.read_text():
|
|
offenders.append(str(f.relative_to(root)))
|
|
assert not offenders, f"the registry filename is hard-coded outside benches.py: {offenders}"
|
|
|
|
|
|
# ---- INV-9: stdlib-only, AND sibling-free (seam review SR-1) ----------------
|
|
|
|
|
|
def test_benches_is_stdlib_only_and_imports_no_sibling():
|
|
"""INV-9. The PARAMETRIZED test in test_marks.py exempts `booth` on purpose,
|
|
so it cannot catch `from booth.links import booth_target` — which is exactly
|
|
the import this unit tempts an implementer into. This is the strict copy,
|
|
mirroring tests/test_manifest.py. Seam review SR-1."""
|
|
src = pathlib.Path(__file__).parent.parent / "booth" / "benches.py"
|
|
tree = ast.parse(src.read_text())
|
|
roots = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
roots.update(a.name.split(".")[0] for a in node.names)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
roots.add("booth" if node.level else (node.module or "").split(".")[0])
|
|
outside = {r for r in roots if r and r not in sys.stdlib_module_names}
|
|
assert not outside, f"booth/benches.py imports outside the stdlib (booth.* included): {sorted(outside)}"
|
|
# A STRING IMPORT IS INVISIBLE TO THE WALK ABOVE. `__import__("booth.links")`
|
|
# or `importlib.import_module(...)` inside a function defeats it entirely,
|
|
# and that is the exact shape someone reaches for when a sibling import is
|
|
# refused by review. Caught by the cold panel's per-invariant vacuity pass.
|
|
called = {n.func.id for n in ast.walk(tree)
|
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
|
|
assert "__import__" not in called, "benches.py imports by string, defeating the AST walk"
|
|
assert "importlib" not in roots, "benches.py can import anything at runtime via importlib"
|
|
|
|
|
|
# ---- INV-2: ONE predicate decides what a booth URL is -----------------------
|
|
|
|
# Every row is (url, expected booth name or None). Run against BOTH callers.
|
|
BOOTH_URL_TABLE = [
|
|
("http://10.100.10.50:8090/b/sindra-bakeoff/", "sindra-bakeoff"),
|
|
("http://10.100.10.50:8090/b/sindra-bakeoff", "sindra-bakeoff"),
|
|
("http://localhost:8090/b/x/", "x"),
|
|
("http://NH3-DEV.nh3.internal:8090/b/x/", "x"), # host-agnostic, any case
|
|
("https://10.100.10.50:8090/b/x/", "x"), # scheme-agnostic
|
|
("http://10.100.10.50:8090/b/my%20booth/", "my booth"), # SR-7: decoded
|
|
("http://10.100.10.50:8090/b/x/zoom/a.png", "x"), # nested path
|
|
("http://10.100.10.50:8090/b/x/?q=1", "x"), # query
|
|
("http://10.100.10.50:8090/b/x/#frag", "x"),
|
|
("http://10.100.10.50:8090/", None), # the Booth root IS a bench
|
|
("http://10.100.10.50:8090/b/", None), # no name
|
|
("http://10.100.10.50:8090/b//", None),
|
|
("http://10.100.10.50:8090/b/.hidden/", None), # resolve_booth's rules
|
|
("http://10.100.10.50:8090/b/%2e%2e/", None), # decoded `..`
|
|
("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/<x>` 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"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("url,expected", BOOTH_URL_TABLE)
|
|
def test_booth_target_classifies(url, expected):
|
|
"""INV-2. The table is shared with the CLI refusal test and the dead-marker
|
|
test, so a second implementation in either place goes red here or there."""
|
|
assert booth_target(url) == expected
|
|
|
|
|
|
def test_booth_target_never_raises():
|
|
"""A board row is arbitrary operator-editable text; a predicate that raises
|
|
on one row takes the whole page. Defeating change: `urlsplit` unguarded."""
|
|
for junk in ["", " ", "http://[oops", "\x00", "://", "http://]"]:
|
|
assert booth_target(junk) is None
|
|
|
|
|
|
# ---- upsert semantics -------------------------------------------------------
|
|
|
|
|
|
def test_added_survives_reregistration_updated_does_not(tmp_path):
|
|
first, created = upsert_bench(tmp_path, "http://a.test/", "one", "o1")
|
|
assert created
|
|
second, created = upsert_bench(tmp_path, "http://a.test/", "two", "o2")
|
|
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):
|
|
"""A promoted bench that re-announces itself is still promoted — otherwise
|
|
every deploy silently demotes it."""
|
|
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
|
set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "promoted")
|
|
again, _ = upsert_bench(tmp_path, "http://a.test/", "one again", "o")
|
|
assert again.state == "promoted"
|
|
|
|
|
|
def test_a_new_bench_is_live(tmp_path):
|
|
bench, _ = upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
|
assert bench.state == "live" and bench.state in BENCH_STATES
|
|
|
|
|
|
def test_set_state_refuses_an_unknown_state(tmp_path):
|
|
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
|
with pytest.raises(ValueError):
|
|
set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "invented")
|
|
|
|
|
|
def test_set_state_and_remove_miss_cleanly(tmp_path):
|
|
assert set_bench_state(tmp_path, "http://nope/", "live") is None
|
|
assert remove_bench(tmp_path, "http://nope/") is None
|
|
|
|
|
|
def test_remove_returns_the_record_and_drops_it(tmp_path):
|
|
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
|
gone = remove_bench(tmp_path, normalize_bench_url("http://a.test/"))
|
|
assert gone is not None and gone.name == "one"
|
|
assert read_benches(tmp_path)[0] == []
|
|
|
|
|
|
def test_fields_are_capped_at_the_write(tmp_path):
|
|
from booth.benches import NAME_MAX, OWNER_MAX
|
|
bench, _ = upsert_bench(tmp_path, "http://a.test/", "n" * 500, "o" * 500)
|
|
assert len(bench.name) == NAME_MAX and len(bench.owner) == OWNER_MAX
|
|
|
|
|
|
def test_a_write_over_a_damaged_registry_does_not_destroy_it(tmp_path):
|
|
"""The 2026-09-21 lesson, in this unit's storage: reads are lenient, writes
|
|
are STRICT. A damaged registry must not be silently replaced by a fresh one
|
|
carrying only the new row — that is the marks-wipe bug in a new file."""
|
|
_write_raw(tmp_path, '{"http://a/": {"name": "real", "owner": "o", "state": "live"}, BROKEN')
|
|
before = (tmp_path / BENCHES_FILE).read_text()
|
|
with pytest.raises(ValueError):
|
|
upsert_bench(tmp_path, "http://b.test/", "new", "o")
|
|
assert (tmp_path / BENCHES_FILE).read_text() == before
|
|
|
|
|
|
def test_the_on_disk_shape_is_an_object_keyed_by_id(tmp_path):
|
|
"""Two rows with one identity are then impossible BY CONSTRUCTION rather
|
|
than by an upsert remembering to check."""
|
|
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
|
raw = json.loads((tmp_path / BENCHES_FILE).read_text())
|
|
# The key is the NORMALIZED url, so the bare "/" is already gone — which is
|
|
# the rule `test_these_pairs_are_one_bench` pins independently.
|
|
assert isinstance(raw, dict) and list(raw) == ["http://a.test"]
|
|
assert "id" not in raw["http://a.test"], "the key IS the id; storing it twice invites drift"
|
|
|
|
|
|
# ---- the rendered surface ---------------------------------------------------
|
|
#
|
|
# The benches panel and the dead-row marker both live on the standing board's
|
|
# page — the one booth carrying a links.md.
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from booth.app import create_app # noqa: E402
|
|
|
|
|
|
def _board(root: pathlib.Path, rows: str) -> pathlib.Path:
|
|
b = root / "links"
|
|
b.mkdir(parents=True, exist_ok=True)
|
|
(b / "links.md").write_text(rows)
|
|
return b
|
|
|
|
|
|
def _client(root):
|
|
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False))
|
|
|
|
|
|
ROW_LIVE = "- [still here](http://10.100.10.50:8090/b/alive/) <sub>· x · 2026-09-01 00:00</sub>\n"
|
|
ROW_DEAD = "- [swept](http://10.100.10.50:8090/b/gone/) <sub>· x · 2026-09-01 00:00</sub>\n"
|
|
ROW_REF = "- [a repo](https://gitea.phasefinal.com/vh/peedlar) <sub>· x · 2026-09-01 00:00</sub>\n"
|
|
|
|
|
|
def _dead_rows(body: str) -> list[str]:
|
|
"""Board ROWS carrying the dead class.
|
|
|
|
Scoped to `<div class="board-row ...">` on purpose: the class name also
|
|
appears in base.html's stylesheet, so a whole-document substring test is
|
|
always true and can never go red — a vacuous falsifier of exactly the shape
|
|
persistent-memory.d/2026-09-22-vacuous-falsifiers.md describes.
|
|
"""
|
|
return [ln for ln in body.splitlines()
|
|
if 'class="board-row' in ln and "board-dead" in ln]
|
|
|
|
|
|
def test_a_dead_row_is_marked_and_a_live_one_is_not(tmp_path):
|
|
"""The marker. Defeating change: marking every `/b/` row dead, or none.
|
|
Both a live target and a dead one are in ONE fixture, so a marker that is
|
|
constant in either direction goes red."""
|
|
(tmp_path / "alive").mkdir()
|
|
_board(tmp_path, ROW_LIVE + ROW_DEAD + ROW_REF)
|
|
r = _client(tmp_path).get("/b/links/")
|
|
assert r.status_code == 200
|
|
rows = _dead_rows(r.text)
|
|
assert len(rows) == 1, f"exactly one of the three rows is dead, got {rows}"
|
|
assert "/b/gone/" in r.text and "booth is gone" in r.text
|
|
|
|
|
|
def test_the_marker_never_takes_the_page(tmp_path):
|
|
"""Seam review SR-2. `resolve_booth` RAISES HTTPException(404); calling it
|
|
per row would turn one swept booth into a 404 for the whole board. This is
|
|
the test that goes red under that exact implementation."""
|
|
_board(tmp_path, ROW_DEAD * 5)
|
|
assert _client(tmp_path).get("/b/links/").status_code == 200
|
|
|
|
|
|
def test_a_percent_encoded_booth_is_not_marked_dead(tmp_path):
|
|
"""Seam review SR-7. `quote(name, safe="")` is how the service emits these,
|
|
so the marker must decode before it looks on disk. Defeating change:
|
|
comparing the raw path segment — which marks this row dead forever."""
|
|
(tmp_path / "my booth").mkdir()
|
|
_board(tmp_path, "- [x](http://10.100.10.50:8090/b/my%20booth/) <sub>· x · 2026-09-01 00:00</sub>\n")
|
|
assert _dead_rows(_client(tmp_path).get("/b/links/").text) == []
|
|
|
|
|
|
def test_a_reference_row_is_never_marked_dead(tmp_path):
|
|
_board(tmp_path, ROW_REF)
|
|
assert _dead_rows(_client(tmp_path).get("/b/links/").text) == []
|
|
|
|
|
|
def test_the_panel_renders_registered_benches(tmp_path):
|
|
_board(tmp_path, ROW_REF)
|
|
upsert_bench(tmp_path, "https://talk.nh3.phasefinal.com:8092/", "talk", "tts-dev")
|
|
body = _client(tmp_path).get("/b/links/").text
|
|
assert "talk" in body and "tts-dev" in body
|
|
|
|
|
|
def test_the_anchor_href_is_the_raw_url_not_the_id(tmp_path):
|
|
"""INV-7. Defeating change: rendering `bench.id` in the href because it is
|
|
'the clean one'. The raw URL here normalizes differently in three ways."""
|
|
raw = "HTTP://Talk.NH3.test:80/Some%20Path/?b=2&a=1#frag"
|
|
_board(tmp_path, ROW_REF)
|
|
upsert_bench(tmp_path, raw, "talk", "o")
|
|
body = _client(tmp_path).get("/b/links/").text
|
|
assert 'href="HTTP://Talk.NH3.test:80/Some%20Path/?b=2&a=1#frag"' in body, \
|
|
"the href must be the URL as posted, byte for byte"
|
|
|
|
|
|
@pytest.mark.parametrize("payload,label", [
|
|
(None, "absent"),
|
|
("not json", "non-JSON"),
|
|
("[]", "wrong top-level shape"),
|
|
('{"http://a/": {"name": [], "owner": "o", "state": "live"}}', "wrong-typed field"),
|
|
('{"http://a/": {"name": "n", "owner": "o", "state": "invented"}}', "unknown state"),
|
|
("OVERSIZED", "over the size cap"),
|
|
("UNREADABLE", "chmod 000"),
|
|
("FIFO", "a named pipe"),
|
|
])
|
|
def test_a_damaged_registry_costs_its_panel_and_never_the_page(tmp_path, payload, label):
|
|
"""INV-5, at the render. The v0.2.2 lesson: a poisoned sidecar returned 500
|
|
for `/` and `/healthz` across all 25 booths. The wrong-typed-FIELD row is
|
|
the shape currently 500ing the gallery elsewhere in this service, so it is
|
|
the one that matters most."""
|
|
_board(tmp_path, ROW_REF)
|
|
reg = tmp_path / BENCHES_FILE
|
|
if payload == "OVERSIZED":
|
|
from booth.benches import BENCHES_MAX_BYTES
|
|
reg.write_text('{"http://a/": {"name": "' + "x" * BENCHES_MAX_BYTES + '"}}')
|
|
elif payload == "UNREADABLE":
|
|
if os.geteuid() == 0:
|
|
pytest.skip("root ignores the mode bit")
|
|
reg.write_text("{}")
|
|
reg.chmod(0o000)
|
|
elif payload == "FIFO":
|
|
os.mkfifo(reg)
|
|
elif payload is not None:
|
|
reg.write_text(payload)
|
|
try:
|
|
c = _client(tmp_path)
|
|
body = c.get("/b/links/")
|
|
assert body.status_code == 200, label
|
|
assert c.get("/").status_code == 200, label
|
|
assert c.get("/healthz").status_code == 200, label
|
|
# AND THE ERROR IS VISIBLE. Asserting only 200 was the gap: a render
|
|
# that swallowed the failure and drew an empty panel passed every case
|
|
# here while telling the operator nothing needed fixing. Absent is the
|
|
# one case that must NOT show an error.
|
|
shown = "the bench registry could not be read" in body.text
|
|
assert shown is (payload is not None), label
|
|
finally:
|
|
if payload == "UNREADABLE":
|
|
reg.chmod(0o644)
|
|
|
|
|
|
def test_damaged_and_absent_render_different_text(tmp_path):
|
|
"""INV-5. Only ONE of them needs a human. Defeating change: `except: pass`
|
|
returning ([], None), which renders damaged exactly like absent."""
|
|
_board(tmp_path, ROW_REF)
|
|
absent = _client(tmp_path).get("/b/links/").text
|
|
(tmp_path / BENCHES_FILE).write_text("not json")
|
|
damaged = _client(tmp_path).get("/b/links/").text
|
|
assert absent != damaged
|
|
|
|
|
|
def test_the_panel_does_not_render_on_an_ordinary_booth(tmp_path):
|
|
"""A bench registry on every gallery page would be noise, and would cost a
|
|
read per booth page view for a surface that belongs to exactly one."""
|
|
(tmp_path / "ordinary").mkdir()
|
|
(tmp_path / "ordinary" / "a.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
upsert_bench(tmp_path, "https://talk.test/", "talk", "o")
|
|
assert "talk" not in _client(tmp_path).get("/b/ordinary/").text
|
|
|
|
|
|
def test_the_benches_routes_round_trip(tmp_path):
|
|
_board(tmp_path, ROW_REF)
|
|
c = _client(tmp_path)
|
|
assert c.post("/b/links/bench-add", data={"url": "https://x.test/", "name": "ex"},
|
|
follow_redirects=False).status_code in (302, 303)
|
|
assert "ex" in c.get("/b/links/").text
|
|
bid = normalize_bench_url("https://x.test/")
|
|
c.post("/b/links/bench-state", data={"bench": bid, "state": "retired"},
|
|
follow_redirects=False)
|
|
assert read_benches(tmp_path)[0][0].state == "retired"
|
|
c.post("/b/links/bench-remove", data={"bench": bid}, follow_redirects=False)
|
|
assert read_benches(tmp_path)[0] == []
|
|
|
|
|
|
def test_a_bad_url_posted_to_the_route_does_not_500(tmp_path):
|
|
_board(tmp_path, ROW_REF)
|
|
c = _client(tmp_path)
|
|
r = c.post("/b/links/bench-add", data={"url": "ftp://x.test/f", "name": "ex"},
|
|
follow_redirects=False)
|
|
assert r.status_code in (302, 303, 400)
|
|
assert c.get("/b/links/").status_code == 200
|
|
|
|
|
|
# ---- found by the in-session adversarial pass, after the cold panels shipped -
|
|
|
|
|
|
def test_a_fifo_at_the_registry_path_cannot_hang_the_render(tmp_path):
|
|
"""A NAMED PIPE IS NOT A REGULAR FILE, AND open() BLOCKS ON IT.
|
|
|
|
This is the 2026-09-22 lesson recurring in a new file: a size cap that
|
|
bounds the READ does not help, because the hang is in `open()` — a FIFO
|
|
with no writer blocks there forever, before a single byte is bounded.
|
|
`read_benches` runs on the board page's render path, so one FIFO would hang
|
|
that request and, with enough hits, the threadpool behind every route.
|
|
|
|
The guard is a REGULAR-FILE check before the open, which is what marks.py
|
|
already does (`stat.S_ISREG`). Defeating change: reverting to `path.open()`
|
|
guarded only by a byte cap — which is what this unit shipped first, while
|
|
its docstring claimed the cap closed exactly this hole.
|
|
"""
|
|
os.mkfifo(tmp_path / BENCHES_FILE)
|
|
benches, err = read_benches(tmp_path) # must RETURN, not block
|
|
assert benches == [] and err
|
|
|
|
|
|
def test_a_directory_at_the_registry_path_is_an_error_not_a_crash(tmp_path):
|
|
(tmp_path / BENCHES_FILE).mkdir()
|
|
benches, err = read_benches(tmp_path)
|
|
assert benches == [] and err
|
|
|
|
|
|
@pytest.mark.parametrize("encoded", ["%00", "%0a", "%0d", "%09", "%1b"])
|
|
def test_a_control_character_is_not_an_addressable_booth(encoded):
|
|
"""`unquote` happily produces a NUL or a newline, and neither can name a
|
|
real directory. Left unfiltered they reach `is_dir()` (which raises
|
|
ValueError on an embedded NUL on some paths), the refusal message the CLI
|
|
prints, and the marker the board renders. Defeating change: dropping the
|
|
control-character clause — the `%2e%2e` and `%2f` rows above stay green
|
|
under it, so this needs its own."""
|
|
assert booth_target(f"http://h:8090/b/{encoded}/") is None
|
|
|
|
|
|
def test_normalization_is_idempotent(tmp_path):
|
|
"""LOAD-BEARING for `bench state <id|url>` and `bench rm <id|url>`: both
|
|
normalize whatever they are handed, so an id must normalize to itself or
|
|
addressing a bench by the id the registry stores would miss it. Defeating
|
|
change: any rule that rewrites an already-normalized form."""
|
|
for u in (GITEA_EIGHT + TALK_FIVE + [
|
|
"http://x.test/", "http://x.test:8080/p/", "https://x.test/?a=1",
|
|
"HTTP://X.Test:80/Some%20Path/?b=2&a=1#frag",
|
|
]):
|
|
once = normalize_bench_url(u)
|
|
assert normalize_bench_url(once) == once, u
|
|
|
|
|
|
def test_a_failed_write_leaves_no_scratch_file(tmp_path, monkeypatch):
|
|
"""The temp file is named per-pid so two writers cannot share it, but a
|
|
write that dies between create and replace would strand it beside the
|
|
registry forever. Defeating change: dropping the cleanup."""
|
|
import booth.benches as B
|
|
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
|
real = B.os.replace
|
|
|
|
def boom(src, dst):
|
|
raise OSError("disk full")
|
|
monkeypatch.setattr(B.os, "replace", boom)
|
|
with pytest.raises(OSError):
|
|
upsert_bench(tmp_path, "http://b.test/", "two", "o")
|
|
monkeypatch.setattr(B.os, "replace", real)
|
|
strays = [p.name for p in tmp_path.iterdir() if ".tmp" in p.name]
|
|
assert not strays, strays
|
|
# and the prior registry is intact — a failed write destroys nothing
|
|
assert [b.name for b in read_benches(tmp_path)[0]] == ["one"]
|
|
|
|
|
|
def test_an_ipv6_literal_keeps_its_brackets():
|
|
"""`urlsplit().hostname` strips them, and a netloc rebuilt from it is not
|
|
another spelling of the URL — it is a broken one, so a re-post never
|
|
matches the row the operator means to update. Defeating change: rebuilding
|
|
netloc from `hostname` with no re-wrap, which is what this shipped as."""
|
|
assert normalize_bench_url("http://[::1]:8080/a") == "http://[::1]:8080/a"
|
|
assert normalize_bench_url("http://[2001:DB8::1]/p") == "http://[2001:db8::1]/p"
|
|
assert normalize_bench_url("HTTP://[::1]:80/p") == "http://[::1]/p"
|
|
# An UNBRACKETED IPv6 netloc is refused with a reason, not repaired:
|
|
# `urlsplit(...).port` raises on `::1:8080` because it cannot tell the
|
|
# address from the port — which is precisely why the brackets exist. The
|
|
# refusal is the honest answer; guessing where the address ends would be
|
|
# inventing an identity out of an ambiguous string.
|
|
with pytest.raises(ValueError):
|
|
normalize_bench_url("http://::1:8080/a")
|
|
|
|
|
|
def test_deeply_nested_json_does_not_escape_the_read(tmp_path):
|
|
"""RecursionError is neither ValueError nor OSError, so it went straight
|
|
past `read_benches`'s except pair and 500'd the page the function exists to
|
|
protect. The byte cap does not help: 200k open brackets is 200 KB, well
|
|
inside it. Defeating change: dropping the RecursionError arm."""
|
|
(tmp_path / BENCHES_FILE).write_text("[" * 200_000)
|
|
benches, err = read_benches(tmp_path)
|
|
assert benches == [] and err
|
|
|
|
|
|
def test_the_panel_renders_on_an_EMPTY_board(tmp_path):
|
|
"""The panel is gated on PAGE IDENTITY, not page content. Gating on
|
|
`board or benches` hid the panel and its registration form exactly when the
|
|
board was empty and the registry absent — the state a new deployment starts
|
|
in, and the one where "no benches registered yet" is most worth saying.
|
|
Defeating change: any content-derived gate."""
|
|
_board(tmp_path, "")
|
|
body = _client(tmp_path).get("/b/links/").text
|
|
assert "no benches registered yet" in body
|
|
assert "bench-add" in body, "the registration form vanished with the panel"
|
|
|
|
|
|
def test_the_panel_shows_when_a_bench_was_added(tmp_path):
|
|
"""INV-N/What renders: the contract says the panel shows the date it was
|
|
added; `b.added` appeared nowhere in the template and no test asked. All
|
|
four cold arms found this independently."""
|
|
_board(tmp_path, ROW_REF)
|
|
upsert_bench(tmp_path, "https://talk.test/", "talk", "o")
|
|
added = read_benches(tmp_path)[0][0].added[:10]
|
|
assert added in _client(tmp_path).get("/b/links/").text
|
|
|
|
|
|
@pytest.mark.parametrize("url,expected", BOOTH_URL_TABLE)
|
|
def test_the_dead_marker_classifies_the_SAME_table(tmp_path, url, expected):
|
|
"""INV-2 names RENDER-LEVEL agreement, and the marker tests never ran the
|
|
table — three hand-written rows with no query between them, so a marker
|
|
that stopped calling `booth_target` and treated `?q=1` as "not a booth"
|
|
stayed green while disagreeing with the CLI. Caught by the cold panel.
|
|
|
|
Every row whose target does not exist on disk must be marked dead; every
|
|
non-booth row must not be."""
|
|
_board(tmp_path, f"- [r]({url}) <sub>· x · 2026-09-01 00:00</sub>\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
|
|
|
|
|
|
def test_the_benches_panel_is_not_nested_inside_a_span(tmp_path):
|
|
"""Cold bug-hunt panel, 3-of-4, seat-confirmed by byte offset in the live
|
|
document: the panel `<div>` had landed INSIDE the booth header's
|
|
`<span class="sub">`, because the insertion matched the first
|
|
`{% if board %}` in the template rather than the block-level one.
|
|
|
|
A `<div>` inside a `<span>` is invalid HTML — the parser closes the span
|
|
implicitly and hoists the div out, orphaning the rest of the sub-line. It
|
|
renders "fine" in the sense that nothing 500s, which is exactly why no
|
|
other test in this file could see it.
|
|
|
|
Checked the way the seat checked it: by offset. Defeating change: moving
|
|
the panel back above the sub-span's close."""
|
|
_board(tmp_path, ROW_REF)
|
|
upsert_bench(tmp_path, "https://talk.test/", "talk", "o")
|
|
body = _client(tmp_path).get("/b/links/").text
|
|
sub_open = body.index('<span class="sub">')
|
|
sub_close = body.index("</span>", body.index("· ", sub_open))
|
|
panel = body.index('<div class="benches">')
|
|
assert not (sub_open < panel < sub_close), (
|
|
f"the benches div (offset {panel}) sits inside the sub span "
|
|
f"({sub_open}..{sub_close})")
|
|
|
|
|
|
def test_a_symlinked_booth_is_dead_to_the_marker_as_it_is_to_the_page(tmp_path):
|
|
"""Cold bug-hunt panel, 3-of-4: `_booth_exists` used a bare `is_dir()`
|
|
while `resolve_booth` resolves and requires the parent to BE the data root.
|
|
They disagreed on a symlink — the marker called a booth pointing outside
|
|
the root alive while the page 404s it, so the row rendered healthy and the
|
|
link was dead. The worst of both, and invisible.
|
|
|
|
Defeating change: dropping the containment check from `_booth_exists`."""
|
|
outside = tmp_path.parent / f"outside-{tmp_path.name}"
|
|
outside.mkdir()
|
|
try:
|
|
(tmp_path / "escapee").symlink_to(outside, target_is_directory=True)
|
|
except OSError:
|
|
pytest.skip("no symlink support here")
|
|
_board(tmp_path, "- [x](http://h:8090/b/escapee/) <sub>· a · 2026-09-01 00:00</sub>\n")
|
|
c = _client(tmp_path)
|
|
body = c.get("/b/links/")
|
|
assert body.status_code == 200
|
|
# the page's own verdict on that name, which the marker must agree with
|
|
assert c.get("/b/escapee/").status_code == 404
|
|
assert _dead_rows(body.text), "the marker called a booth alive that the page 404s"
|