"""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``. Rate limiting (phase 81, audit SEC-03): the sign-in failure counter is PROCESS state (in-memory per IP), and TestClient's ``request.client.host`` is the fixed value ``"testclient"`` — so every client in this module (including ``_admin_client``'s separate TestClient) shares ONE limiter entry. The ``clean_rate_limit`` autouse fixture resets that entry around every test so the deliberate 429 pins can't poison the other tests (and the table TRUNCATEs can't help: the counter is not row state). 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.api.auth import TOO_MANY_DETAIL from app.core import rate_limit from app.core.rate_limit import WINDOW_SECONDS 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_rate_limit() -> Iterator[None]: """Reset the in-memory sign-in-failure counter around every test. TestClient's ``request.client.host`` is the fixed value ``"testclient"``, so every client in this module — including the ``_admin_client`` helper's separate TestClient — shares ONE limiter entry, and the counter is process state the table TRUNCATEs cannot clear. Without this reset, the deliberate 429 tests below (which block the shared IP on purpose) would make every later test in the process hit the throttled 429 path instead of the 401/204 contract it pins. """ rate_limit.reset("testclient") yield rate_limit.reset("testclient") @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 # Phase 119 (A1): the citation surface is the agent's READ docs # only — the canned turn reads nothing ⇒ no chips (the cited # document's content pin below is the surface's other half). assert frames[-1]["sources"] == [] # 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) "created_at", # added in phase 106 (task 05) "content", "indexed_at", "chunks", "is_image", # added in phase 122 (task 04) — always present } assert body["summary"] is None assert body["is_image"] is False # text doc — and no image_url key (never null) assert "image_url" not in body # 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 # Phase 119 (A1): read docs only — nothing was read ⇒ no chips. assert frames[-1]["sources"] == [] # ---------- phase 81: rate-limited failed sign-ins (audit SEC-03) ---------- def test_eleventh_failed_login_429_with_retry_after(client: TestClient) -> None: """10 wrong passwords → 401 (the phase-16 contract, unchanged detail); the 11th attempt is the FIRST 429 — one generic detail + a ``Retry-After`` header carrying whole-window seconds.""" for _ in range(10): r = client.post("/api/login", json={"password": "nope"}) assert r.status_code == 401 assert r.json() == {"detail": "invalid password"} r = client.post("/api/login", json={"password": "nope"}) assert r.status_code == 429 assert r.json()["detail"] == TOO_MANY_DETAIL retry_after = int(r.headers["Retry-After"]) assert retry_after > 0 assert retry_after <= WINDOW_SECONDS # the whole-window wait, never more def test_correct_password_while_blocked_still_429(client: TestClient) -> None: """An exhausted window stays exhausted until it slides: the pre-check fires BEFORE the password is compared, so even a CORRECT password gets the 429 while blocked (success only resets a CLEAN counter — this is the intended semantics, pinned).""" for _ in range(10): assert client.post("/api/login", json={"password": "nope"}).status_code == 401 r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 429 assert r.json()["detail"] == TOO_MANY_DETAIL assert "bor_session" not in client.cookies # not signed in while throttled def test_success_on_clean_counter_resets_it(client: TestClient) -> None: """9 failures + 1 success (204) → the counter is back to 0: the next 9 failures are all plain 401s (a fresh 10 would be needed to block again). A fat-fingered owner is not poisoned by earlier misses.""" for _ in range(9): assert client.post("/api/login", json={"password": "nope"}).status_code == 401 assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204 for _ in range(9): r = client.post("/api/login", json={"password": "nope"}) assert r.status_code == 401 assert r.json() == {"detail": "invalid password"} def test_login_and_token_failures_share_the_counter(client: TestClient) -> None: """The counter is SHARED by both routes (a token-spraying attack must not dodge the window by alternating routes): 5 login failures + 5 token failures exhaust it, so the 11th attempt of EITHER shape — even with a VALID token — is a 429.""" # Admin + token FIRST: the successful admin login resets the shared # counter, so it must not happen after failures have accumulated. admin = _admin_client() _token_id, token = _create_token(admin) for _ in range(5): assert client.post("/api/login", json={"password": "nope"}).status_code == 401 for _ in range(5): r = client.post("/api/token-auth", json={"token": "bor_" + "0" * 32}) assert r.status_code == 401 assert r.json() == {"detail": "invalid token"} r = client.post("/api/token-auth", json={"token": token}) assert r.status_code == 429 assert r.json()["detail"] == TOO_MANY_DETAIL assert int(r.headers["Retry-After"]) > 0