feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public
This commit is contained in:
@@ -56,6 +56,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
|
||||
("/", "Brain of Reese"),
|
||||
("/sources.html", "Knowledge base"),
|
||||
("/document.html", "Brain of Reese"), # phase 10: viewer page
|
||||
("/login.html", "Sign in"), # phase 16: admin sign-in page
|
||||
],
|
||||
)
|
||||
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
|
||||
@@ -75,6 +76,7 @@ def test_styles_and_js_served(client) -> None:
|
||||
assert client.get("/assets/sources.js").status_code == 200
|
||||
assert client.get("/assets/markdown.js").status_code == 200 # phase 10: shared renderer
|
||||
assert client.get("/assets/document.js").status_code == 200 # phase 10: viewer page
|
||||
assert client.get("/assets/login.js").status_code == 200 # phase 16: login page
|
||||
|
||||
|
||||
# Emoji code points banned from UI chrome (phase 08): the pictograph
|
||||
@@ -104,10 +106,12 @@ def _find_emoji(text: str) -> list[str]:
|
||||
"/",
|
||||
"/sources.html",
|
||||
"/document.html",
|
||||
"/login.html", # phase 16
|
||||
"/assets/app.js",
|
||||
"/assets/sources.js",
|
||||
"/assets/markdown.js",
|
||||
"/assets/document.js",
|
||||
"/assets/login.js", # phase 16
|
||||
"/assets/styles.css",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""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",
|
||||
"content",
|
||||
"indexed_at",
|
||||
"chunks",
|
||||
}
|
||||
|
||||
# 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"
|
||||
@@ -12,15 +12,15 @@ from sqlalchemy import text
|
||||
from app.models import Chunk, Document
|
||||
|
||||
|
||||
def test_docs_empty_shape(client, db) -> None:
|
||||
def test_docs_empty_shape(admin_client, db) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
r = client.get("/api/docs")
|
||||
r = admin_client.get("/api/docs")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"documents": []}
|
||||
|
||||
|
||||
def test_docs_populated_shape_sorted_with_chunk_counts(client, db) -> None:
|
||||
def test_docs_populated_shape_sorted_with_chunk_counts(admin_client, db) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
now = datetime.now(UTC)
|
||||
@@ -50,7 +50,7 @@ def test_docs_populated_shape_sorted_with_chunk_counts(client, db) -> None:
|
||||
)
|
||||
db.commit()
|
||||
|
||||
r = client.get("/api/docs")
|
||||
r = admin_client.get("/api/docs")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Ordered by (source, path): Deployments < Homelab.
|
||||
@@ -69,8 +69,8 @@ def test_docs_populated_shape_sorted_with_chunk_counts(client, db) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_docs_response_matches_schema_shape(client, db) -> None:
|
||||
r = client.get("/api/docs")
|
||||
def test_docs_response_matches_schema_shape(admin_client, db) -> None:
|
||||
r = admin_client.get("/api/docs")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"documents"}
|
||||
|
||||
@@ -30,7 +30,7 @@ EXPECTED_DOCS = {
|
||||
}
|
||||
|
||||
|
||||
def test_import_fixtures_end_to_end(client, db) -> None:
|
||||
def test_import_fixtures_end_to_end(admin_client, db) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
llm = FakeEmbedder()
|
||||
@@ -67,7 +67,7 @@ def test_import_fixtures_end_to_end(client, db) -> None:
|
||||
assert c.embedding is not None and len(c.embedding) == 768
|
||||
|
||||
# The Sources page consumes exactly this shape.
|
||||
r = client.get("/api/docs")
|
||||
r = admin_client.get("/api/docs") # phase 16: the catalog is admin-only
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["documents"]) == 8
|
||||
|
||||
@@ -63,8 +63,8 @@ def _turn_log_lines(caplog: pytest.LogCaptureFixture) -> list[str]:
|
||||
# ---------- CRUD ----------
|
||||
|
||||
|
||||
def test_create_note_returns_201_and_stores_trimmed(client: TestClient, db) -> None:
|
||||
r = client.post("/api/steering", json={"note": f" {NOTE} "})
|
||||
def test_create_note_returns_201_and_stores_trimmed(admin_client: TestClient, db) -> None:
|
||||
r = admin_client.post("/api/steering", json={"note": f" {NOTE} "})
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["note"] == NOTE # trimmed before storage
|
||||
@@ -74,13 +74,13 @@ def test_create_note_returns_201_and_stores_trimmed(client: TestClient, db) -> N
|
||||
assert [row.note for row in rows] == [NOTE]
|
||||
|
||||
|
||||
def test_list_notes_empty(client: TestClient) -> None:
|
||||
r = client.get("/api/steering")
|
||||
def test_list_notes_empty(admin_client: TestClient) -> None:
|
||||
r = admin_client.get("/api/steering")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"notes": []}
|
||||
|
||||
|
||||
def test_list_notes_newest_first(client: TestClient, db) -> None:
|
||||
def test_list_notes_newest_first(admin_client: TestClient, db) -> None:
|
||||
base = datetime.now(UTC)
|
||||
db.add_all(
|
||||
[
|
||||
@@ -91,7 +91,7 @@ def test_list_notes_newest_first(client: TestClient, db) -> None:
|
||||
)
|
||||
db.commit()
|
||||
|
||||
r = client.get("/api/steering")
|
||||
r = admin_client.get("/api/steering")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert [n["note"] for n in body["notes"]] == ["newest", "middle", "oldest"]
|
||||
@@ -100,33 +100,33 @@ def test_list_notes_newest_first(client: TestClient, db) -> None:
|
||||
uuid.UUID(n["id"])
|
||||
|
||||
|
||||
def test_delete_note_returns_204_and_removes(client: TestClient, db) -> None:
|
||||
created = client.post("/api/steering", json={"note": NOTE}).json()
|
||||
def test_delete_note_returns_204_and_removes(admin_client: TestClient, db) -> None:
|
||||
created = admin_client.post("/api/steering", json={"note": NOTE}).json()
|
||||
|
||||
assert client.delete(f"/api/steering/{created['id']}").status_code == 204
|
||||
assert client.get("/api/steering").json() == {"notes": []}
|
||||
assert admin_client.delete(f"/api/steering/{created['id']}").status_code == 204
|
||||
assert admin_client.get("/api/steering").json() == {"notes": []}
|
||||
assert db.scalars(select(SteeringNote)).all() == []
|
||||
|
||||
|
||||
def test_delete_unknown_note_returns_404(client: TestClient) -> None:
|
||||
r = client.delete(f"/api/steering/{uuid.uuid4()}")
|
||||
def test_delete_unknown_note_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.delete(f"/api/steering/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_delete_invalid_id_returns_422(client: TestClient) -> None:
|
||||
assert client.delete("/api/steering/not-a-uuid").status_code == 422
|
||||
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.delete("/api/steering/not-a-uuid").status_code == 422
|
||||
|
||||
|
||||
def test_create_rejects_empty_and_blank_notes(client: TestClient) -> None:
|
||||
assert client.post("/api/steering", json={"note": ""}).status_code == 422
|
||||
assert client.post("/api/steering", json={"note": " \t\n "}).status_code == 422
|
||||
assert client.get("/api/steering").json() == {"notes": []}
|
||||
def test_create_rejects_empty_and_blank_notes(admin_client: TestClient) -> None:
|
||||
assert admin_client.post("/api/steering", json={"note": ""}).status_code == 422
|
||||
assert admin_client.post("/api/steering", json={"note": " \t\n "}).status_code == 422
|
||||
assert admin_client.get("/api/steering").json() == {"notes": []}
|
||||
|
||||
|
||||
def test_create_enforces_2000_char_limit(client: TestClient) -> None:
|
||||
assert client.post("/api/steering", json={"note": "x" * 2001}).status_code == 422
|
||||
r = client.post("/api/steering", json={"note": "x" * 2000})
|
||||
def test_create_enforces_2000_char_limit(admin_client: TestClient) -> None:
|
||||
assert admin_client.post("/api/steering", json={"note": "x" * 2001}).status_code == 422
|
||||
r = admin_client.post("/api/steering", json={"note": "x" * 2000})
|
||||
assert r.status_code == 201
|
||||
assert len(r.json()["note"]) == 2000
|
||||
|
||||
@@ -135,14 +135,14 @@ def test_create_enforces_2000_char_limit(client: TestClient) -> None:
|
||||
|
||||
|
||||
def test_chat_turn_high_mode_receives_note_in_system_prompt(
|
||||
client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
client.post("/api/steering", json={"note": NOTE})
|
||||
admin_client.post("/api/steering", json={"note": NOTE})
|
||||
caplog.set_level(logging.INFO, logger="app.chat")
|
||||
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
_, _, frames = _stream_chat(admin_client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
@@ -170,14 +170,14 @@ def test_chat_turn_high_mode_receives_note_in_system_prompt(
|
||||
|
||||
|
||||
def test_chat_turn_low_mode_receives_note_in_system_prompt(
|
||||
client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
client.post("/api/steering", json={"note": NOTE})
|
||||
admin_client.post("/api/steering", json={"note": NOTE})
|
||||
caplog.set_level(logging.INFO, logger="app.chat")
|
||||
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, OFF_TOPIC)
|
||||
_, _, frames = _stream_chat(admin_client, OFF_TOPIC)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
@@ -192,13 +192,13 @@ def test_chat_turn_low_mode_receives_note_in_system_prompt(
|
||||
|
||||
|
||||
def test_chat_turn_without_notes_has_no_tuning_section(
|
||||
client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
caplog.set_level(logging.INFO, logger="app.chat")
|
||||
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
_stream_chat(client, QUESTION)
|
||||
_stream_chat(admin_client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
@@ -208,7 +208,7 @@ def test_chat_turn_without_notes_has_no_tuning_section(
|
||||
assert lines and "tuning=0" in lines[-1]
|
||||
|
||||
|
||||
def test_chat_turn_numbers_notes_oldest_first(client: TestClient, db, seeded_kb) -> None:
|
||||
def test_chat_turn_numbers_notes_oldest_first(admin_client: TestClient, db, seeded_kb) -> None:
|
||||
base = datetime.now(UTC)
|
||||
db.add_all(
|
||||
[
|
||||
@@ -220,7 +220,7 @@ def test_chat_turn_numbers_notes_oldest_first(client: TestClient, db, seeded_kb)
|
||||
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
_stream_chat(client, QUESTION)
|
||||
_stream_chat(admin_client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
@@ -231,22 +231,22 @@ def test_chat_turn_numbers_notes_oldest_first(client: TestClient, db, seeded_kb)
|
||||
assert system["content"].index("1. older note") < system["content"].index("2. newer note")
|
||||
|
||||
|
||||
def test_multiple_turns_keep_reading_notes(client: TestClient, seeded_kb) -> None:
|
||||
def test_multiple_turns_keep_reading_notes(admin_client: TestClient, seeded_kb) -> None:
|
||||
"""The note steers EVERY subsequent turn, not just the next one."""
|
||||
client.post("/api/steering", json={"note": NOTE})
|
||||
admin_client.post("/api/steering", json={"note": NOTE})
|
||||
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
_stream_chat(client, QUESTION)
|
||||
_stream_chat(client, QUESTION)
|
||||
_stream_chat(admin_client, QUESTION)
|
||||
_stream_chat(admin_client, QUESTION)
|
||||
assert len(seeded_kb.seen_messages) == 2
|
||||
for messages in seeded_kb.seen_messages:
|
||||
assert f"1. {NOTE}" in messages[0]["content"]
|
||||
|
||||
# Delete → the following turn is clean again.
|
||||
note_id = client.get("/api/steering").json()["notes"][0]["id"]
|
||||
assert client.delete(f"/api/steering/{note_id}").status_code == 204
|
||||
_stream_chat(client, QUESTION)
|
||||
note_id = admin_client.get("/api/steering").json()["notes"][0]["id"]
|
||||
assert admin_client.delete(f"/api/steering/{note_id}").status_code == 204
|
||||
_stream_chat(admin_client, QUESTION)
|
||||
assert len(seeded_kb.seen_messages) == 3
|
||||
assert "<tuning>" not in seeded_kb.seen_messages[-1][0]["content"]
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user