"""Integration: the auth surface (phase 16) + the public-API regression guards. Covers the full login/logout 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 **anonymous** guarantees that phase 16 must not break: the document viewer stays public (soft rule) and ``POST /api/chat`` still streams. 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 are global state: reset around tests.""" db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes")) db.commit() yield db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes")) 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 == 8 # A9 formats; .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 public 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 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. 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 def test_anonymous_document_content_stays_public(client: TestClient, db) -> None: """Soft rule (phase 16): the catalog is gated, the viewer is not.""" _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 anonymously (no enumeration of titles). r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"}) assert r.status_code == 404 def test_anonymous_chat_still_streams( client: TestClient, seeded_kb: FakeRagLLM ) -> None: """Regression guard: sign-in must not have locked chat (A10 public).""" 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"