"""Integration: the auth surface (phase 16 admin; phase 79 token users). Covers the full admin lifecycle against the real app (TestClient keeps the cookie jar): wrong password → 401 + still-gated; correct → 204 + cookie → admin everywhere gated; logout → 403 again. And the phase-79 token flows: ``POST /api/token-auth`` (valid → 204 + the ``user`` role; invalid / malformed / revoked / empty → ONE generic 401), the three-role ``whoami``, logout clearing the token session, and the LIVE revocation check (a revoked token is refused on the next request and the dead session's keys are dropped — whoami falls to anonymous). The phase-16 anonymous pins are SUPERSEDED by the phase-79 contract: the ONLY anonymous content is the shared chats (plus the login/infra endpoints the gate itself needs) — anonymous chat / suggestions / document content now 401 ``authentication required``. Requires: podman compose up -d db """ from __future__ import annotations import asyncio from collections.abc import Iterator from pathlib import Path import pytest from fastapi.testclient import TestClient from sqlalchemy import text from test_chat_api import FakeRagLLM, _stream_chat from app.api import chat as chat_api from app.main import app as fastapi_app from app.models import Document from app.rag.importer import import_sources from tests.conftest import ADMIN_PASSWORD FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs" QUESTION = "How is my Kubernetes cluster set up?" @pytest.fixture(autouse=True) def clean_tables(db) -> Iterator[None]: """Docs + steering + query log + tokens are global state: reset around tests.""" db.execute( text("TRUNCATE chunks, documents, query_log, steering_notes, api_tokens") ) db.commit() yield db.execute( text("TRUNCATE chunks, documents, query_log, steering_notes, api_tokens") ) db.commit() @pytest.fixture() def seeded_kb(db) -> Iterator[FakeRagLLM]: """Fresh Postgres with the fixture docs imported (real pipeline).""" db.execute(text("TRUNCATE chunks, documents, query_log")) db.commit() llm = FakeRagLLM() summary = asyncio.run(import_sources([FIXTURES], llm, session=db)) assert summary.added == 13 # A9 formats (phase 47 added quadlet+j2); .hidden/ skipped yield llm db.execute(text("TRUNCATE chunks, documents, query_log")) db.commit() def _seed_one_doc(db) -> Document: """One minimal document (for the user-gated document-content endpoint).""" doc = Document( source="docs", path="homelab/kubernetes.md", full_path="/tmp/kubernetes.md", title="Kubernetes Homelab Cluster", content="# Kubernetes Homelab Cluster\n\nTalos on 3 nodes.", content_hash="c" * 64, ) db.add(doc) db.commit() db.refresh(doc) return doc def _admin_client() -> TestClient: """A SEPARATE client signed in as the admin — for tests that need an admin and a token user at the same time (the shared ``client`` fixture is the token holder in those).""" admin = TestClient(fastapi_app) r = admin.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}" return admin def _create_token(admin: TestClient, label: str = "alice") -> tuple[str, str]: """Admin issues one token; returns ``(token_id, plaintext)`` — the plaintext is the 201 body's one-and-only wire moment (task 02).""" r = admin.post("/api/tokens", json={"label": label}) assert r.status_code == 201, r.text body = r.json() return body["id"], body["token"] # ---------- phase 16: the admin lifecycle (unchanged contract) ---------- def test_wrong_password_401_and_still_gated(client: TestClient) -> None: r = client.post("/api/login", json={"password": "not-the-password"}) assert r.status_code == 401 assert r.json() == {"detail": "invalid password"} # No session state was created by the failed attempt. assert "bor_session" not in client.cookies r = client.post("/api/login", json={"password": ""}) # empty → same 401 assert r.status_code == 401 assert r.json() == {"detail": "invalid password"} # The gated surface stays closed (anonymous). assert client.get("/api/docs").status_code == 403 r = client.get("/api/steering") assert r.status_code == 403 assert r.json() == {"detail": "admin only"} assert client.post("/api/steering", json={"note": "x"}).status_code == 403 assert client.get("/api/whoami").json() == { "authenticated": False, "role": "anonymous", } def test_login_logout_lifecycle(client: TestClient) -> None: # Anonymous shape before anything (phase 79: the anonymous whoami # gains the explicit role — the UI keys its admin surfaces off it). who = client.get("/api/whoami").json() assert who == {"authenticated": False, "role": "anonymous"} # Wrong first, right second — one generic 401, then success. assert client.post("/api/login", json={"password": "nope"}).status_code == 401 r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204 assert "bor_session" in client.cookies # the signed session cookie # Admin: whoami + the gated endpoints all open up. assert client.get("/api/whoami").json() == { "authenticated": True, "role": "admin", } assert client.get("/api/docs").status_code == 200 created = client.post("/api/steering", json={"note": " be terse "}) assert created.status_code == 201 note = created.json() assert note["note"] == "be terse" listing = client.get("/api/steering") assert listing.status_code == 200 assert [n["note"] for n in listing.json()["notes"]] == ["be terse"] assert client.delete(f"/api/steering/{note['id']}").status_code == 204 assert client.get("/api/steering").json() == {"notes": []} # Logout: 204, cookie gone, gated again. assert client.post("/api/logout").status_code == 204 assert "bor_session" not in client.cookies assert client.get("/api/whoami").json() == { "authenticated": False, "role": "anonymous", } assert client.get("/api/docs").status_code == 403 r = client.get("/api/steering") assert r.status_code == 403 assert r.json() == {"detail": "admin only"} # Logout is idempotent (anonymous logout is still a clean 204). assert client.post("/api/logout").status_code == 204 assert client.get("/api/whoami").json()["authenticated"] is False def test_forged_cookie_is_rejected(client: TestClient) -> None: client.cookies.set("bor_session", "tampered-session-blob") assert client.get("/api/whoami").json() == { "authenticated": False, "role": "anonymous", } assert client.get("/api/docs").status_code == 403 # ---------- phase 79: the token login + the user role ---------- def test_token_auth_valid_signs_in_token_user(client: TestClient) -> None: """A valid token → 204 + session cookie → whoami reports the THIRD role: ``{authenticated: true, role: "user"}``.""" admin = _admin_client() _token_id, token = _create_token(admin) r = client.post("/api/token-auth", json={"token": token}) assert r.status_code == 204 assert "bor_session" in client.cookies # the signed session cookie assert client.get("/api/whoami").json() == { "authenticated": True, "role": "user", } def test_token_auth_all_failures_share_one_generic_401(client: TestClient) -> None: """Malformed / unknown / revoked / empty are INDISTINGUISHABLE — one 401 ``{"detail": "invalid token"}`` for every failure shape (the phase-16 no-enumeration pattern; a 422 would hint at input-shape differences on a credential endpoint).""" admin = _admin_client() _token_id, token = _create_token(admin) failures = [ token + "0", # unknown (no row for this hash) "bor_" + "0" * 32, # well-shaped, never issued "short", # malformed: too short "wrongprefix_" + "0" * 32, # malformed: wrong prefix "", # empty " ", # whitespace only ] for bad in failures: r = client.post("/api/token-auth", json={"token": bad}) assert r.status_code == 401, bad assert r.json() == {"detail": "invalid token"}, bad assert "bor_session" not in client.cookies # no session on failure # A failed attempt must not sign anyone in. assert client.get("/api/whoami").json() == { "authenticated": False, "role": "anonymous", } # Revoked → the SAME 401 (not a different "revoked" message). _bob_id, bob_token = _create_token(admin, "bob") bob_id = next( t["id"] for t in admin.get("/api/tokens").json()["tokens"] if t["label"] == "bob" ) assert admin.post(f"/api/tokens/{bob_id}/revoke").status_code == 204 r = client.post("/api/token-auth", json={"token": bob_token}) assert r.status_code == 401 assert r.json() == {"detail": "invalid token"} def test_token_user_surface_matrix(client: TestClient, db, seeded_kb: FakeRagLLM) -> None: """The owner's sentence (phase 79): a token user gets the app surface — chat streams, suggestion chips, cited document content — and NO admin surface (tokens, docs list, saved chats, steering, git sources stay 403 for them; saved chats have no per-user attribution).""" admin = _admin_client() _token_id, token = _create_token(admin) assert client.post("/api/token-auth", json={"token": token}).status_code == 204 # Chat streams (the mocked LLM — the real turn pipeline). fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb try: status, content_type, frames = _stream_chat(client, QUESTION) finally: fastapi_app.dependency_overrides.clear() assert status == 200 assert content_type.startswith("text/event-stream") assert frames[-1]["type"] == "done" assert frames[-1]["deflected"] is False assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md" # Suggestion chips + the cited document's content (the viewer). assert client.get("/api/suggestions").status_code == 200 r = client.get( "/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"} ) assert r.status_code == 200 assert r.json()["title"] == "Kubernetes Homelab Cluster" # The admin surface stays admin-only for a token user (A3 scope). for path in ("/api/tokens", "/api/docs", "/api/chats", "/api/steering", "/api/git-sources"): r = client.get(path) assert r.status_code == 403, path assert r.json() == {"detail": "admin only"}, path def test_token_user_cannot_create_or_list_tokens(client: TestClient) -> None: """The token ADMIN surface is closed to token holders on every method, not just GET (A3: only the admin manages tokens).""" admin = _admin_client() _token_id, token = _create_token(admin) assert client.post("/api/token-auth", json={"token": token}).status_code == 204 assert client.post("/api/tokens", json={"label": "evil"}).status_code == 403 assert client.get("/api/tokens").status_code == 403 assert client.post("/api/tokens/00000000-0000-0000-0000-000000000000/revoke").status_code == 403 # The admin's own token list is untouched. assert [t["label"] for t in admin.get("/api/tokens").json()["tokens"]] == ["alice"] def test_logout_clears_token_session(client: TestClient) -> None: """One logout, one session dict: a token holder's logout drops the ``user`` role — whoami anonymous and the gated surface 401s again.""" admin = _admin_client() _token_id, token = _create_token(admin) assert client.post("/api/token-auth", json={"token": token}).status_code == 204 assert client.get("/api/whoami").json()["role"] == "user" assert client.post("/api/logout").status_code == 204 assert "bor_session" not in client.cookies assert client.get("/api/whoami").json() == { "authenticated": False, "role": "anonymous", } assert client.get("/api/suggestions").status_code == 401 assert client.post("/api/chat", json={"message": "hi"}).status_code == 401 def test_revocation_mid_session_enforced_live( client: TestClient, db, seeded_kb: FakeRagLLM ) -> None: """The live check (A4): a token revoked MID-SESSION is refused on the holder's next request — chat 401 — and the dead session's keys are dropped right there (whoami falls to anonymous without a logout).""" admin = _admin_client() token_id, token = _create_token(admin) assert client.post("/api/token-auth", json={"token": token}).status_code == 204 assert client.get("/api/whoami").json()["role"] == "user" # The turn BEFORE the revocation still streams. fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb try: status, _ct, frames = _stream_chat(client, QUESTION) finally: fastapi_app.dependency_overrides.clear() assert status == 200 assert frames[-1]["type"] == "done" # The admin revokes (idempotent 204 from task 02). assert admin.post(f"/api/tokens/{token_id}/revoke").status_code == 204 # The NEXT request is refused — 401, not a stream — and the session # is dead (the live check popped both user keys). r = client.post("/api/chat", json={"message": "hello"}) assert r.status_code == 401 assert r.json() == {"detail": "authentication required"} assert client.get("/api/whoami").json() == { "authenticated": False, "role": "anonymous", } # …and a FRESH login attempt with the revoked token fails too (the # token itself is dead, not just this session). r = client.post("/api/token-auth", json={"token": token}) assert r.status_code == 401 assert r.json() == {"detail": "invalid token"} # ---------- phase 79: the enforcement matrix (the 401 contract) ---------- def test_anonymous_chat_now_401s(client: TestClient, db) -> None: """The phase-16 "chat still streams anonymously" pin is SUPERSEDED: anonymous chat gets a plain 401 ``authentication required`` (401, not 403 — no higher privilege would unblock them).""" r = client.post("/api/chat", json={"message": "hello"}) assert r.status_code == 401 assert r.json() == {"detail": "authentication required"} def test_anonymous_suggestions_and_document_content_401(client: TestClient, db) -> None: """The other two gated endpoints share the same 401 contract.""" r = client.get("/api/suggestions") assert r.status_code == 401 assert r.json() == {"detail": "authentication required"} _seed_one_doc(db) r = client.get( "/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"} ) assert r.status_code == 401 assert r.json() == {"detail": "authentication required"} def test_document_content_admin_contract(client: TestClient, db) -> None: """The phase-16 soft rule (viewer public) is superseded: the viewer content is token-or-admin — the admin gets the full DocContent shape, unknown pairs still 404 (no enumeration of titles).""" client.post("/api/login", json={"password": ADMIN_PASSWORD}) _seed_one_doc(db) r = client.get( "/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"} ) assert r.status_code == 200 body = r.json() assert body["title"] == "Kubernetes Homelab Cluster" assert "Talos" in body["content"] assert set(body) == { "source", "path", "title", "format", "summary", # nullable field added in phase 36 (null here — markdown) "content", "indexed_at", "chunks", } assert body["summary"] is None # Unknown docs still 404 (same shape as the phase-16 pin). r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"}) assert r.status_code == 404 assert r.json() == {"detail": "document not found"} def test_admin_chat_still_streams(client: TestClient, db, seeded_kb: FakeRagLLM) -> None: """The streaming assertions of the old anonymous pin survive under a signed-in (admin) client — sign-in locked out strangers, not the owner.""" assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204 fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb try: status, content_type, frames = _stream_chat(client, QUESTION) finally: fastapi_app.dependency_overrides.clear() assert status == 200 assert content_type.startswith("text/event-stream") deltas = [f for f in frames if f.get("type") == "delta"] assert len(deltas) >= 2 # genuinely streamed assert frames[-1]["type"] == "done" assert frames[-1]["deflected"] is False assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md"