"""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() # Phase 34 task 02: the shared header module rewrites the static # ?next= fallback to the CURRENT pathname ("return to where you # were") — on the chat page that is "/" (the markup keeps # ?next=/sources.html as the no-JS fallback only). expect(page.locator("#sign-in-link")).to_have_attribute("href", "/login.html?next=/") 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