tests/test_smoke.py
Rama master · réplica de lectura del repositorio original.
"""Page smoke tests — every HTML route loads (or redirects) per its auth tier.
Catches the failures that take a page from 200 to 500: template syntax errors,
missing data files, broken route wiring, import-time crashes. Does NOT exercise
LLM calls or mutate data — pure GETs plus the two login endpoints.
Run (server is up locally):
SMOKE_BASE_URL=http://localhost:8080 pytest tests/ -q
"""
import pytest
from conftest import API_KEY, TURNSTILE_SECRET, HTML_ACCEPT
# Public — no auth, must render. Only the front doors + login bootstrap stay open.
PUBLIC_PAGES = ["/", "/recruiter", "/login", "/guest"]
# require_auth — no auth redirects to /login; admin Bearer renders.
# /hosaka moved guest->owner 2026-11-10 (its health probe + sockets stay guest:
# /tarot narrates through them).
PROTECTED_PAGES = ["/rd", "/hq", "/debug", "/hosaka"]
# require_guest_auth — no auth redirects to /guest; guest or admin Bearer renders.
# /graph, /UI, /security, /nightfall moved public->guest 2026-07-03; /printer
# owner->guest 2026-08-30 (READ-ONLY for guests — see the tier tests below).
# /zombo is UNLINKED (no nav entry, no landing spoke) but still gated: unlisted
# is not a tier, so it is asserted here like every other guest page.
GUEST_PAGES = ["/mtg", "/tarot", "/graph", "/UI", "/security", "/nightfall",
"/printer", "/zombo", "/noodle", "/noodle-tc", "/spire"]
def _is_page(r) -> bool:
return r.status_code == 200 and len(r.text) > 100 and "<" in r.text
# ── public pages ──────────────────────────────────────────────────────────────
@pytest.mark.parametrize("path", PUBLIC_PAGES)
def test_public_page_loads(client, path):
r = client.get(path, headers=HTML_ACCEPT)
assert r.status_code == 200, f"{path} -> {r.status_code}"
assert _is_page(r), f"{path} returned 200 but body looks empty/broken"
def test_guest_login_alias_redirects(client):
r = client.get("/guest-login", headers=HTML_ACCEPT)
assert r.status_code == 302
assert r.headers["location"].startswith("/guest")
# ── protected pages (require_auth) ──────────────────────────────────────────────
@pytest.mark.parametrize("path", PROTECTED_PAGES)
def test_protected_redirects_without_auth(client, path):
r = client.get(path, headers=HTML_ACCEPT)
assert r.status_code == 302, f"{path} -> {r.status_code} (expected login redirect)"
assert r.headers["location"].startswith("/login")
@pytest.mark.parametrize("path", PROTECTED_PAGES)
def test_protected_refuses_guest_cookie(client, guest_cookie, path):
r = client.get(path, headers={**guest_cookie, **HTML_ACCEPT})
assert r.status_code == 302, f"{path} -> {r.status_code} with guest cookie"
assert r.headers["location"].startswith("/login")
@pytest.mark.parametrize("path", PROTECTED_PAGES)
def test_protected_loads_with_admin(client, admin_headers, path):
r = client.get(path, headers=admin_headers)
assert r.status_code == 200, f"{path} -> {r.status_code} with admin Bearer"
assert _is_page(r), f"{path} returned 200 but body looks empty/broken"
# ── guest pages (require_guest_auth) ────────────────────────────────────────────
@pytest.mark.parametrize("path", GUEST_PAGES)
def test_guest_redirects_without_auth(client, path):
r = client.get(path, headers=HTML_ACCEPT)
assert r.status_code == 302, f"{path} -> {r.status_code} (expected guest redirect)"
assert r.headers["location"].startswith("/guest")
@pytest.mark.parametrize("path", GUEST_PAGES)
def test_guest_loads_with_guest_cookie(client, guest_cookie, path):
r = client.get(path, headers=guest_cookie)
assert r.status_code == 200, f"{path} -> {r.status_code} with guest cookie"
assert _is_page(r)
@pytest.mark.parametrize("path", GUEST_PAGES)
def test_guest_loads_with_admin_bearer(client, admin_headers, path):
# Admin auth also satisfies the guest gate.
r = client.get(path, headers=admin_headers)
assert r.status_code == 200, f"{path} -> {r.status_code} with admin Bearer"
# ── auth endpoints ──────────────────────────────────────────────────────────────
def test_login_post_good_key_sets_cookie(client):
if not API_KEY:
pytest.skip("API_KEY not available")
r = client.post("/login", data={"key": API_KEY})
assert r.status_code == 303
assert "session=" in r.headers.get("set-cookie", "")
def test_login_post_bad_key_rejected(client):
r = client.post("/login", data={"key": "wrong-key"})
assert r.status_code == 401
def test_guest_post_valid_turnstile_sets_cookie(client):
# Only the Cloudflare test secret (1x0000…) attests an arbitrary token; with a
# real secret a passing token can't be minted offline, so skip there.
if not (TURNSTILE_SECRET or "").startswith("1x0000000000000000000000000000000"):
pytest.skip("not using the Cloudflare test secret; cannot mint a passing token")
r = client.post("/guest", data={"cf-turnstile-response": "dummy"})
assert r.status_code == 303
assert "guest_session=" in r.headers.get("set-cookie", "")
def test_guest_post_no_token_rejected(client):
# Empty/missing Turnstile token is a fast 401 (no siteverify call) under any secret.
r = client.post("/guest", data={"cf-turnstile-response": ""})
assert r.status_code == 401
# ── read-only JSON API (a couple, as a wiring check) ────────────────────────────
def test_public_json_api_loads(client):
r = client.get("/api/ui/usage")
assert r.status_code == 200
assert r.headers["content-type"].startswith("application/json")
def test_protected_json_api_requires_auth(client):
# JSON Accept -> 401 (no html redirect).
r = client.get("/api/debug/logs", headers={"Accept": "application/json"})
assert r.status_code == 401
def test_protected_json_api_loads_with_admin(client, admin_headers):
r = client.get("/api/debug/logs", headers={**admin_headers, "Accept": "application/json"})
assert r.status_code == 200
# ── gpu-mode owner-only routes ──────────────────────────────────────────────────
def test_hosaka_mode_route_authed(client, admin_headers):
r = client.get("/api/hosaka/mode", headers=admin_headers)
assert r.status_code == 200
assert r.json()["mode"] in {"homo", "emo", "idle", "gone"}
def test_hosaka_mode_route_requires_auth(client):
# no cookie / no bearer -> 401 from require_auth
r = client.get("/api/hosaka/mode")
assert r.status_code == 401
# ── printer owner-only routes ───────────────────────────────────────────────────
def test_printer_health_route_authed(client, admin_headers):
# 200 {ok:true} when the printer answers through the tunnel, 503 {ok:false}
# when it (or the home box) is off -- both are the route working.
r = client.get("/api/printer/health", headers={**admin_headers, "Accept": "application/json"})
assert r.status_code in (200, 503)
assert r.json()["ok"] is (r.status_code == 200)
def test_printer_health_route_requires_auth(client):
r = client.get("/api/printer/health")
assert r.status_code == 401
def test_printer_proxy_requires_auth(client):
# The proxied SPA + its websocket-adjacent routes are full-session only.
r = client.get("/printer/network-device-manager/network/control", headers=HTML_ACCEPT)
assert r.status_code == 302
assert r.headers["location"].startswith("/login")
r = client.get("/printer/video")
assert r.status_code == 401
# ── printer tier split: guests view, only the owner controls ────────────────────
def test_printer_control_routes_deny_guest(client, guest_cookie):
"""The whole point of the public page: nothing a guest sends reaches the
LAN. The SPA proxy (the only browser->printer HTTP path) must not serve a
guest -- it bounces to the ADMIN login, not the guest gate."""
for path in ("/printer/network-device-manager/network/control",
"/printer/index.html",
"/printer/uploadFile/upload"):
r = client.get(path, headers=guest_cookie)
assert r.status_code == 302, f"{path} -> {r.status_code} for a guest"
assert r.headers["location"].startswith("/login"), path
def test_printer_page_is_readonly_for_guest(client, guest_cookie, admin_cookie):
# The server decides the tier; printer.js reads data-readonly and never
# mounts the SPA frame for a guest.
assert 'data-readonly="1"' in client.get("/printer", headers=guest_cookie).text
assert 'data-readonly="1"' not in client.get("/printer", headers=admin_cookie).text
def test_printer_status_is_guest_readable_and_scrubbed(client, guest_cookie):
"""Status is pushed by the printer (no command sent) and whitelisted: no
identifiers, nothing naming what is being printed."""
r = client.get("/api/printer/status", headers={**guest_cookie, "Accept": "application/json"})
assert r.status_code == 200
body = r.json()
assert "online" in body
for leaked in ("Filename", "TaskId", "MainboardID", "filename"):
assert leaked not in r.text, f"{leaked} leaked into the public status payload"
if body["online"]:
assert {"state", "nozzle", "bed", "progress"} <= set(body)
def test_printer_video_streams_for_guest(client, guest_cookie):
"""Guests get the camera -- a one-way read the server opens, fanned out
from a single shared upstream stream."""
with client.stream("GET", "/printer/video", headers=guest_cookie) as r:
if r.status_code == 503:
pytest.skip("printer offline / too many viewers")
assert r.status_code == 200
assert "multipart/x-mixed-replace" in r.headers["content-type"]
for chunk in r.iter_bytes():
assert chunk.startswith(b"--printerframe")
break
# ── noodle: public by unguessable link, owner-only to create ───────────────────
def test_noodle_poll_pages_are_public(client, noodle_slug):
r = client.get(f"/noodle/{noodle_slug}", headers=HTML_ACCEPT)
assert r.status_code == 200 and _is_page(r)
assert "noindex" in r.headers.get("x-robots-tag", "")
assert 'data-kdf=' in r.text and 'id="nd-voters"' in r.text
# the feedback mailto sits under the make-another link
assert r.text.index("make your own noodle") < r.text.index('href="mailto:wl.wailau@gmail.com?subject=noodle')
# results moved onto the vote page; old links redirect there
r = client.get(f"/noodle/{noodle_slug}/results", headers=HTML_ACCEPT)
assert r.status_code == 301 and r.headers["location"] == f"/noodle/{noodle_slug}"
def test_noodle_poll_page_has_a_link_preview(client, noodle_slug):
"""Open Graph tags (Messenger etc.): absolute urls, the fixed dark card."""
r = client.get(f"/noodle/{noodle_slug}", headers=HTML_ACCEPT)
assert '<meta property="og:title" content="__smoke__">' in r.text
assert f'<meta property="og:url" content="https://wai-lau.net/noodle/{noodle_slug}">' in r.text
assert 'content="https://wai-lau.net/noodle-card.jpg?v=' in r.text
assert 'name="twitter:card" content="summary_large_image"' in r.text
assert '<meta name="theme-color" content="#000">' in r.text
img = client.get("/noodle-card.jpg")
assert img.status_code == 200 and img.headers["content-type"] == "image/jpeg"
def test_noodle_page_has_no_demo_video(client, guest_cookie, admin_cookie):
"""/noodle is the title and the create button, nothing else: the demo video
was dropped 2026-10-03 (owner feedback)."""
for h in (guest_cookie, admin_cookie):
r = client.get("/noodle", headers=h)
assert r.status_code == 200 and "<video" not in r.text
def test_noodle_poll_json_is_public_and_holds_no_budget(client, noodle_slug):
r = client.get(f"/api/noodle/{noodle_slug}")
assert r.status_code == 200
body = r.json()
assert {"slug", "voters", "host", "halves"} <= set(body) and "start" not in body
assert "ask" not in body and "ask_remaining" not in body
@pytest.mark.parametrize("slug", ["A" * 22, "short", "..%2F..%2Fetc%2Fpasswd", "A" * 23])
def test_noodle_unknown_or_malformed_slug_404s(client, slug):
assert client.get(f"/noodle/{slug}", headers=HTML_ACCEPT).status_code == 404
assert client.get(f"/api/noodle/{slug}").status_code == 404
def test_noodle_create_is_owner_only(client, guest_cookie):
body = {"title": "x", "start": "2026-10-01", "end": "2026-10-02"}
assert client.post("/api/noodle-polls", json=body).status_code == 401
r = client.post("/api/noodle-polls", json=body, headers={**guest_cookie, "Accept": "application/json"})
assert r.status_code == 401
assert client.get("/api/noodle-polls").status_code == 401
# deleting is the owner's alone too, and a guest must not even learn if a slug exists
assert client.delete(f"/api/noodle-polls/{'A' * 22}").status_code == 401
r = client.delete(f"/api/noodle-polls/{'A' * 22}", headers={**guest_cookie, "Accept": "application/json"})
assert r.status_code == 401
def test_noodle_rejects_bad_votes_without_writing(client, noodle_slug):
before = client.get(f"/api/noodle/{noodle_slug}").json()["voters"]
r = client.post(f"/api/noodle/{noodle_slug}/vote",
json={"name": "smoke", "pub": "A" * 43, "slots": [], "ts": 1, "sig": "x"})
assert r.status_code == 400 # ts far from server time: refused before any key check
r = client.post(f"/api/noodle/{noodle_slug}/vote", content=b"x" * 9000,
headers={"Content-Type": "application/json"})
assert r.status_code == 413
assert client.get(f"/api/noodle/{noodle_slug}").json()["voters"] == before
def test_noodle_ask_refuses_before_spending(client, noodle_slug):
r = client.post(f"/api/noodle/{noodle_slug}/ask", json={"text": "fridays", "pub": "nope"})
assert r.status_code == 400
r = client.post(f"/api/noodle/{noodle_slug}/ask", content=b"{" + b" " * 2000 + b"}",
headers={"Content-Type": "application/json"})
assert r.status_code == 413
@pytest.mark.parametrize("path", ["settings", "remove", "rekey"])
def test_noodle_signed_actions_refuse_junk_without_writing(client, noodle_slug, path):
before = client.get(f"/api/noodle/{noodle_slug}").json()["voters"]
body = {"name": "smoke", "pub": "A" * 43, "ts": 1, "sig": "x", "halves": False,
"target": "x", "newpub": "A" * 43}
assert client.post(f"/api/noodle/{noodle_slug}/{path}", json=body).status_code == 400
r = client.post(f"/api/noodle/{noodle_slug}/{path}", content=b"x" * 9000,
headers={"Content-Type": "application/json"})
assert r.status_code == 413
assert client.get(f"/api/noodle/{noodle_slug}").json()["voters"] == before
def test_data_never_serves_noodle_even_to_the_owner(client, admin_headers):
"""/data/{path} once handed the owner tier noodle's secret.key (which mints
draft tokens) -- one XSS on an owner page away from anyone (audit 2026-10-14)."""
h = {**admin_headers, "Accept": "application/json"}
for path in ("noodle/secret.key", "noodle/created.json", "noodle/polls", "./noodle/secret.key"):
assert client.get(f"/data/{path}", headers=h).status_code == 404, path
assert client.get("/data/rd.json", headers=h).status_code == 200, "everything else still serves"
def test_every_response_carries_the_security_headers(client):
"""HSTS, no framing by other sites, nosniff -- on pages and static alike
(api/security_headers.py, security audit 2026-10-14)."""
for path in ("/", "/chrome.css?v=84", "/login"):
h = client.get(path).headers
assert h["strict-transport-security"].startswith("max-age="), path
assert h["x-frame-options"] == "SAMEORIGIN" and h["x-content-type-options"] == "nosniff", path
def test_noodle_pages_keep_their_csp_and_no_store(client, guest_cookie, noodle_slug):
vote = client.get(f"/noodle/{noodle_slug}", headers=HTML_ACCEPT).headers
assert "script-src 'self'" in vote["content-security-policy"]
assert "frame-ancestors 'none'" in vote["content-security-policy"]
assert vote["cache-control"] == "no-store", "the site middleware must not turn it into no-cache"
# both start pages: the vote page's strict policy since 2026-11-10 (no
# site nav, so no inline script)
for start in ("/noodle", "/noodle-tc"):
page = client.get(start, headers={**guest_cookie, **HTML_ACCEPT}).headers
assert page["content-security-policy"] == vote["content-security-policy"], start
assert page["cache-control"] == "no-store", start
def test_noodle_drafts_need_their_token(client, guest_cookie):
slug = "B" * 22
assert client.get(f"/noodle/{slug}", headers=HTML_ACCEPT).status_code == 404
assert client.get(f"/noodle/{slug}?t=nope", headers=HTML_ACCEPT).status_code == 404
# starting a draft is behind Turnstile (the guest tier), stores nothing,
# and gives a working page -- which, by its token, needs no gate itself
assert client.post("/api/noodle-polls/new").status_code == 401
# a draft binds the creator's claim key (noodle/drafts.py): none, no draft
assert client.post("/api/noodle-polls/new", headers=guest_cookie).status_code == 400
claim = "A" * 43 # 32 bytes in url-safe base64: a public key's shape
# the suite's rate-limit exemption (conftest RL_EXEMPT) keeps NEW_RATE from
# failing this after a busy hour of commits; a 429 here means it broke
r = client.post("/api/noodle-polls/new", headers=guest_cookie, json={"claim": claim})
assert r.status_code == 200, f"draft start failed: {r.status_code} {r.text[:120]}"
d = r.json()
assert f"?t={claim}." in d["url"]
assert client.get(d["url"], headers=HTML_ACCEPT).status_code == 200
assert client.get(f"/api/noodle/{d['slug']}").status_code == 404, "a draft stores nothing"
def test_noodle_page_is_guest_tier_but_its_poll_list_is_owner_only(client, guest_cookie):
r = client.get("/noodle", headers={**guest_cookie, **HTML_ACCEPT})
assert r.status_code == 200 and "create poll" in r.text
for headers in ({}, {**guest_cookie, "Accept": "application/json"}):
assert client.get("/api/noodle-polls", headers=headers).status_code == 401
assert client.post("/api/noodle-polls", json={"title": "x"}, headers=headers).status_code == 401
assert client.delete(f"/api/noodle-polls/{'A' * 22}", headers=headers).status_code == 401
assert client.get("/api/noodle-polls/created", headers=headers).status_code == 401
def test_noodle_created_counts_are_the_owners(client, admin_headers):
r = client.get("/api/noodle-polls/created", headers={**admin_headers, "Accept": "application/json"})
assert r.status_code == 200
days = r.json()["days"]
assert all(len(d) == 10 and isinstance(n, int) and n > 0 for d, n in days.items())