–
diff --git a/pyproject.toml b/pyproject.toml
index 2185c4b..c44a696 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,10 @@ dependencies = [
# --- LLM client (OpenAI-compatible, self-hosted "aipi") ---
"httpx>=0.27,<1.0",
"openai>=1.40,<3.0",
+ # Phase 16: starlette's SessionMiddleware signs the session cookie with
+ # itsdangerous — an OPTIONAL starlette extra ("full") since starlette 1.x,
+ # so the app declares it directly (narrower than starlette[full]).
+ "itsdangerous>=2.2,<3.0",
]
[dependency-groups]
diff --git a/tests/conftest.py b/tests/conftest.py
index b2644d9..a7fe4f4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,6 +15,14 @@ from sqlalchemy.orm import Session
# The production default stays 0.62 (app/config.py, A8 revised).
os.environ.setdefault("BOR_RELEVANCE_THRESHOLD", "0.30")
+# Phase 16: single-admin auth is fail-loud — create_app() refuses to boot
+# without both vars, and app.main (imported below) builds the app at
+# import time. Set known test values first, same pattern as the threshold.
+ADMIN_PASSWORD = "test-admin-password"
+SESSION_SECRET = "test-session-secret-0123456789abcdef0123456789abcdef"
+os.environ.setdefault("BOR_ADMIN_PASSWORD", ADMIN_PASSWORD)
+os.environ.setdefault("BOR_SESSION_SECRET", SESSION_SECRET)
+
from app.db import SessionLocal, db_available # noqa: E402
from app.main import app as fastapi_app # noqa: E402
@@ -24,6 +32,19 @@ def client() -> TestClient:
return TestClient(fastapi_app)
+@pytest.fixture()
+def admin_client(client: TestClient) -> TestClient:
+ """A client signed in as the single admin (phase 16).
+
+ TestClient keeps its cookie jar across requests, so one login covers
+ every subsequent request of the test. Use it for the admin-only
+ surface (``GET /api/docs``, ``/api/steering``).
+ """
+ r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
+ assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
+ return client
+
+
@pytest.fixture()
def db() -> Iterator[Session]:
"""Real Postgres session (``podman compose up -d db``).
diff --git a/tests/e2e/auth_helpers.py b/tests/e2e/auth_helpers.py
new file mode 100644
index 0000000..65db3ea
--- /dev/null
+++ b/tests/e2e/auth_helpers.py
@@ -0,0 +1,38 @@
+"""Shared Playwright auth helper (phase 16).
+
+``login`` drives the REAL form login on /login.html (fill → submit →
+redirect) so every story that needs the admin does exactly what a human
+would — no cookie surgery. ``password=None`` uses the shared E2E admin
+password (success path); pass a wrong value to drive the error state
+(no redirect, ``#login-error`` role=alert visible, still anonymous).
+"""
+from __future__ import annotations
+
+from playwright.sync_api import Page, expect
+
+from e2e.conftest import ADMIN_PASSWORD # noqa: F401 (re-exported for tests)
+
+DEFAULT_NEXT = "/sources.html"
+
+
+def login(page: Page, app_url: str, password: str | None = None, next: str | None = None) -> None:
+ """Perform the real form login and wait for its outcome.
+
+ * correct password (or ``password=None`` → the shared admin password)
+ → redirects to ``next`` (default ``/sources.html``);
+ * wrong password → ``#login-error`` (role=alert) is visible, the URL
+ never changes, and the visitor is still anonymous.
+ """
+ attempt = ADMIN_PASSWORD if password is None else password
+ url = f"{app_url}/login.html"
+ if next is not None:
+ url += f"?next={next}"
+ page.goto(url)
+ expect(page.locator("#login-password")).to_be_visible()
+ page.fill("#login-password", attempt)
+ page.click("#login-form button[type=submit]")
+ if attempt != ADMIN_PASSWORD:
+ expect(page.locator("#login-error")).to_be_visible(timeout=15_000)
+ expect(page).to_have_url(url) # no redirect on failure
+ return
+ expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 9463256..78b6fb9 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -31,6 +31,13 @@ MOCK_PORT = int(os.environ.get("E2E_MOCK_PORT", "8901"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
USE_REAL_LLM = os.environ.get("E2E_REAL_LLM") == "1"
+# Phase 16: the app under test boots with single-admin auth configured
+# (fail-loud otherwise). Known E2E values — the shared form-login helper
+# (tests/e2e/auth_helpers.py) uses ADMIN_PASSWORD; the secret is fixed so
+# session cookies stay valid across a session-scoped app restart.
+ADMIN_PASSWORD = "e2e-admin-password"
+SESSION_SECRET = "e2e-session-secret-0123456789abcdef0123456789abcdef"
+
def _wait_http(url: str, timeout: float = 40.0) -> None:
deadline = time.monotonic() + timeout
@@ -89,6 +96,9 @@ def app_server(mock_llm: int) -> Iterator[str]:
# `embed` model's 0.41–0.84 cosine range, PLAN A8).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault("BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese")
+ # Phase 16: admin auth must be set or create_app() refuses to boot.
+ env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
+ env["BOR_SESSION_SECRET"] = SESSION_SECRET
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
diff --git a/tests/e2e/test_admin_auth.py b/tests/e2e/test_admin_auth.py
new file mode 100644
index 0000000..8493305
--- /dev/null
+++ b/tests/e2e/test_admin_auth.py
@@ -0,0 +1,326 @@
+"""Phase 16 E2E (Playwright): single-admin sign-in (A10 revised).
+
+Story: ``.agent/user_stories/admin-auth.md``
+Run in isolation (DB must be up: ``podman compose up -d db``):
+
+ uv run pytest tests/e2e/test_admin_auth.py -v --no-cov
+
+The E2E app server boots with ``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``
+set (``tests/e2e/conftest.py``); the shared ``tests/e2e/auth_helpers.py::login``
+performs the real form login on /login.html.
+
+Test → story mapping (Playwright Mapping Rule):
+1. ``test_anonymous_chat_without_tuning``
+2. ``test_anonymous_sources_gated_viewer_open``
+3. ``test_login_wrong_password_shows_error``
+4. ``test_admin_login_unlocks_sources_and_tuning``
+5. ``test_logout_returns_to_anonymous``
+6. ``test_login_page_a11y``
+"""
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from threading import Thread
+from typing import Any
+
+from playwright.sync_api import Page, expect
+from sqlalchemy import text
+
+from app.config import Settings
+from app.db import SessionLocal
+from app.rag.importer import ImportSummary, import_sources
+from app.rag.llm import LLMClient
+from e2e.auth_helpers import ADMIN_PASSWORD, login
+
+REPO = Path(__file__).resolve().parents[2]
+FIXTURES = REPO / "tests" / "fixtures" / "docs"
+QUESTION = "How is my Kubernetes cluster set up?"
+MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
+DOC_TITLE = "Kubernetes Homelab Cluster"
+DOC_VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
+
+
+async def _import_fixtures(mock_port: int) -> ImportSummary:
+ kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
+ settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
+ return await import_sources([FIXTURES], LLMClient(settings))
+
+
+def _run_in_thread(coro: Any) -> Any:
+ """Run a coroutine on a worker thread (Playwright owns the test loop)."""
+ box: dict[str, Any] = {}
+
+ def runner() -> None:
+ try:
+ box["value"] = asyncio.run(coro)
+ except BaseException as e: # noqa: BLE001 — re-raised on the test thread
+ box["error"] = e
+
+ t = Thread(target=runner)
+ t.start()
+ t.join()
+ if "error" in box:
+ raise box["error"]
+ return box["value"]
+
+
+def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
+ """Truncate the KB (and query log + steering notes), optionally re-seed."""
+ with SessionLocal() as db:
+ db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
+ db.commit()
+ if not seed:
+ return None
+ return _run_in_thread(_import_fixtures(mock_port))
+
+
+def _ask(page: Page, question: str) -> None:
+ """Send one turn and wait until the grounded answer has fully landed."""
+ page.fill("#message-input", question)
+ page.click("#send-btn")
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(
+ MOCK_ANSWER_MARKER, timeout=30_000
+ )
+ expect(page.locator("#send-btn")).to_be_enabled()
+ expect(page.locator("#send-label")).to_have_text("Send")
+
+
+# ---------------------------------------------------------------------------
+# 1. Anonymous: chat works, the tuning UI is gone, Sign in is offered
+# ---------------------------------------------------------------------------
+
+
+def test_anonymous_chat_without_tuning(
+ page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+ _reset_db(mock_llm, seed=True)
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+
+ # Header: Sign in offered, Sign out not.
+ expect(page.locator("#sign-in-link")).to_be_visible()
+ expect(page.locator("#sign-in-link")).to_have_attribute(
+ "href", "/login.html?next=/sources.html"
+ )
+ expect(page.locator("#sign-out-btn")).to_be_hidden()
+
+ # Chat still streams a grounded answer (with source chips) for
+ # anonymous visitors…
+ _ask(page, QUESTION)
+ expect(page.locator(".msg.brain .source-chip", has_text="kubernetes.md")).to_have_count(1)
+
+ # …but the tuning UI is completely gone: no Tune button (new or
+ # restored), no Tuning toggle or panel in the DOM at all.
+ expect(page.locator(".msg.brain .tune-btn")).to_have_count(0)
+ expect(page.locator("#steering-toggle")).to_have_count(0)
+ expect(page.locator("#steering-panel")).to_have_count(0)
+
+ # A reload (the phase-14 restore path) must not bring it back.
+ page.reload()
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
+ expect(page.locator(".msg.brain .tune-btn")).to_have_count(0)
+ expect(page.locator("#steering-toggle")).to_have_count(0)
+ expect(page.locator("#sign-in-link")).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 2. Anonymous: Sources gated, the document viewer stays open (soft rule)
+# ---------------------------------------------------------------------------
+
+
+def test_anonymous_sources_gated_viewer_open(
+ page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+ _reset_db(mock_llm, seed=True)
+
+ api_docs_calls: list[str] = []
+ page.on(
+ "request",
+ lambda r: api_docs_calls.append(r.url) if "/api/docs" in r.url else None,
+ )
+
+ page.goto(f"{app_url}/sources.html")
+ # The gate, with its sign-in link (≥44px) — not a redirect.
+ gate = page.locator("#sources-gate")
+ expect(gate).to_be_visible()
+ expect(gate).to_contain_text("Sign in to view the full catalog")
+ link = gate.locator("a[href='/login.html?next=/sources.html']")
+ expect(link).to_have_count(1)
+ box = link.bounding_box()
+ assert box is not None and box["height"] >= 44
+
+ # Stat cards + table hidden…
+ expect(page.locator("#stat-cards")).to_be_hidden()
+ expect(page.locator("#docs-table")).to_be_hidden()
+ expect(page.locator("#sources-empty")).to_be_hidden()
+ # …and NO /api/docs call was ever made.
+ assert api_docs_calls == [], f"anonymous sources page called /api/docs: {api_docs_calls}"
+
+ # The soft rule: any seeded document still opens by direct URL.
+ page.goto(app_url + DOC_VIEWER_URL)
+ expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
+ expect(page.locator("#doc-content")).not_to_be_empty()
+
+
+# ---------------------------------------------------------------------------
+# 3. Wrong password → role=alert error, no redirect, still anonymous
+# ---------------------------------------------------------------------------
+
+
+def test_login_wrong_password_shows_error(page: Page, app_url: str, db_ready: None) -> None:
+ _reset_db(mock_port=0, seed=False)
+ page.set_default_timeout(30_000)
+
+ login(page, app_url, password="definitely-not-the-password")
+
+ error = page.locator("#login-error")
+ expect(error).to_be_visible()
+ assert error.get_attribute("role") == "alert"
+ expect(error).not_to_be_empty()
+ # No redirect happened…
+ expect(page).to_have_url(app_url + "/login.html")
+ # …and the server agrees: still anonymous, no session cookie set.
+ who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
+ assert who == {"authenticated": False, "role": "anonymous"}
+
+ # The form stays usable: the correct password now succeeds.
+ page.fill("#login-password", ADMIN_PASSWORD)
+ page.click("#login-form button[type=submit]")
+ expect(page).to_have_url(app_url + "/sources.html", timeout=30_000)
+
+
+# ---------------------------------------------------------------------------
+# 4. Correct password → Sources + tuning unlocked, Sign out offered
+# ---------------------------------------------------------------------------
+
+
+def test_admin_login_unlocks_sources_and_tuning(
+ page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+ _reset_db(mock_llm, seed=True)
+ page.set_default_timeout(30_000)
+
+ # Real form login (default password + next) lands on the catalog.
+ login(page, app_url)
+ expect(page).to_have_url(app_url + "/sources.html")
+ expect(page.locator("#sources-gate")).to_be_hidden()
+ expect(page.locator("#stat-docs")).to_have_text("8")
+ expect(page.locator("#stat-chunks")).not_to_have_text("–")
+ expect(page.locator("#docs-table")).to_be_visible()
+ expect(page.locator("#docs-tbody tr")).to_have_count(8)
+
+ # Chat: the tuning UI is back — header toggle with count badge,
+ # Sign out instead of Sign in, Tune under the answer.
+ page.goto(app_url)
+ expect(page.locator("#sign-out-btn")).to_be_visible()
+ expect(page.locator("#sign-in-link")).to_be_hidden()
+ toggle = page.locator("#steering-toggle")
+ expect(toggle).to_be_visible()
+ expect(page.locator("#steering-count")).to_have_text("0")
+
+ _ask(page, QUESTION)
+ tune = page.locator(".msg.brain .tune-btn").last
+ expect(tune).to_be_visible()
+ box = tune.bounding_box()
+ assert box is not None and box["height"] >= 44
+
+ # The API agrees: admin, and the gated endpoints answer now.
+ who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
+ assert who == {"authenticated": True, "role": "admin"}
+ docs_status = page.evaluate("() => fetch('/api/docs').then((r) => r.status)")
+ assert docs_status == 200
+
+
+# ---------------------------------------------------------------------------
+# 5. Sign out → anonymous again (gate back, tuning gone, restore untunable)
+# ---------------------------------------------------------------------------
+
+
+def test_logout_returns_to_anonymous(
+ page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+ _reset_db(mock_llm, seed=True)
+ page.set_default_timeout(30_000)
+
+ login(page, app_url, next="/") # straight into the chat
+ expect(page).to_have_url(app_url + "/")
+ expect(page.locator("#sign-out-btn")).to_be_visible()
+ expect(page.locator("#steering-toggle")).to_be_visible()
+
+ # One grounded turn as admin (persisted to localStorage by phase 14).
+ _ask(page, QUESTION)
+ expect(page.locator(".msg.brain .tune-btn").last).to_be_visible()
+
+ # Sign out: POST /api/logout + reload → anonymous again.
+ page.click("#sign-out-btn")
+ expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000)
+ expect(page.locator("#sign-out-btn")).to_be_hidden()
+ expect(page.locator("#steering-toggle")).to_have_count(0)
+ expect(page.locator("#steering-panel")).to_have_count(0)
+
+ # The restored conversation came back… without any Tune button.
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
+ expect(page.locator(".msg.brain .tune-btn")).to_have_count(0)
+
+ # The server agrees, and Sources is gated again.
+ who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
+ assert who == {"authenticated": False, "role": "anonymous"}
+ page.goto(f"{app_url}/sources.html")
+ expect(page.locator("#sources-gate")).to_be_visible()
+ expect(page.locator("#docs-table")).to_be_hidden()
+
+
+# ---------------------------------------------------------------------------
+# 6. Login page accessibility (WCAG 2.1 AA basics)
+# ---------------------------------------------------------------------------
+
+
+def test_login_page_a11y(page: Page, app_url: str, db_ready: None) -> None:
+ _reset_db(mock_port=0, seed=False)
+ page.set_default_timeout(30_000)
+
+ page.goto(f"{app_url}/login.html")
+
+ # Standard app frame: landmarks + skip link, no CDN tags.
+ expect(page.locator("header.app-header")).to_have_count(1)
+ expect(page.locator("nav[aria-label='Primary']")).to_have_count(1)
+ expect(page.locator("main#main")).to_have_count(1)
+ expect(page.locator("footer.app-footer")).to_have_count(1)
+ expect(page.locator(".skip-link")).to_have_count(1)
+ html = page.content()
+ assert 'src="https://' not in html and 'href="https://' not in html
+
+ # The password field is labeled (visually-hidden ).
+ pw = page.get_by_label("Admin password")
+ expect(pw).to_have_count(1)
+ expect(pw.first).to_have_attribute("type", "password")
+ expect(pw.first).to_have_attribute("autocomplete", "current-password")
+
+ # Touch targets ≥44px (field + submit).
+ for el in (pw.first, page.locator("#login-form button[type=submit]")):
+ box = el.bounding_box()
+ assert box is not None and box["height"] >= 44, f"target too small: {box}"
+
+ # Keyboard focus draws the 3px focus-visible outline.
+ page.focus("#login-password")
+ outline = page.evaluate(
+ "() => getComputedStyle(document.querySelector('#login-password')).outlineWidth"
+ )
+ assert outline == "3px", f"focus-visible outline missing: {outline!r}"
+
+ # Errors are announced through the role=alert region.
+ error = page.locator("#login-error")
+ assert error.get_attribute("role") == "alert"
+ expect(error).to_be_hidden()
+ page.fill("#login-password", "wrong")
+ page.click("#login-form button[type=submit]")
+ expect(error).to_be_visible(timeout=15_000)
+
+ # A signed-in visit to /login.html?next=/ redirects immediately.
+ page.fill("#login-password", ADMIN_PASSWORD)
+ page.click("#login-form button[type=submit]")
+ expect(page).to_have_url(app_url + "/sources.html", timeout=30_000)
+ page.goto(f"{app_url}/login.html?next=/")
+ expect(page).to_have_url(app_url + "/", timeout=30_000)
diff --git a/tests/e2e/test_chat_persistence.py b/tests/e2e/test_chat_persistence.py
index f710f4e..a2259e4 100644
--- a/tests/e2e/test_chat_persistence.py
+++ b/tests/e2e/test_chat_persistence.py
@@ -33,6 +33,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -271,7 +272,9 @@ def test_persists_across_page_navigation(
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
# A trip to Sources — the New chat control is chat-page-only.
- page.goto(app_url + "/sources.html")
+ # (Phase 16: the catalog is admin-only — the trip starts with a
+ # real form login.)
+ login(page, app_url, next="/sources.html")
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#new-chat-btn")).to_have_count(0)
diff --git a/tests/e2e/test_dark_tech_theme.py b/tests/e2e/test_dark_tech_theme.py
index 1aa8cc3..cb91180 100644
--- a/tests/e2e/test_dark_tech_theme.py
+++ b/tests/e2e/test_dark_tech_theme.py
@@ -42,6 +42,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -212,8 +213,9 @@ def test_dark_palette_and_contrast(
_assert_aa(pairs["button"], "dark ink on brand (send button)")
_assert_aa(pairs["chip"], "chip ink on chip bg (chat)")
- # Sources page pairs.
- page.goto(f"{app_url}/sources.html")
+ # Sources page pairs. (Phase 16: the stat cards are admin-only —
+ # a real form login first.)
+ login(page, app_url, next="/sources.html")
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
diff --git a/tests/e2e/test_document_back_navigation.py b/tests/e2e/test_document_back_navigation.py
index 55d64b4..1e0762c 100644
--- a/tests/e2e/test_document_back_navigation.py
+++ b/tests/e2e/test_document_back_navigation.py
@@ -40,6 +40,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -140,7 +141,7 @@ def test_back_from_sources_returns_to_sources(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the Sources table is admin-only
row = page.locator("#docs-tbody tr", has_text="kubernetes.md")
expect(row).to_have_count(1)
diff --git a/tests/e2e/test_document_viewer.py b/tests/e2e/test_document_viewer.py
index da0499e..5ca2f62 100644
--- a/tests/e2e/test_document_viewer.py
+++ b/tests/e2e/test_document_viewer.py
@@ -38,6 +38,7 @@ from app.db import SessionLocal
from app.models import Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -139,7 +140,7 @@ def test_sources_row_links_to_viewer(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the Sources catalog is admin-only
row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml")
expect(row).to_have_count(1)
diff --git a/tests/e2e/test_header_consistency.py b/tests/e2e/test_header_consistency.py
index 28b5db9..76dbff6 100644
--- a/tests/e2e/test_header_consistency.py
+++ b/tests/e2e/test_header_consistency.py
@@ -13,6 +13,11 @@ Test → story mapping (Playwright Mapping Rule):
1. ``test_header_height_identical_across_pages_desktop``
2. ``test_header_height_identical_across_pages_mobile``
3. ``test_viewer_header_content_still_fits`` (phase-10 regression guard)
+
+Phase 16 adaptation: the auth control (Sign in / Sign out) joins the chat
+header's ``.header-inner`` — the desktop test verifies its presence in
+both auth states without the bar's height moving (height assertions
+unchanged).
"""
from __future__ import annotations
@@ -28,6 +33,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -111,6 +117,18 @@ def test_header_height_identical_across_pages_desktop(
f"document header {heights['document']}px (was content-sized)"
)
+ # Phase 16: the auth control lives in the same bar — anonymous sees
+ # "Sign in", signed-in sees "Sign out", and neither state moves the
+ # height.
+ page.goto(app_url + "/")
+ expect(page.locator("#sign-in-link")).to_be_visible()
+ expect(page.locator("#sign-out-btn")).to_be_hidden()
+ assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
+ login(page, app_url, next="/")
+ expect(page.locator("#sign-out-btn")).to_be_visible()
+ expect(page.locator("#sign-in-link")).to_be_hidden()
+ assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
+
# ---------------------------------------------------------------------------
# 2. Mobile (≤640px): all three pages, one identical 58px bar
diff --git a/tests/e2e/test_import_documents.py b/tests/e2e/test_import_documents.py
index 291129b..7475246 100644
--- a/tests/e2e/test_import_documents.py
+++ b/tests/e2e/test_import_documents.py
@@ -8,6 +8,9 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
Seeding runs the real import function in-process against
``tests/fixtures/docs/`` with the deterministic mock embeddings — it is a
fixture, not the subject of the tests.
+
+Phase 16 adaptation: the Sources catalog is admin-only — every test
+performs the real form login (``e2e.auth_helpers.login``) first.
"""
from __future__ import annotations
@@ -23,6 +26,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -86,7 +90,7 @@ def test_sources_page_lists_indexed_docs(
assert summary is not None and summary.added == 8
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the catalog is admin-only
expect(page.locator("#stat-docs")).to_have_text("8")
expect(page.locator("#stat-chunks")).to_have_text(str(summary.chunks))
expect(page.locator("#stat-last")).not_to_have_text("–")
@@ -105,7 +109,7 @@ def test_sources_table_layout(
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the catalog is admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible")
wrap = page.locator(".table-wrap")
@@ -124,7 +128,7 @@ def test_sources_table_layout(
# scrolls horizontally instead of squeezing into a hairline.
mobile = browser.new_page(viewport={"width": 375, "height": 812})
try:
- mobile.goto(f"{app_url}/sources.html")
+ login(mobile, app_url) # phase 16: the catalog is admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
scroll_width, client_width = mobile.evaluate(
"() => { const el = document.querySelector('.table-wrap');"
@@ -138,7 +142,7 @@ def test_sources_table_layout(
def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
_reset_db(mock_llm, seed=False)
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the (empty-state) catalog is admin-only
expect(page.locator("#sources-empty")).to_be_visible()
expect(page.locator("#sources-empty")).to_contain_text("Nothing indexed yet")
expect(page.locator("#sources-empty code")).to_have_text(
diff --git a/tests/e2e/test_responsive_polish.py b/tests/e2e/test_responsive_polish.py
index f785f7a..0ab7abe 100644
--- a/tests/e2e/test_responsive_polish.py
+++ b/tests/e2e/test_responsive_polish.py
@@ -45,6 +45,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -206,7 +207,7 @@ def test_no_horizontal_overflow_at_viewports(
)
_assert_no_doc_overflow(page, f"chat @ {width}px")
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url, next="/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
_assert_no_doc_overflow(page, f"sources @ {width}px")
finally:
@@ -254,7 +255,7 @@ def test_sources_table_full_width(
_reset_db(mock_llm, seed=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url, next="/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
wrap_box = page.locator(".table-wrap").bounding_box()
shell_box = page.locator(".sources-shell").bounding_box()
@@ -268,7 +269,7 @@ def test_sources_table_full_width(
mobile = browser.new_page(viewport={"width": 375, "height": 812})
try:
- mobile.goto(f"{app_url}/sources.html")
+ login(mobile, app_url, next="/sources.html") # phase 16: admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
scroll, client = mobile.evaluate(
"() => { const el = document.querySelector('.table-wrap');"
@@ -389,7 +390,8 @@ def test_contrast_pairs_pass_aa(
_assert_aa(pairs["deflection"], "deflection ink on deflection bg")
# Sources page: ink-soft/surface, white/brand (active nav).
- page.goto(f"{app_url}/sources.html")
+ # (Phase 16: the stat cards are admin-only — sign in first.)
+ login(page, app_url, next="/sources.html")
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
@@ -498,7 +500,7 @@ def test_long_content_wraps_without_overflow(
# Sources @ 360px: the long path ellipsizes, full path stays in `title`.
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
- phone.goto(f"{app_url}/sources.html")
+ login(phone, app_url, next="/sources.html") # phase 16: admin-only
row = phone.locator("#docs-tbody tr", has_text="backup_rotation").first
row.wait_for(state="visible", timeout=10_000)
cell = row.get_by_role("cell").nth(1)
diff --git a/tests/e2e/test_retrieval_quality.py b/tests/e2e/test_retrieval_quality.py
index d3437b2..6a0af2d 100644
--- a/tests/e2e/test_retrieval_quality.py
+++ b/tests/e2e/test_retrieval_quality.py
@@ -32,6 +32,7 @@ from app.db import SessionLocal
from app.models import QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -90,7 +91,15 @@ def test_multi_format_import_hidden_doc_excluded(
assert summary.added == 8
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
- r = httpx.get(f"{app_url}/api/docs", timeout=10)
+ # Phase 16: the catalog is admin-only — perform the real form login,
+ # then call the API with the signed cookie the browser now holds.
+ login(page, app_url, next="/sources.html")
+ cookies = {
+ c["name"]: c["value"]
+ for c in page.context.cookies()
+ if "name" in c and "value" in c
+ }
+ r = httpx.get(f"{app_url}/api/docs", timeout=10, cookies=cookies)
assert r.status_code == 200
docs = r.json()["documents"]
assert len(docs) == 8
@@ -103,8 +112,7 @@ def test_multi_format_import_hidden_doc_excluded(
"homelab/ssh/ssh_aliases.txt",
}
- # The Sources page reflects the same set.
- page.goto(f"{app_url}/sources.html")
+ # The Sources page (we're already on it, signed in) reflects the set.
expect(page.locator("#stat-docs")).to_have_text("8")
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
diff --git a/tests/e2e/test_steering.py b/tests/e2e/test_steering.py
index a5e3db7..5d4cd22 100644
--- a/tests/e2e/test_steering.py
+++ b/tests/e2e/test_steering.py
@@ -1,5 +1,8 @@
"""Phase 15 E2E (Playwright): tune how Brain answers (steering notes).
+Phase 16 adaptation: tuning is admin-only — every test performs the real
+form login (``e2e.auth_helpers.login``) before touching the tuning UI.
+
Story: ``.agent/user_stories/steering-notes.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
@@ -34,6 +37,7 @@ from app.db import SessionLocal
from app.models import SteeringNote
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -125,6 +129,7 @@ def test_tune_under_answer_persists_and_steers(
assert summary is not None and summary.added == 8 # A9 formats
page.set_default_timeout(30_000)
page.goto(app_url)
+ login(page, app_url, next="/") # phase 16: tuning is admin-only
_ask(page, QUESTION)
# The Tune control: ghost button in the answer's meta row, ≥44px.
@@ -165,6 +170,7 @@ def test_delete_note_stops_steering(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
+ login(page, app_url, next="/") # phase 16: tuning is admin-only
_ask(page, QUESTION)
_tune_and_save(page, NOTE)
@@ -202,6 +208,7 @@ def test_note_rendered_as_text_xss_safe(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
+ login(page, app_url, next="/") # phase 16: tuning is admin-only
dialogs: list[str] = []
@@ -234,6 +241,7 @@ def test_tuning_panel_a11y(page: Page, app_url: str, db_ready: None) -> None:
_reset_db(mock_port=0, seed=False) # no KB seeding needed for the panel a11y
page.set_default_timeout(30_000)
page.goto(app_url)
+ login(page, app_url, next="/") # phase 16: the panel is admin-only
toggle = page.locator("#steering-toggle")
panel = page.locator("#steering-panel")
diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py
index 516f8ec..3c759a3 100644
--- a/tests/integration/test_api.py
+++ b/tests/integration/test_api.py
@@ -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",
],
)
diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py
new file mode 100644
index 0000000..0ebf4d5
--- /dev/null
+++ b/tests/integration/test_auth_api.py
@@ -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"
diff --git a/tests/integration/test_docs_api.py b/tests/integration/test_docs_api.py
index 0e37ad9..7a7a2d7 100644
--- a/tests/integration/test_docs_api.py
+++ b/tests/integration/test_docs_api.py
@@ -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"}
diff --git a/tests/integration/test_importer_e2e.py b/tests/integration/test_importer_e2e.py
index 984eb9b..0dbdd0e 100644
--- a/tests/integration/test_importer_e2e.py
+++ b/tests/integration/test_importer_e2e.py
@@ -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
diff --git a/tests/integration/test_steering.py b/tests/integration/test_steering.py
index 9ec0dec..030bf2d 100644
--- a/tests/integration/test_steering.py
+++ b/tests/integration/test_steering.py
@@ -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 "" not in seeded_kb.seen_messages[-1][0]["content"]
finally:
diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py
new file mode 100644
index 0000000..0402942
--- /dev/null
+++ b/tests/unit/test_auth.py
@@ -0,0 +1,153 @@
+"""Unit tests: single-admin auth (phase 16; A10 revised).
+
+Covers the config gate (fail-loud, including via ``create_app``), the
+constant-time password check, the ``require_admin`` dependency, the
+whoami payload shape, and the sign_in/sign_out session semantics.
+"""
+from __future__ import annotations
+
+import pytest
+from fastapi import HTTPException
+from starlette.middleware.sessions import Session
+from starlette.requests import Request
+
+import app.main as main_mod
+from app.api.auth import whoami
+from app.config import Settings
+from app.core.auth import (
+ check_password,
+ ensure_admin_configured,
+ require_admin,
+ sign_in,
+ sign_out,
+)
+from app.schemas import WhoamiResponse
+
+
+def _settings(**kwargs: object) -> Settings:
+ return Settings(_env_file=None, **kwargs) # pyright: ignore[reportCallIssue]
+
+
+# ---------- ensure_admin_configured (fail-loud) ----------
+
+
+def test_configured_passes() -> None:
+ ensure_admin_configured(_settings(admin_password="pw", session_secret="s"))
+
+
+@pytest.mark.parametrize(
+ ("password", "secret", "named"),
+ [
+ ("", "s", "BOR_ADMIN_PASSWORD"),
+ ("pw", "", "BOR_SESSION_SECRET"),
+ ("", "", "BOR_ADMIN_PASSWORD"),
+ ("", "", "BOR_SESSION_SECRET"),
+ ],
+)
+def test_missing_vars_raise_naming_them(password: str, secret: str, named: str) -> None:
+ with pytest.raises(RuntimeError) as exc:
+ ensure_admin_configured(_settings(admin_password=password, session_secret=secret))
+ assert named in str(exc.value)
+ # Both vars are named when both are missing.
+ if not password and not secret:
+ assert "BOR_ADMIN_PASSWORD" in str(exc.value)
+ assert "BOR_SESSION_SECRET" in str(exc.value)
+
+
+def test_whitespace_only_counts_as_missing() -> None:
+ with pytest.raises(RuntimeError, match="BOR_ADMIN_PASSWORD"):
+ ensure_admin_configured(_settings(admin_password=" ", session_secret="s"))
+
+
+def test_create_app_raises_when_admin_password_missing(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(main_mod.settings, "admin_password", "")
+ with pytest.raises(RuntimeError, match="BOR_ADMIN_PASSWORD"):
+ main_mod.create_app()
+
+
+def test_create_app_raises_when_session_secret_missing(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(main_mod.settings, "session_secret", "")
+ with pytest.raises(RuntimeError, match="BOR_SESSION_SECRET"):
+ main_mod.create_app()
+
+
+def test_create_app_boots_when_configured(monkeypatch: pytest.MonkeyPatch) -> None:
+ # conftest set both env vars before app.main imported — the factory
+ # with valid config returns an app (and no static-dir warning fires
+ # here: the frontend dir exists in the repo).
+ monkeypatch.setattr(main_mod.settings, "admin_password", "pw")
+ monkeypatch.setattr(main_mod.settings, "session_secret", "s")
+ app2 = main_mod.create_app()
+ assert app2 is not None
+
+
+# ---------- check_password (constant-time, one generic result) ----------
+
+
+def test_check_password_match() -> None:
+ assert check_password("hunter2", "hunter2") is True
+
+
+def test_check_password_mismatch() -> None:
+ assert check_password("hunter2", "hunter3") is False
+
+
+def test_check_password_empty_candidate() -> None:
+ assert check_password("", "hunter2") is False
+
+
+def test_check_password_unicode() -> None:
+ assert check_password("pässwörd", "pässwörd") is True
+ assert check_password("pässwörd", "pässwörX") is False
+
+
+# ---------- require_admin (dependency: 403 for anonymous) ----------
+
+
+def _request_with_session(**session: object) -> Request:
+ # request.session is a scope-backed property (SessionMiddleware puts
+ # the Session into the scope) — build the scope the same way.
+ return Request({"type": "http", "session": Session(dict(session))})
+
+
+def test_require_admin_passes_for_admin_session() -> None:
+ require_admin(_request_with_session(admin=True)) # no exception
+
+
+@pytest.mark.parametrize("session", [{}, {"admin": False}, {"admin": None}])
+def test_require_admin_403s_anonymous(session: dict) -> None:
+ with pytest.raises(HTTPException) as exc:
+ require_admin(_request_with_session(**session))
+ assert exc.value.status_code == 403
+ assert exc.value.detail == "admin only"
+
+
+# ---------- whoami payload shape ----------
+
+
+def test_whoami_anonymous_payload() -> None:
+ body = whoami(_request_with_session())
+ assert body == WhoamiResponse(authenticated=False, role="anonymous")
+ assert set(body.model_dump()) == {"authenticated", "role"}
+
+
+def test_whoami_admin_payload() -> None:
+ body = whoami(_request_with_session(admin=True))
+ assert body == WhoamiResponse(authenticated=True, role="admin")
+
+
+# ---------- sign_in / sign_out session semantics ----------
+
+
+def test_sign_in_marks_session_admin_and_modified() -> None:
+ session = Session({})
+ sign_in(session)
+ assert session["admin"] is True
+ assert session.modified is True # the middleware will persist the cookie
+
+
+def test_sign_out_clears_session() -> None:
+ session = Session({"admin": True, "stray": "x"})
+ sign_out(session)
+ assert dict(session) == {}
+ assert "admin" not in session
diff --git a/uv.lock b/uv.lock
index d4579e7..b7ccf45 100644
--- a/uv.lock
+++ b/uv.lock
@@ -55,6 +55,7 @@ dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "httpx" },
+ { name = "itsdangerous" },
{ name = "openai" },
{ name = "pgvector" },
{ name = "psycopg", extra = ["binary"] },
@@ -80,6 +81,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.13,<2.0" },
{ name = "fastapi", specifier = ">=0.115,<1.0" },
{ name = "httpx", specifier = ">=0.27,<1.0" },
+ { name = "itsdangerous", specifier = ">=2.2,<3.0" },
{ name = "openai", specifier = ">=1.40,<3.0" },
{ name = "pgvector", specifier = ">=0.3,<1.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1,<4.0" },
@@ -433,6 +435,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
[[package]]
name = "jiter"
version = "0.16.0"