Ctrl K
ComunidadGuías…

tests/test_smoke.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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())