"""Per-item blur storage — `.blurred` round-trips any rel, whoever writes it. `.blurred` was one stripped rel per line, so a rel with a leading space could not survive a write: blurring " a.png" stored "a.png", and toggled the neighbour instead (heid bug-hunt on r2b merge 1, reported to booth-dev). It is now a JSON array, the `.seen` shape, read without following a link or blocking on a FIFO. The old line format is still READ, so nothing live changes until the next write upgrades it. Two writers share the file: the service (the operator's per-item control) and `scripts/booth blur` (a session at post time). Both go through `booth.blur`, which is stdlib-only so the CLI can import it under the system python3. """ from __future__ import annotations import json import os import pathlib import subprocess import sys import threading from fastapi.testclient import TestClient sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) from booth.app import BLUR_FILE, create_app, read_blurred, set_blurred # noqa: E402 from booth.items import booth_items # noqa: E402 PNG = b"\x89PNG\r\n\x1a\n" SCRIPT = pathlib.Path(__file__).parent.parent / "scripts" / "booth" def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path: b = root / name b.mkdir() for rel, data in files.items(): (b / rel).write_bytes(data) return b def _within(seconds: float, fn): """Run fn in a thread and fail, rather than hang the suite, if it blocks.""" out: dict = {} t = threading.Thread(target=lambda: out.setdefault("v", fn()), daemon=True) t.start() t.join(seconds) assert not t.is_alive(), f"{fn} blocked for over {seconds}s" return out["v"] # ---- the round-trip: the defect --------------------------------------------- def test_a_leading_space_rel_round_trips(tmp_path): """Defeating change: storing rels line-stripped (the old format).""" set_blurred(tmp_path, " a.png", True) assert read_blurred(tmp_path) == {" a.png"} def test_unblurring_a_leading_space_rel_leaves_its_neighbour_blurred(tmp_path): """The reported wrong-item write: " a.png" and "a.png" are two items, and toggling one must never move the other. (Unblurring the SPACED one would pass under the old format too — it was a no-op there — so this unblurs the plain one and asks whether the spaced one survived.)""" set_blurred(tmp_path, "a.png", True) set_blurred(tmp_path, " a.png", True) set_blurred(tmp_path, "a.png", False) assert read_blurred(tmp_path) == {" a.png"} def test_a_newline_in_a_rel_round_trips(tmp_path): """A line format cannot hold one at all.""" set_blurred(tmp_path, "two\nlines.png", True) assert read_blurred(tmp_path) == {"two\nlines.png"} def test_the_file_is_a_json_array_in_sorted_order(tmp_path): """The `.seen` shape, and a stated order (invariant 6) so two writes of the same set are byte-identical.""" set_blurred(tmp_path, "b.png", True) set_blurred(tmp_path, "a.png", True) assert json.loads((tmp_path / BLUR_FILE).read_text("utf-8")) == ["a.png", "b.png"] def test_emptying_the_set_removes_the_file(tmp_path): """Unchanged: an empty marker is a lie by omission.""" set_blurred(tmp_path, "a.png", True) set_blurred(tmp_path, "a.png", False) assert not (tmp_path / BLUR_FILE).exists() # ---- the legacy format: nothing live changes until it is written ------------ def test_the_legacy_line_format_still_reads(tmp_path): """Six live booths hold line-format files. Defeating change: a JSON-only reader, which would un-blur every one of them on deploy.""" (tmp_path / BLUR_FILE).write_text("a.png\nsub/b.png\n\n") assert read_blurred(tmp_path) == {"a.png", "sub/b.png"} def test_a_legacy_rel_that_starts_with_a_bracket_still_reads(tmp_path): """A line-format file whose first rel happens to begin with "[" is not JSON, and must fall back to lines rather than read as nothing.""" (tmp_path / BLUR_FILE).write_text("[draft] a.png\nb.png\n") assert read_blurred(tmp_path) == {"[draft] a.png", "b.png"} def test_a_write_upgrades_a_legacy_file_and_keeps_its_rels(tmp_path): (tmp_path / BLUR_FILE).write_text("a.png\n") set_blurred(tmp_path, "b.png", True) assert json.loads((tmp_path / BLUR_FILE).read_text("utf-8")) == ["a.png", "b.png"] # ---- a planted file: never blocks, never follows ---------------------------- def test_a_fifo_blur_file_does_not_block_the_read(tmp_path): """read_blurred runs for every booth the Desk renders; a FIFO with no writer used to hang it — the outage class `.seen` was built against.""" os.mkfifo(tmp_path / BLUR_FILE) assert _within(5, lambda: read_blurred(tmp_path)) == set() def test_a_symlinked_blur_file_is_not_followed_on_read(tmp_path): outside = tmp_path / "outside.json" outside.write_text('["a.png"]') b = tmp_path / "b" b.mkdir() (b / BLUR_FILE).symlink_to(outside) assert read_blurred(b) == set() def test_a_write_replaces_a_planted_symlink_rather_than_writing_through_it(tmp_path): outside = tmp_path / "outside.txt" outside.write_text("untouched") b = tmp_path / "b" b.mkdir() (b / BLUR_FILE).symlink_to(outside) set_blurred(b, "a.png", True) assert outside.read_text() == "untouched" assert not (b / BLUR_FILE).is_symlink() assert read_blurred(b) == {"a.png"} def test_malformed_json_array_contents_are_skipped_not_fatal(tmp_path): (tmp_path / BLUR_FILE).write_text('["a.png", 3, null, ["x"]]') assert read_blurred(tmp_path) == {"a.png"} # ---- the route: the operator's per-item control ----------------------------- def test_the_blur_route_blurs_exactly_the_item_it_names(tmp_path): """The route stripped `f` before writing, so the form for " a.png" blurred "a.png". Defeating change: `f.strip()` back in the route.""" b = _booth(tmp_path, "g", {" a.png": PNG, "a.png": PNG}) c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)) r = c.post("/b/g/blur", data={"f": " a.png", "on": "1"}, follow_redirects=False) assert r.status_code == 303 blurred = {it.rel: it.blurred for it in booth_items(b)} assert blurred == {" a.png": True, "a.png": False} # ---- the CLI: the other writer ---------------------------------------------- def _cli(data: pathlib.Path, *args: str) -> subprocess.CompletedProcess: env = {**os.environ, "BOOTH_DATA_DIR": str(data), "BOOTH_URL": "http://booth.invalid"} return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env, timeout=30) def test_the_cli_writes_the_format_the_service_reads(tmp_path): """Both writers, one format. Defeating change: the CLI keeping its own grep/printf line writer, which appends a line to a JSON array.""" b = _booth(tmp_path, "g", {" a.png": PNG, "a.png": PNG}) set_blurred(b, "a.png", True) # the service wrote first r = _cli(tmp_path, "blur", "g", " a.png") assert r.returncode == 0, r.stderr assert read_blurred(b) == {"a.png", " a.png"} r = _cli(tmp_path, "unblur", "g", " a.png") assert r.returncode == 0, r.stderr assert read_blurred(b) == {"a.png"} def test_the_cli_unblurring_the_last_item_removes_the_file(tmp_path): b = _booth(tmp_path, "g", {"a.png": PNG}) assert _cli(tmp_path, "blur", "g", "a.png").returncode == 0 assert _cli(tmp_path, "unblur", "g", "a.png").returncode == 0 assert not (b / BLUR_FILE).exists() def test_the_cli_still_refuses_a_dotdot_path(tmp_path): _booth(tmp_path, "g", {"a.png": PNG}) r = _cli(tmp_path, "blur", "g", "../escape.png") assert r.returncode == 2 assert not (tmp_path / "g" / BLUR_FILE).exists() # ---- one read of blur state per render (invariant 3) ------------------------ def test_the_item_record_carries_its_own_blur_apart_from_the_booths(tmp_path): """r2b's per-item control needs the item's OWN blur as well as the composed one. It came from a second `read_blurred` in build_gallery — a second reader of one file, which a write between the two could split. It is now resolved in `booth_items`, from the one read the composed fact already uses.""" b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG}) set_blurred(b, "a.png", True) (b / ".blurbooth").write_bytes(b"") got = {it.rel: (it.blurred, it.blurred_self) for it in booth_items(b)} assert got == {"a.png": (True, True), "b.png": (True, False)} def test_app_py_never_reads_the_blur_file_itself(): """Invariant 3, extended from route bodies to the whole module: blur state is read in `booth_items` and nowhere in app.py. Defeating change: the second `read_blurred` in build_gallery.""" import ast src = pathlib.Path(__file__).parent.parent / "booth" / "app.py" calls = [ n for n in ast.walk(ast.parse(src.read_text())) if isinstance(n, ast.Call) and getattr(n.func, "id", getattr(n.func, "attr", None)) == "read_blurred" ] assert calls == []