feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public

This commit is contained in:
2026-08-23 19:58:39 -04:00
parent fc0d9a2d5c
commit cbc263a4b2
46 changed files with 1555 additions and 691 deletions
+38
View File
@@ -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)
+10
View File
@@ -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"],
+326
View File
@@ -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 <label for=…>).
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)
+4 -1
View File
@@ -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)
+4 -2
View File
@@ -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(
"""() => {
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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)
+18
View File
@@ -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
+8 -4
View File
@@ -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(
+7 -5
View File
@@ -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)
+11 -3
View File
@@ -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)
+8
View File
@@ -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")