Files
booth/tests/test_compare.py
T
vh f8d136a521 fix(r3): fold heid's bug hunt — no link offers a pair that 404s, NUL booth names, a FIFO marker, encoded view-state names
Navigation was built from the review ring while the compare GET also demands
containment, so an outside symlink (which stays in the ring) was offered by
the strip, the steps, the review's Compare control and the flag landing, and
404ed on arrival. Every one is now built from the compare ring (the review
ring filtered by the same conjunction, _in_booth).

Two pre-existing gaps compare inherits, fixed at the source: resolve_booth
caught only OSError, so a NUL in the booth segment was a 500; record_view
opened its marker blocking, so a planted FIFO hung every look. Plus: the page
treats %73ide=a as side=a, and the subgrid engine floor is stated. Two
findings refuted (a chorded click mid-drag never fires pointerup, measured;
booth_items never yields an unquotable rel). r3.toml: 57 rows.
2026-09-24 14:39:06 -07:00

369 lines
17 KiB
Python

"""R3 — compare: two picked items of a booth side by side.
Contract: docs/contracts/r3_compare.contract.md. The server half: the route,
the pair, the step and strip links, the regions, and the JS-off flag landing.
The browser half is tests/test_compare_browser.py.
"""
from __future__ import annotations
import pathlib
import re
import sys
from urllib.parse import parse_qs, urlsplit
from fastapi.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402
PNG = b"\x89PNG\r\n\x1a\n"
def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path:
b = root / name
b.mkdir()
for rel, data in files.items():
p = b / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return b
def _client(root: pathlib.Path) -> TestClient:
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False),
follow_redirects=False)
def _frames(body: str) -> dict[str, str]:
"""rel -> the A/B marks its filmstrip frame carries ('' for none), in
strip order."""
film = re.search(r'<nav class="film"[^>]*data-region="film".*?</nav>', body, re.S).group(0)
out = {}
for f in re.findall(r'<a class="film-f[^"]*"[^>]*>.*?</a>', film, re.S):
rel = re.search(r'data-rel="([^"]*)"', f).group(1)
out[rel] = "".join(re.findall(r'<span class="film-ab">([AB]+)</span>', f))
return out
# ---- C1: the route and the pair ----------------------------------------------
def test_compare_renders_the_pair(tmp_path):
"""The tracer: four pictures, #1 against #3. Both names and both ordinals
are printed, and the filmstrip marks #1 A and #3 B."""
_booth(tmp_path, "g", {f"{n}.png": PNG for n in ("p", "q", "r", "s")})
r = _client(tmp_path).get("/b/g/compare?a=p.png&b=r.png")
assert r.status_code == 200
body = r.text
label_a = re.search(r'data-region="label-a".*?</div>', body, re.S).group(0)
label_b = re.search(r'data-region="label-b".*?</div>', body, re.S).group(0)
assert "p.png" in label_a and "#1" in label_a, label_a
assert "r.png" in label_b and "#3" in label_b, label_b
assert _frames(body) == {"p.png": "A", "q.png": "", "r.png": "B", "s.png": ""}
assert list(_frames(body)) == ["p.png", "q.png", "r.png", "s.png"], "the strip is in RING order"
# each side's flag form names its OWN item
for key, rel in (("a", "p.png"), ("b", "r.png")):
form = re.search(r'data-region="flag-%s".*?</form>' % key, body, re.S).group(0)
assert f'name="target" value="{rel}"' in form, (key, form)
def _four(root: pathlib.Path) -> pathlib.Path:
"""Four pictures, a doc, a caption sidecar and a dotfile: every kind of
thing a side can name that is not a side."""
return _booth(root, "g", {"p.png": PNG, "q.png": PNG, "r.png": PNG, "s.png": PNG,
"notes.md": b"# n", "p.png.txt": b"a caption",
".hidden.png": PNG})
def test_a_bad_side_is_a_404(tmp_path):
"""Missing, traversal, a NUL, a dotfile, a doc item, a non-item file: a 404
each, on either side, never a 500."""
_four(tmp_path)
c = _client(tmp_path)
bad = ["", "../g/p.png/..", "../../etc/passwd", "p.png\x00", ".hidden.png",
"notes.md", "p.png.txt", "gone.png", "sub/"]
for rel in bad:
for q in ({"a": rel, "b": "q.png"}, {"a": "q.png", "b": rel}):
r = c.get("/b/g/compare", params=q)
assert r.status_code == 404, (q, r.status_code)
for q in ({"b": "q.png"}, {"a": "q.png"}, {}):
assert c.get("/b/g/compare", params=q).status_code == 404, q
def test_a_missing_param_is_404_not_422(tmp_path):
"""The review declares `f: str` and answers 422 without it; compare
declares both sides with a default and answers 404."""
_four(tmp_path)
r = _client(tmp_path).get("/b/g/compare?a=p.png")
assert r.status_code == 404
def test_an_outside_symlink_in_the_ring_is_404(tmp_path):
"""`booth_items` follows symlinks, so a link pointing OUTSIDE the booth is
in the review ring; only the containment check refuses it."""
from booth.items import booth_items, review_chain
b = _four(tmp_path)
outside = tmp_path / "elsewhere.png"
outside.write_bytes(PNG)
(b / "zz-link.png").symlink_to(outside)
assert "zz-link.png" in review_chain(booth_items(b)), "the fixture must put it in the ring"
# a SIBLING whose name shares the booth's prefix is outside too (the
# containment check compares with the separator, never a bare prefix)
sib = tmp_path / "g-extra"
sib.mkdir()
(sib / "x.png").write_bytes(PNG)
(b / "zz-sib.png").symlink_to(sib / "x.png")
c = _client(tmp_path)
for rel in ("zz-link.png", "zz-sib.png"):
assert c.get(f"/b/g/compare?a={rel}&b=p.png").status_code == 404, rel
assert c.get(f"/b/g/compare?a=p.png&b={rel}").status_code == 404, rel
def test_no_navigation_offers_a_pair_that_404s(tmp_path):
"""An outside symlink stays in the review ring, and compare 404s it. So no
compare link may offer it: not the strip, not a step, not the review's
Compare control, not the JS-off flag landing (heid bug hunt, 3 of 4)."""
b = _booth(tmp_path, "g", {"a.png": PNG, "c.png": PNG})
outside = tmp_path / "elsewhere.png"
outside.write_bytes(PNG)
(b / "b-link.png").symlink_to(outside)
c = _client(tmp_path)
body = c.get("/b/g/compare?a=a.png&b=c.png").text
assert list(_frames(body)) == ["a.png", "c.png"], _frames(body)
for h in _compare_links(body):
q = parse_qs(urlsplit(h).query)
assert "b-link.png" not in (q["a"][0], q["b"][0]), h
assert _step(body, "a-next") == ("c.png", "c.png") # steps over it
assert _compare_href(c.get("/b/g/view?f=a.png").text) == ("a.png", "c.png")
r = c.post("/b/g/flag", data={"target": "a.png", "on": "1", "back": "compare",
"a": "a.png", "b": "b-link.png"})
assert r.headers["location"] == "/b/g/#item-a.png", r.headers["location"]
def test_hostile_booth_names_are_404_not_500(tmp_path):
"""A NUL in the booth segment makes Path.resolve raise ValueError, which
is not an OSError: it must still be a 404 (heid bug hunt, hulda)."""
_four(tmp_path)
c = _client(tmp_path)
for path in ("/b/g%00/compare?a=p.png&b=q.png", "/b/g%00/view?f=p.png", "/b/g%00/"):
assert c.get(path).status_code == 404, path
def test_a_planted_fifo_marker_cannot_hang_a_look(tmp_path):
"""Recording a look never costs the page: a FIFO planted at `.viewed` must
not block the open that touches it (heid bug hunt, hulda)."""
import os
import threading
b = _four(tmp_path)
os.mkfifo(b / ".viewed")
got = []
t = threading.Thread(target=lambda: got.append(
_client(tmp_path).get("/b/g/compare?a=p.png&b=q.png").status_code), daemon=True)
t.start()
t.join(10)
assert got == [200], "a planted FIFO held the look open"
def test_a_look_records_both_seen(tmp_path):
"""A compare GET is a look at both sides; a 404 records nothing."""
import json
b = _four(tmp_path)
c = _client(tmp_path)
assert c.get("/b/g/compare?a=p.png&b=gone.png").status_code == 404
assert not (b / ".seen").exists() and not (b / ".viewed").exists()
assert c.get("/b/g/compare?a=q.png&b=s.png").status_code == 200
assert set(json.loads((b / ".seen").read_text())) == {"q.png", "s.png"}
assert (b / ".viewed").exists()
def test_compare_carries_data_booth(tmp_path):
"""Reveal all's script and the head script's reveal restore both read
`data-booth` off <html>, and bail without it."""
_four(tmp_path)
body = _client(tmp_path).get("/b/g/compare?a=p.png&b=q.png").text
assert re.search(r'<html lang="en" data-booth="g">', body)
def test_no_data_region_repeats(tmp_path):
"""The swap keeps the FIRST fresh node per id and copies it over EVERY live
node with that id, so a shared id would turn B's flag into A's. Unique,
keyed by side — including when a == b."""
_four(tmp_path)
c = _client(tmp_path)
for q in ("a=p.png&b=r.png", "a=q.png&b=q.png"):
# attributes only: base.html's script names `[data-region="status"]`
ids = re.findall(r'\sdata-region="([^"]+)"', c.get(f"/b/g/compare?{q}").text)
assert len(ids) == len(set(ids)), (q, ids)
assert {"flag-a", "flag-b", "label-a", "label-b", "film"} <= set(ids), ids
assert not [i for i in ids if i.startswith("item-")], ids
body = c.get("/b/g/compare?a=q.png&b=q.png").text
assert _frames(body)["q.png"] == "AB", "a == b marks the one frame both ways"
def test_a_video_or_track_plays_in_its_own_stage_and_two_get_no_toggle(tmp_path):
"""C4: video and audio play in their own stage; the Fit | 1:1 toggle is
bound only when a side is a picture, so two videos get none."""
_booth(tmp_path, "g", {"a.webm": b"\x1aE\xdf\xa3", "b.mp3": b"ID3", "c.png": PNG})
c = _client(tmp_path)
body = c.get("/b/g/compare?a=a.webm&b=b.mp3").text
assert re.search(r'<video class="cmp-media"[^>]*src="a.webm"', body)
assert re.search(r'<audio class="cmp-media"[^>]*src="b.mp3"', body)
assert 'id="vtoggle"' not in body
assert 'id="vtoggle"' in c.get("/b/g/compare?a=a.webm&b=c.png").text
# ---- C3: stepping --------------------------------------------------------------
def _ring6(root: pathlib.Path) -> pathlib.Path:
"""Six media with a DOC between them: 03-notes.md takes ordinal 3, so an
ordinal is not a ring position."""
return _booth(root, "g", {"01.png": PNG, "02.png": PNG, "03-notes.md": b"# n",
"04.png": PNG, "05.png": PNG, "06.png": PNG, "07.png": PNG})
def _step(body: str, which: str) -> tuple[str, str]:
"""The (a, b) rels a step link targets."""
href = re.search(r'<a [^>]*data-step="%s"[^>]*href="([^"]+)"' % which, body)
href = href or re.search(r'<a [^>]*href="([^"]+)"[^>]*data-step="%s"' % which, body)
q = parse_qs(urlsplit(href.group(1).replace("&amp;", "&")).query)
return q["a"][0], q["b"][0]
def test_linked_steps_keep_the_distance_and_wrap(tmp_path):
"""Ring positions 2 and 5 step forward to (3, 6), then (4, 1) — wrapped —
and back from (1, 4) to (6, 3). Named by rel, never by ordinal."""
_ring6(tmp_path)
c = _client(tmp_path)
body = c.get("/b/g/compare?a=02.png&b=06.png").text
assert _step(body, "both-next") == ("04.png", "07.png")
body = c.get("/b/g/compare?a=04.png&b=07.png").text
assert _step(body, "both-next") == ("05.png", "01.png")
body = c.get("/b/g/compare?a=07.png&b=02.png").text
assert _step(body, "both-next") == ("01.png", "04.png"), "A wraps as B does"
body = c.get("/b/g/compare?a=01.png&b=05.png").text
assert _step(body, "both-prev") == ("07.png", "04.png")
# each side on its own moves only itself, and wraps the same way
assert _step(body, "a-prev") == ("07.png", "05.png")
assert _step(body, "a-next") == ("02.png", "05.png")
assert _step(body, "b-prev") == ("01.png", "04.png")
assert _step(body, "b-next") == ("01.png", "06.png")
def _compare_links(body: str) -> list[str]:
"""Every step and filmstrip href on the page, entity-decoded."""
hrefs = re.findall(r'<a [^>]*(?:data-step="[^"]+"[^>]*href|class="film-f[^"]*"[^>]*href)="([^"]+)"', body)
return [h.replace("&amp;", "&") for h in hrefs]
def test_the_urls_are_keyed_by_rel(tmp_path):
"""Every step and strip link names both sides by rel, url-quoted; none
carries an ordinal or any key beyond the pair and the view state."""
_booth(tmp_path, "g", {"a b.png": PNG, "sub dir/c#d.png": PNG, "e&f.png": PNG})
body = _client(tmp_path).get("/b/g/compare", params={"a": "a b.png", "b": "sub dir/c#d.png"}).text
links = _compare_links(body)
assert len(links) == 6 + 3, links # six steps, three frames
for h in links:
u = urlsplit(h)
assert u.path == "/b/g/compare", h
assert " " not in h and "#" not in u.query.replace("%23", ""), h
q = parse_qs(u.query)
assert set(q) == {"a", "b"}, h
assert all(v in ("a b.png", "sub dir/c#d.png", "e&f.png") for v in (q["a"][0], q["b"][0])), h
def test_view_state_rides_the_links(tmp_path):
"""`side=a&link=0` rides every step and strip link; an unknown value reads
as the default (B active, linked) and is never an error."""
_four(tmp_path)
c = _client(tmp_path)
body = c.get("/b/g/compare?a=p.png&b=r.png&side=a&link=0").text
links = _compare_links(body)
assert links and all(h.endswith("&side=a&link=0") for h in links), links
assert 'class="cmp-side is-active" data-side="a"' in body
# with A active a frame replaces A, keeping B
frame = re.search(r'<a class="film-f[^"]*"\s+href="([^"]+)" data-rel="q.png"', body).group(1)
assert parse_qs(urlsplit(frame.replace("&amp;", "&")).query) == {
"a": ["q.png"], "b": ["r.png"], "side": ["a"], "link": ["0"]}
links = _compare_links(c.get("/b/g/compare?a=p.png&b=r.png&link=0").text)
assert links and all(h.endswith("&link=0") and "side=" not in h for h in links), links
for odd in ("side=z&link=maybe", "side=A&link=00", "side=&link="):
r = c.get(f"/b/g/compare?a=p.png&b=r.png&{odd}")
assert r.status_code == 200, odd
links = _compare_links(r.text)
assert all(set(parse_qs(urlsplit(h).query)) == {"a", "b"} for h in links), (odd, links)
assert 'class="cmp-side is-active" data-side="b"' in r.text, odd
assert 'data-linked="1"' in r.text, odd
# ---- C5: judging without JS -----------------------------------------------------
def _flag(c: TestClient, form: dict, accept: str | None = None):
headers = {"accept": accept} if accept else {}
r = c.post("/b/g/flag", data={"target": "q.png", "on": "1", **form}, headers=headers)
assert r.status_code == (204 if accept else 303), (form, r.status_code)
return r
def test_a_flag_without_js_lands_on_the_same_pair(tmp_path):
"""`back=compare` lands on exactly the pair, built from the checked rels,
with the view state mapped from a closed set and never echoed, and no
fragment. Anything outside the ring takes the no-`back` landing; the
in-place answer is the 204 it always was."""
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG, "sub dir/r s.png": PNG, "n.md": b"# n"})
c = _client(tmp_path)
pair = {"back": "compare", "a": "p.png", "b": "sub dir/r s.png"}
want = "/b/g/compare?a=p.png&b=sub%20dir/r%20s.png"
r = _flag(c, pair)
assert r.status_code == 303 and r.headers["location"] == want
assert _flag(c, {**pair, "side": "a", "link": "0"}).headers["location"] == want + "&side=a&link=0"
assert _flag(c, {**pair, "link": "0"}).headers["location"] == want + "&link=0"
for odd in ({"side": "A"}, {"link": "00"}, {"side": "b", "link": "1"},
{"side": "a#x", "link": "0&side=a"}):
assert _flag(c, {**pair, **odd}).headers["location"] == want, odd
no_back = _flag(c, {"target": "q.png"}).headers["location"]
assert no_back == "/b/g/#item-q.png"
for bad in ({"a": "n.md"}, {"b": "gone.png"}, {"a": "../g/p.png"}, {"b": ""}):
assert _flag(c, {**pair, **bad}).headers["location"] == no_back, bad
r = _flag(c, pair, accept="application/json")
assert r.status_code == 204 and "location" not in r.headers
def test_every_other_landing_is_byte_identical(tmp_path):
"""R2 INV-4: `back=view`, `back=marks` and no `back` land exactly where they
did before compare existed."""
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG})
c = _client(tmp_path)
assert _flag(c, {}).headers["location"] == "/b/g/#item-q.png"
assert _flag(c, {"back": "marks"}).headers["location"] == "/b/g/marks#item-q.png"
assert _flag(c, {"back": "view", "f": "p.png"}).headers["location"] == "/b/g/view?f=p.png#rail"
assert _flag(c, {"back": "view", "f": "gone.png"}).headers["location"] == "/b/g/#item-q.png"
assert _flag(c, {"back": "Compare", "a": "p.png", "b": "q.png"}).headers["location"] == "/b/g/#item-q.png"
# ---- C2: picking from the review ------------------------------------------------
def _compare_href(body: str) -> tuple[str, str]:
href = re.search(r'<a class="vbtn vcompare"[^>]*href="([^"]+)"', body).group(1)
u = urlsplit(href.replace("&amp;", "&"))
assert u.path == "/b/g/compare", href
q = parse_qs(u.query)
assert set(q) == {"a", "b"}, href
return q["a"][0], q["b"][0]
def test_the_review_offers_compare_with_the_next_item(tmp_path):
"""The review's Compare control opens this item against the NEXT media item
in the ring — skipping the doc — and the last wraps to the first. A ring of
one compares the item with itself."""
_ring6(tmp_path)
c = _client(tmp_path)
assert _compare_href(c.get("/b/g/view?f=02.png").text) == ("02.png", "04.png")
assert _compare_href(c.get("/b/g/view?f=07.png").text) == ("07.png", "01.png")
_booth(tmp_path, "one", {"only.png": PNG, "n.md": b"# n"})
body = c.get("/b/one/view?f=only.png").text
href = re.search(r'<a class="vbtn vcompare"[^>]*href="([^"]+)"', body).group(1)
assert href.replace("&amp;", "&") == "/b/one/compare?a=only.png&b=only.png"
# a doc's own page is not a review, and offers no compare
assert 'class="vbtn vcompare"' not in c.get("/b/g/view?f=03-notes.md").text