diff --git a/.agent/phases/complete/01_infrastructure.md b/.agent/phases/complete/01_infrastructure.md deleted file mode 100644 index 7efb6c5..0000000 Binary files a/.agent/phases/complete/01_infrastructure.md and /dev/null differ diff --git a/.agent/phases/complete/02_story_import_documents.md b/.agent/phases/complete/02_story_import_documents.md deleted file mode 100644 index 8c6ef1f..0000000 --- a/.agent/phases/complete/02_story_import_documents.md +++ /dev/null @@ -1,76 +0,0 @@ -# Phase 02 — Story: Import Documents - -**Story:** `.agent/user_stories/import-documents.md` -**Context:** `.agent/PLAN.md` §5 (data model), §9 (logging), §11 (import workflow) - -## Goal -The importer (`scripts/import_docs.py`) + `GET /api/docs` + the Sources page -rendering the indexed documents — the knowledge base becomes refreshable. - -## Implementation steps -1. `app/rag/__init__.py`, `app/rag/chunker.py` — markdown-aware chunker - (PLAN §5 policy: heading splits, 2000-char target, 200 overlap, keep - nearest heading). Pure functions, fully unit-testable. -2. `app/rag/llm.py` — `LLMClient` (openai async) with `embed(texts) -> - list[list[float]]` (batched, `BOR_EMBED_BATCH_SIZE`) and a - `embed_one`; dimension check vs `settings.embedding_dim` with a loud, - actionable error. (Chat streaming is added in Phase 03 on this client.) -3. `app/rag/importer.py` — the core: directory walk (exclusion list, PLAN - A9; `*.md` only), sha256 delta vs `documents.content_hash`, - upsert-or-skip, two-phase chunk replace (insert doc → replace chunks → - embed → commit), `--prune` support, per-file + summary logging. -4. `scripts/import_docs.py` — CLI wrapper (argparse): repeatable - `--source` (default `~/Homelab` `~/Deployments`, `expanduser`), - `--prune`, `--limit`. -5. `app/api/docs.py` — `GET /api/docs` → `{"documents": [DocSummary]}` - (include `chunks` count via `func.count`); mount in `app/main.py` - **before** the static mount. -6. `frontend/assets/sources.js` + `sources.html` polish — wire the real - endpoint (already scaffolded to expect this shape); keep the empty state. -7. Update `README.md` §Knowledge Base Import with the final commands + - exclusion list + "update your docs → re-run the script" workflow. - -## UI Verification -Compare `/sources.html` against the story's "UI Visualization & Structure": -stat cards `auto-fit minmax(170px,1fr)`; full-width table (≥85% container); -mono path column with `title` ellipsis; empty state with the exact command; -``, `scope="col"`, scroll wrapper -`role="region" tabindex="0"`. No CDN refs. Take a 1280px and 375px -screenshot pass before finishing. - -## Testing & Quality -- Unit: chunker (heading splits, overlap, short-doc single chunk, code - fences kept intact), exclusion walk (temp tree with `.venv` junk), - delta logic (unchanged/changed/pruned via tmp Postgres or in-memory fakes - — real DB preferred since compose runs locally). -- Integration: `GET /api/docs` empty shape + populated shape; importer - end-to-end against `tests/fixtures/docs/` into a test schema. -- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%** - on `app/` (importer + chunker + client are the bulk; test them hard). - -## Playwright Execution Phase -Run ONLY this story's suite (DB must be up: `podman compose up -d db`): - -```bash -uv run pytest tests/e2e/test_import_documents.py -v --no-cov -``` - -The test file implements the story's Playwright Mapping Rule (seed via the -import function against `tests/fixtures/docs/` with the mock LLM; assert -Sources page rows, layout width, and the empty state). - -## Success criteria -- [ ] `uv run python -m scripts.import_docs` (fixtures) imports all 3 docs, - re-run reports `unchanged` -- [ ] `GET /api/docs` + Sources page show the docs (real run: `~/Homelab` - + `~/Deployments` counts logged) -- [ ] unit + integration green, coverage >90% -- [ ] UI verification passed (screenshots attached to the phase record) -- [ ] story E2E green in isolation -- [ ] README import section updated -- [ ] committed - -## Commit -```bash -git add -A && git commit --no-gpg-sign -m "feat(kb): markdown importer with sha256 deltas, chunking, batched embeddings, and Sources page" -``` diff --git a/.agent/phases/complete/03_story_chat_rag.md b/.agent/phases/complete/03_story_chat_rag.md deleted file mode 100644 index 1d9bb80..0000000 --- a/.agent/phases/complete/03_story_chat_rag.md +++ /dev/null @@ -1,74 +0,0 @@ -# Phase 03 — Story: Chat RAG Answer (happy path) - -**Story:** `.agent/user_stories/chat-rag-answer.md` -**Context:** `.agent/PLAN.md` §3 (data flow), §4 (SSE contract), §6 (persona), §9 (logging) - -## Goal -The core product loop: question → embed → cosine top-4 → full top-2 -documents → `turbo` (streamed) → chippy grounded answer with source chips. - -## Implementation steps -1. `app/rag/retriever.py` — `retrieve(db, question_embedding) -> - list[RetrievedChunk]` (score = 1 − distance, `ORDER BY embedding <=> $1 - LIMIT BOR_TOP_K_CHUNKS`) + `select_documents(chunks, n) -> list[Document]` - (distinct by `document_id`, ranked by best chunk score, cap content at - `BOR_MAX_CONTEXT_CHARS` with `[…truncated…]`). -2. `app/rag/prompts.py` — locked persona + HONESTY GATE prompt builder - (PLAN §6 verbatim, `HIGH|LOW`, `` - block; LOW mode includes the `DEFLECT_MODE` marker + weak-hit titles). -3. `app/rag/llm.py` — add `chat_stream(messages) -> AsyncIterator[str]` - (openai async, `stream=True`, `model=turbo`, temperature 0.4, - max_tokens ~700). -4. `app/api/chat.py` — `POST /api/chat` (ChatRequest) → `StreamingResponse` - (SSE): emit `delta` events from the stream, then the `done` event - (deflected, sources, suggestions); insert `query_log` row (deflected= - false this phase); per-turn log line (PLAN §9); structured error events - (`{"type":"error","detail":…}`) on LLM/DB failure. -5. `frontend/assets/app.js` — replace the placeholder handler: `fetch` + - `ReadableStream` SSE parser; render deltas live into a brain bubble - (reuse the typing-indicator → streaming handoff); on `done`, append - `.source-chip`s under the bubble; on error, show the banner (full - state machine is Phase 06 — keep it simple-correct here). -6. Tune `settings.suggestions` if the real Homelab import revealed better - defaults (optional here; Phase 05 owns the chips). - -## UI Verification -Against the story's "UI Visualization & Structure": bubbles right/left -(brand vs surface, ≥4.5:1 text), avatar 🧠, source chips mono/brand-soft -with `source/path` and ellipsis, safe markdown (paste an answer containing -`` from the mock to prove it's escaped). Chat -column 46rem centered. 1280px + 375px screenshot pass. - -## Testing & Quality -- Unit: retriever ordering/dedup/cap (fake rows), prompt builder (HIGH - contains documents + `HIGH`, LOW contains `DEFLECT_MODE` + titles only, - persona rules present verbatim), SSE event serialization. -- Integration: `/api/chat` against the mock LLM with a seeded temp schema — - assert SSE delta sequence, `done` payload (sources non-empty, - deflected false), `query_log` row, error event when LLM unreachable. -- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%**. - -## Playwright Execution Phase -Run ONLY this story's suite: - -```bash -uv run pytest tests/e2e/test_chat_rag.py -v --no-cov -``` - -Implements the story mapping: streamed grounded answer + `kubernetes.md` -source chip + button recovery; DB `query_log` assertion; raw SSE shape -check via `httpx`. - -## Success criteria -- [ ] end-to-end: question → streamed chippy answer citing `kubernetes.md` -- [ ] `query_log` row per turn; per-turn log line in stdout -- [ ] LLM-down path shows error banner, no stuck button -- [ ] unit + integration green, coverage >90% -- [ ] UI verification passed -- [ ] story E2E green in isolation -- [ ] committed - -## Commit -```bash -git add -A && git commit --no-gpg-sign -m "feat(rag): stream grounded chat answers via pgvector cosine retrieval with source citations" -``` diff --git a/.agent/phases/complete/04_story_honest_deflection.md b/.agent/phases/complete/04_story_honest_deflection.md deleted file mode 100644 index 2162edd..0000000 --- a/.agent/phases/complete/04_story_honest_deflection.md +++ /dev/null @@ -1,66 +0,0 @@ -# Phase 04 — Story: Honest Deflection - -**Story:** `.agent/user_stories/honest-deflection.md` -**Context:** `.agent/PLAN.md` §4, §6 (honesty gate), §9 - -## Goal -When retrieval finds nothing relevant, Brain says so — plainly, chippily — -and offers real alternatives. No hallucinated confidence. - -## Implementation steps -1. `app/api/chat.py` — apply the gate: `best_score < settings.relevance_ - threshold` ⇒ build LOW prompt (`DEFLECT_MODE`, weak-hit titles only), - else HIGH prompt. Set `deflected` on the `done` event + `query_log`. -2. Deflection `suggestions[]`: ask `turbo` (same stream) to include 2–3 - alternative questions; simplest robust approach — have the LLM emit them - inline in the answer AND have the server derive 2–3 chips from the - weak-hit document titles (deterministic fallback if the model doesn't - produce a parsable list). Ship the deterministic title-derived chips as - the v1 behavior; model-generated list is a bonus if trivially parseable. -3. `frontend/assets/app.js` — on `done.deflected`: add `.is-deflected` - class to the bubble, render "Maybe try:" chips below it (same - `.suggestion-chip` component; clicking fills the input — full submit - behavior lands with Phase 05's chip component; wire what exists). -4. `README.md` — document `BOR_RELEVANCE_THRESHOLD` tuning + the - deflection behavior in Troubleshooting. - -## UI Verification -Against the story: amber bubble (`#fff7e8` bg / `#f59e0b` border) distinct -from normal answers; "Maybe try:" chips ≥44px, brand-soft/brand-ink; -contrast pairs verified (ink on accent-bg ≥ 9:1, accent-ink ≥ 8:1); -chip group has an accessible name; mobile wraps cleanly. - -## Testing & Quality -- Unit: gate boundary with a fake retriever — score exactly 0.30 → HIGH; - 0.2999 → LOW; LOW prompt contains `DEFLECT_MODE` + titles, no full docs; - HIGH unaffected. Suggestions derivation (2–3, non-empty, derived from - titles). -- Integration: mock LLM — off-topic question ("sourdough") ⇒ `done` - `deflected: true`, `query_log.deflected=true`, weak `top_score` stored; - on-topic question ⇒ `deflected: false`. -- Coverage: **>90%** on `app/`. - -## Playwright Execution Phase -Run ONLY this story's suite: - -```bash -uv run pytest tests/e2e/test_honest_deflection.py -v --no-cov -``` - -Implements the story mapping: off-topic question ⇒ `.is-deflected` bubble -matching /haven't done anything like that/i + ≥2 "Maybe try:" chips; chip -click behavior; (unit boundary test lives in pytest, not here). - -## Success criteria -- [ ] off-topic question never gets a confident fake answer -- [ ] deflected bubble visually distinct + alternative chips render -- [ ] `query_log.deflected` accurate; threshold env-tunable -- [ ] unit + integration green, coverage >90% -- [ ] UI verification passed -- [ ] story E2E green in isolation -- [ ] committed - -## Commit -```bash -git add -A && git commit --no-gpg-sign -m "feat(rag): honest deflection gate with amber UI state and alternative-question chips" -``` diff --git a/.agent/phases/complete/05_story_suggestion_chips.md b/.agent/phases/complete/05_story_suggestion_chips.md deleted file mode 100644 index cc8a05d..0000000 --- a/.agent/phases/complete/05_story_suggestion_chips.md +++ /dev/null @@ -1,59 +0,0 @@ -# Phase 05 — Story: Suggestion Chips - -**Story:** `.agent/user_stories/suggestion-chips.md` -**Context:** `.agent/PLAN.md` §7 (UI/UX), story file for chip spec - -## Goal -Zero-friction onboarding: 3–4 real example questions on first load, -clickable → filled → submitted, keyboard-first, mobile-scrollable. - -## Implementation steps -1. `app/config.py` — confirm `suggestions` is env-overridable - (`BOR_SUGGESTIONS` as JSON list via pydantic-settings) and tune the - defaults against the *actually imported* Homelab/Deployments topics - (read a sample of `documents` titles; pick questions real answers - exist for). -2. `app.js` — extract a `renderChips(container, items, {onSelect})` helper; - real ` + + + diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..2ecd1d3 --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,62 @@ + + + + + + + Sign in · Brain of Reese + + + + + + + +
+
+ + + Brain of Reese + + +
+
+ +
+ +
+ + + + + + diff --git a/frontend/sources.html b/frontend/sources.html index c55cf3d..c1c665a 100644 --- a/frontend/sources.html +++ b/frontend/sources.html @@ -34,6 +34,21 @@

+ + +
– 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