Files
esh-pfi-infrastructure/services/booth/tests/test_booth.py
T
vh 603d0ad555 feat(booth): image viewer page with Fit/1:1, download, Esc-back (v0.1.2)
Clicking a gallery image now opens a dedicated viewer instead of dumping you on
the raw file.

- GET /b/<name>/view?f=<img> — full-viewport viewer (registered before the file
  catch-all so /view wins; non-image f 307-redirects to the raw file, traversal
  and missing f 404).
- Fit (downscale-only) / 1:1 (natural pixels, scroll-to-pan) toggle that only
  appears when the image is larger than the viewport — when it already fits,
  Fit ≡ 1:1 so the toggle is hidden. Re-evaluates on resize.
- Download button + ✕/Esc back to the gallery. Australis-themed, progressive
  JS (degrades to fit-only, no-JS still shows the image + download + back).
- 5 new tests (34 total, all green); verified Fit/1:1/hidden-toggle states in a
  real browser.
2026-07-20 22:10:46 -07:00

329 lines
9.8 KiB
Python

import os
import re
import time
import pytest
from fastapi.testclient import TestClient
from booth.app import (
build_gallery,
classify,
create_app,
generate_pickup_id,
human_dur,
is_expired,
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
# ---- 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_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_image_view_nonimage_redirects_to_raw(client):
c, data = client
_touch(data / "run1" / "notes.txt")
r = c.get("/b/run1/view", params={"f": "notes.txt"}, follow_redirects=False)
assert r.status_code == 307
assert r.headers["location"] == "/b/run1/notes.txt"