Files
brain-of-reese/tests/integration/test_auth_api.py
T

193 lines
6.9 KiB
Python

"""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"