"""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'', body, re.S).group(0)
out = {}
for f in re.findall(r']*>.*?', film, re.S):
rel = re.search(r'data-rel="([^"]*)"', f).group(1)
out[rel] = "".join(re.findall(r'([AB]+)', 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".*?', body, re.S).group(0)
label_b = re.search(r'data-region="label-b".*?', 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".*?' % 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 , 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'', 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'