feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
This commit is contained in:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+74 -2
View File
@@ -1,10 +1,18 @@
"""Shared Playwright auth helper (phase 16).
"""Shared Playwright auth helpers (phase 16 + phase 79).
``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).
``login_with_token`` (phase 79) drives the REAL in-app token gate on the
shell the same way (fill → submit): pass an admin-issued token for the
success contract (the gate hides, the app is interactive); call it with
the default all-zeros sentinel — or ``expect_error=True`` for a token
that USED to be valid (revocation) — for the wrong-token contract
(``#auth-gate-error`` role=alert visible, the gate stays, still
anonymous). Task 07's story suite is the first consumer.
"""
from __future__ import annotations
@@ -14,6 +22,53 @@ from e2e.conftest import ADMIN_PASSWORD # noqa: F401 (re-exported for tests)
DEFAULT_NEXT = "/sources.html"
#: The wrong-token sentinel (phase 79): the ``bor_`` prefix plus 32 zero
#: hex chars. The admin issues ``bor_`` + ``secrets.token_hex(16)``, so
#: an all-zeros token is never a live one — the DEFAULT call drives the
#: wrong-token contract without needing a real token at all.
WRONG_TOKEN = "bor_" + "0" * 32
def login_with_token(
page: Page,
app_url: str,
token: str = WRONG_TOKEN,
next: str = "/",
expect_error: bool | None = None,
) -> None:
"""Drive the real in-app token gate (phase 79) on the shell.
``page.goto`` the shell — the test contexts are fresh, so no
``bor.token`` is cached in localStorage and ``#auth-gate`` is the
visible surface — then fill ``#auth-gate-input`` with ``token`` and
submit the gate's form:
* success (an explicitly-passed valid, unrevoked token — with
``expect_error`` left ``None`` the branch is derived from the
all-zeros ``WRONG_TOKEN`` sentinel) → ``#auth-gate`` hides and
the app is interactive (the composer is reachable) at ``next``;
* wrong / revoked (``expect_error=True``, or the sentinel default)
→ ``#auth-gate-error`` (role=alert) is visible, the gate stays
visible — the visible gate IS the anonymity proof, the UI mirror
of ``login``'s wrong-password branch — and the visitor is still
anonymous.
"""
page.goto(app_url + next)
gate = page.locator("#auth-gate")
expect(gate).to_be_visible(timeout=30_000)
page.fill("#auth-gate-input", token)
page.click("#auth-gate-form button")
error_expected = token == WRONG_TOKEN if expect_error is None else expect_error
if error_expected:
error = page.locator("#auth-gate-error")
expect(error).to_have_attribute("role", "alert")
expect(error).to_be_visible(timeout=15_000)
expect(gate).to_be_visible() # the gate is the anonymity proof
return
expect(gate).to_be_hidden(timeout=30_000)
expect(page.locator("#message-input")).to_be_visible() # app is interactive
expect(page).to_have_url(app_url + next)
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.
@@ -22,13 +77,30 @@ def login(page: Page, app_url: str, password: str | None = None, next: str | Non
→ redirects to ``next`` (default ``/sources.html``);
* wrong password → ``#login-error`` (role=alert) is visible, the URL
never changes, and the visitor is still anonymous.
Idempotent (phase 79): in a context that is ALREADY signed in the
login page boots straight to ``next`` (login.js's whoami redirect —
no form) and the call returns as a success; a test may sign in once
per context and call it again from a later section without breaking.
"""
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()
if attempt == ADMIN_PASSWORD:
# Phase 79 (task 04): the helper must be IDEMPOTENT — a context
# that is ALREADY signed in never sees the form (login.js's
# boot whoami redirect goes straight to `next`), and a test that
# signs in twice (two sections, one context) must not break on
# the second call.
try:
expect(page.locator("#login-password")).to_be_visible(timeout=2_000)
except AssertionError:
expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
return
else:
expect(page.locator("#login-password")).to_be_visible()
page.fill("#login-password", attempt)
page.click("#login-form button[type=submit]")
if attempt != ADMIN_PASSWORD:
+57 -20
View File
@@ -10,12 +10,19 @@ 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``
1. ``test_anonymous_chat_gated_no_tuning``
2. ``test_anonymous_sources_and_viewer_gated``
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``
Phase 79 (API tokens): the anonymous pins moved to the gated contract —
``POST /api/chat`` and ``GET /api/documents/content`` are
``require_user`` (401 ``authentication required`` for anonymous; the
phase-16 "the viewer stays open" soft rule is SUPERSEDED, shared chats
are the only open surface). The password sign-in / sign-out /
wrong-password assertions are UNCHANGED.
"""
from __future__ import annotations
@@ -37,7 +44,6 @@ 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"
@@ -88,14 +94,12 @@ def _ask(page: Page, question: str) -> None:
# ---------------------------------------------------------------------------
# 1. Anonymous: chat works, the tuning UI is gone, Sign in is offered
# 1. Anonymous: chat is GATED (phase 79), 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)
def test_anonymous_chat_gated_no_tuning(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
@@ -108,31 +112,48 @@ def test_anonymous_chat_without_tuning(
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)
# Phase 79: the phase-16 "anonymous chat still streams" pin is
# SUPERSEDED — POST /api/chat is require_user-gated and the
# anonymous browser's own fetch gets the 401 contract (the in-app
# token gate that locks this UI is task 05's surface; the API
# contract is the stable half of the pin).
anon_chat = page.evaluate(
"""async () => {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'hello?'}),
});
return {status: r.status, body: await r.json()};
}"""
)
assert anon_chat["status"] == 401, anon_chat
assert anon_chat["body"] == {"detail": "authentication required"}, anon_chat
# …but the tuning UI is completely gone: no Tune button (new or
# …and the server agrees the visitor is anonymous.
who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
assert who == {"authenticated": False, "role": "anonymous"}
# 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.
# A reload 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)
# 2. Anonymous: Sources gated AND the document viewer's DATA is gated
# (phase 79 supersedes the phase-16 soft rule)
# ---------------------------------------------------------------------------
def test_anonymous_sources_gated_viewer_open(
def test_anonymous_sources_and_viewer_gated(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
@@ -160,10 +181,26 @@ def test_anonymous_sources_gated_viewer_open(
# …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.
# Phase 79 (task 05): the phase-16 soft rule ("any seeded document
# still opens by direct URL") is SUPERSEDED — the content endpoint
# is require_user-gated. The page DOCUMENT still loads (anonymous
# gets the HTML), but the GATED DATA does not: the API refuses
# with 401, and the viewer shows the inline token gate
# (#doc-auth-gate) instead of a content error — the content fetch
# never runs, so no not-found card either.
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()
expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#doc-not-found")).to_be_hidden()
expect(page.locator("#doc-title")).to_have_text("Loading…")
anon_content = page.evaluate(
"""async () => {
const r = await fetch(
'/api/documents/content?source=docs&path=homelab%2Fkubernetes.md');
return {status: r.status, body: await r.json()};
}"""
)
assert anon_content["status"] == 401, anon_content
assert anon_content["body"] == {"detail": "authentication required"}, anon_content
# ---------------------------------------------------------------------------
+5 -4
View File
@@ -73,6 +73,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, QueryLog
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
# --------------------------------------------------------------------------
@@ -358,7 +359,7 @@ def test_marker_question_lists_reads_and_quotes(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, MARKER_QUESTION)
@@ -446,7 +447,7 @@ def test_tool_lines_re_render_after_reload(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_submit(page, MARKER_QUESTION)
_wait_settled(page)
@@ -483,7 +484,7 @@ def test_plain_grounded_question_has_no_tool_frames(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, PLAIN_QUESTION)
@@ -518,7 +519,7 @@ def test_deflected_question_has_no_tool_frames(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, DEFLECT_QUESTION)
+5 -4
View File
@@ -85,6 +85,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, QueryLog
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
# --------------------------------------------------------------------------
@@ -392,7 +393,7 @@ def test_multi_read_turn(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, MULTI_QUESTION)
@@ -474,7 +475,7 @@ def test_done_sources_include_reads(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, MULTI_QUESTION)
@@ -513,7 +514,7 @@ def test_relist_allowed(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, MULTI_QUESTION)
@@ -556,7 +557,7 @@ def test_single_tool_flow_regression(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, SINGLE_QUESTION)
+676
View File
@@ -0,0 +1,676 @@
"""Phase 79 E2E (Playwright): API tokens — the owner's sentence, pinned
in a real browser.
TODO.md L5 (owner 2026-09-06): "Add api tokens that the admin can
generate and hand out so people can log in to use the app. The only
thing that should be accessible without an API token is shared chats.
The web ui should ask for a token before letting a user through and
should cache that token in browser storage so they don't have to keep
entering it."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_api_tokens.py -v --no-cov
Test → claim mapping (every clause of the owner's sentence is pinned):
1. ``test_anonymous_is_locked_out`` — "ask for a token before letting a
user through": a fresh context meets ``#auth-gate`` on the chat
page, ``#main`` is inert (the composer is NOT keyboard-reachable —
the inverted tab-order walk), and the three gated endpoints
(chat / suggestions / document content) 401 from the context's own
empty cookies.
2. ``test_shared_chats_stay_open_anonymous`` — "the only thing that
should be accessible without an API token is shared chats": the
admin creates + shares a saved chat (the house API pattern), a
FRESH context opens ``/shared/<token>`` anonymously and sees the
conversation rendered — no gate anywhere on that page.
3. ``test_admin_generates_token_in_ui`` — "the admin can generate":
the admin navigates to the Tokens view, labels a token
"e2e-alice" and generates — ``#token-once-value`` carries
``bor_`` + 32 hex (the A4 plaintext-once), the table shows the
Active row, and the once-block is GONE on a re-show (the plaintext
can never be re-shown).
4. ``test_token_user_uses_the_app`` — "hand out so people can log in
and use the app": a fresh context signs in through the real gate
(the task-04 ``login_with_token`` helper), chats end-to-end
(mock LLM), opens a cited document in the same-page modal, and
gets the role-``user`` header contract — every admin nav link
absent, Sign out visible.
5. ``test_cached_token_survives_reload`` — "cache that token in
browser storage so they don't have to keep entering it": the
entered token lands in ``localStorage["bor.token"]``; a reload
re-auths silently — no gate, no re-entry, still role user.
6. ``test_admin_only_walls_403_for_token_user`` — "every existing
admin-only surface stays admin-only": the token user's own session
cookie 403s on tokens / chats / docs / steering / git-sources.
7. ``test_sign_out_clears_the_cached_token`` — sign out clears the
session AND the cached token (one logout, both gone); the gate
comes back.
8. ``test_revocation_closes_the_door`` — "revocation is enforced
IMMEDIATELY": the admin revokes through the UI two-step; the
holder's next gated request 401s (the 401 clears the dead
session cookie — the next whoami is anonymous), and a FRESH login
attempt with the same token is refused at the gate.
9. ``test_wrong_token_is_one_generic_error`` — no enumeration: a
wrong token shows the gate's role=alert line and keeps the
visitor anonymous; the API's error body for a malformed token is
byte-equal to the one for a well-formed unknown token.
DB isolation: the shared e2e Postgres keeps ``api_tokens`` (and
``saved_chats``) rows across suites. This file is the only suite that
issues tokens, so an autouse fixture deletes the ``e2e-``-labeled
rows before each test (never a TRUNCATE — the shared DB may hold the
owner's real tokens); the shared-chat test deletes its own saved row
in a ``finally``. Every scenario runs in its OWN fresh browser
context — no cached token (localStorage) or session cookie leaks
between tests.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
import pytest
from playwright.sync_api import Browser, BrowserContext, 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 login, login_with_token
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The plaintext token's shape (owner-locked A4): prefix + 32 hex.
TOKEN_RE = re.compile(r"bor_[0-9a-f]{32}")
#: The five admin-only nav links (the role-`user` contract: ALL of
#: them stay absent for a token user — header.js reveals them only
#: for role === "admin").
ADMIN_NAV_LINKS = ("#nav-sources", "#nav-git-sources", "#nav-tuning", "#nav-history", "#nav-tokens")
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's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test
body.
"""
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 — deterministic
mock answers), then optionally re-import fixtures. ``saved_chats``
and ``api_tokens`` are deliberately NOT touched (the house
pattern)."""
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 _cleanup_e2e_tokens() -> None:
"""Delete this suite's issued tokens (deterministic re-runs).
Label-scoped on ``e2e-`` — never a TRUNCATE: the shared e2e DB is
also the dev DB and may hold the owner's real tokens.
"""
with SessionLocal() as db:
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
db.commit()
@pytest.fixture(autouse=True)
def _e2e_tokens_clean(db_ready: None) -> None:
"""Start every test from the same token-empty state (this file is
the only E2E suite that issues tokens)."""
_cleanup_e2e_tokens()
def _ask(page: Page, question: str) -> None:
"""Send one turn and wait until the grounded answer has fully
landed (the ``done`` event restored the Send button)."""
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")
def _cookies(page: Page) -> dict[str, str]:
"""The session cookies the browser context holds (the test's API
side sees exactly what that browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _create_token(app_url: str, cookies: dict[str, str], label: str) -> tuple[str, str]:
"""Admin-issued token through the house API pattern (httpx + the
signed admin cookie): ``POST /api/tokens`` → 201 (the ONE response
that carries the plaintext, A4). Returns (row id, plaintext)."""
r = httpx.post(f"{app_url}/api/tokens", json={"label": label}, cookies=cookies, timeout=10)
assert r.status_code == 201, r.text
body = r.json()
assert body["label"] == label
assert TOKEN_RE.fullmatch(body["token"]), f"bad token shape: {body}"
return body["id"], body["token"]
def _whoami(page: Page) -> dict[str, Any]:
"""The page's own ``/api/whoami`` read (the context's cookies)."""
return page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
# ---------------------------------------------------------------------------
# 1. Anonymous is locked out: the gate is up, #main is inert, and the
# three app surfaces 401 (only the shared chats are open — test 2)
# ---------------------------------------------------------------------------
def test_anonymous_is_locked_out(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
js_errors: list[str] = []
page.on("pageerror", lambda e: js_errors.append(str(e)))
page.goto(app_url)
# The gate is the visible surface of an anonymous chat page…
gate = page.locator("#auth-gate")
expect(gate).to_be_visible(timeout=30_000)
expect(gate).to_have_attribute("aria-labelledby", "auth-gate-title")
expect(page.locator("#auth-gate-input")).to_be_focused() # the gate takes the focus
expect(page.locator("#auth-gate-form button[type=submit]")).to_be_visible()
expect(page.locator("#auth-gate-error")).to_have_attribute("role", "alert")
# …and it LOCKS the app: #main is inert while the gate is up, so
# the composer cannot be reached — not by mouse, not by keyboard.
assert page.evaluate("() => document.getElementById('main').inert === true")
# Inverted tab-order walk (the test_suggestion_chips keyboard
# walk, inverted): from the page start, Tab cycles the gate and
# the sign-in link only — the composer is never a tab stop.
seen: list[str] = []
for _ in range(12):
page.keyboard.press("Tab")
seen.append(
page.evaluate(
"() => (document.activeElement && document.activeElement.id) || ''"
)
)
assert "auth-gate-input" in seen, f"the gate input must be keyboard-reachable: {seen}"
assert "message-input" not in seen, (
f"the composer must NOT be keyboard-reachable while the gate is up: {seen}"
)
# The API agrees, from the context's own (empty) cookies: the
# three app surfaces all refuse with ONE 401 detail.
anon_chat = page.evaluate(
"""async () => {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'hello?'}),
});
return {status: r.status, body: await r.json()};
}"""
)
assert anon_chat["status"] == 401, anon_chat
assert anon_chat["body"] == {"detail": "authentication required"}, anon_chat
anon_sugg = page.evaluate(
"""() => fetch('/api/suggestions')
.then((r) => r.json().then((body) => ({status: r.status, body})))"""
)
assert anon_sugg["status"] == 401, anon_sugg
assert anon_sugg["body"] == {"detail": "authentication required"}, anon_sugg
anon_doc = page.evaluate(
"""async () => {
const r = await fetch(
'/api/documents/content?source=docs&path=homelab%2Fkubernetes.md');
return {status: r.status, body: await r.json()};
}"""
)
assert anon_doc["status"] == 401, anon_doc
assert anon_doc["body"] == {"detail": "authentication required"}, anon_doc
# The gated boot itself must be crash-free.
assert not js_errors, f"the gated boot must not throw: {js_errors}"
# ---------------------------------------------------------------------------
# 2. Shared chats stay open: the ONLY anonymous content (the owner's
# sentence) — a fresh context reads the shared conversation with no
# gate anywhere
# ---------------------------------------------------------------------------
def test_shared_chats_stay_open_anonymous(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# As admin: create + share a saved chat (the house API pattern
# from test_share_chat — httpx with the signed session cookie).
q = "How is my Kubernetes cluster set up? (api-tokens-shared)"
r = httpx.post(
f"{app_url}/api/chats",
json={
"messages": [
{"who": "user", "text": q},
{"who": "brain", "text": "Deterministic mock answer for E2E (api-tokens-shared)"},
]
},
cookies=_cookies(page),
timeout=10,
)
assert r.status_code == 201, r.text
chat_id: str = r.json()["id"]
r = httpx.post(f"{app_url}/api/chats/{chat_id}/share", cookies=_cookies(page), timeout=10)
assert r.status_code == 200, r.text
share_url: str = r.json()["share_url"]
assert share_url.startswith("/shared/"), share_url
title = " ".join(q.split())[:120] # the auto-title convention
anon_ctx: BrowserContext | None = None
try:
# The anonymous JSON snapshot is open (no session at all)…
snap = httpx.get(f"{app_url}/api{share_url}", timeout=10)
assert snap.status_code == 200, snap.text
assert snap.json()["title"] == title
# …and the shared PAGE renders the conversation in a FRESH
# context (no cookies, no cached token) — with no gate
# anywhere on that page: shared chats are the anonymous
# surface, full stop.
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
expect(anon.locator("#shared-title")).to_have_text(title)
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
expect(anon.locator(".msg.brain .bubble")).to_contain_text("api-tokens-shared")
expect(anon.locator(".auth-gate")).to_have_count(0)
expect(anon.locator("#auth-gate, #doc-auth-gate")).to_have_count(0)
# The guest header offers sign-in (the shared page contract).
expect(anon.locator("#sign-in-link")).to_be_visible(timeout=15_000)
finally:
if anon_ctx is not None:
anon_ctx.close()
httpx.delete(f"{app_url}/api/chats/{chat_id}", cookies=_cookies(page), timeout=10)
# ---------------------------------------------------------------------------
# 3. The admin generates a token in the UI: the plaintext appears
# EXACTLY ONCE and the Active row lands in the table; the
# once-block is gone on a re-show
# ---------------------------------------------------------------------------
def test_admin_generates_token_in_ui(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
# The Tokens view is the shell's sixth nav link (the phase-76
# fold) — revealed for the admin, hidden for everyone else.
expect(page.locator("#nav-tokens")).to_be_visible(timeout=15_000)
page.click("#nav-tokens")
expect(page.locator("#view-tokens")).to_be_visible()
expect(page.locator("#tokens-gate")).to_be_hidden() # admin: no sign-in gate
expect(page.locator("#token-create")).to_be_visible(timeout=15_000)
# Generate: label "e2e-alice" → the plaintext appears exactly
# once, in the mono read-only field (the A4 shown-once contract).
page.fill("#token-label", "e2e-alice")
page.click("#token-generate")
expect(page.locator("#token-once")).to_be_visible(timeout=15_000)
token = page.input_value("#token-once-value")
assert TOKEN_RE.fullmatch(token), f"bad token shape: {token!r}"
expect(page.locator("#tokens-status")).to_have_text(
"Token created — copy it now; it won't be shown again."
)
# The table shows the Active row: the em-dash status marker, no
# Revoked pill, a Revoke action.
row = page.locator("#tokens-tbody tr", has_text="e2e-alice")
expect(row).to_have_count(1)
expect(row.locator("td.tokens-label-cell")).to_have_text("e2e-alice")
expect(row.locator("td").nth(3)).to_have_text("—") # Active = the plain em-dash
expect(row.locator(".stale-pill")).to_have_count(0)
expect(row.locator("button.token-revoke")).to_have_count(1)
# The once-block is NOT re-shown on a re-show: nav away (RAG) and
# back — the router's re-show refresh re-runs the list load,
# which hides + wipes the once-block. The plaintext is gone.
page.click("#nav-sources")
expect(page.locator("#view-rag")).to_be_visible()
page.go_back()
expect(page.locator("#view-tokens")).to_be_visible(timeout=15_000)
expect(page.locator("#token-once")).to_be_hidden()
assert page.input_value("#token-once-value") == "", "the plaintext must be wiped on re-show"
# The row survives the re-render (the token itself is unaffected).
expect(page.locator("#tokens-tbody tr", has_text="e2e-alice")).to_have_count(1)
# ---------------------------------------------------------------------------
# 4. The token flow: a fresh context signs in through the real gate
# and USES the app — a grounded chat turn (mock LLM), a cited
# document opened in the same-page modal, the role-user header
# ---------------------------------------------------------------------------
def test_token_user_uses_the_app(
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(mock_llm, seed=True) # a deterministic seeded KB for the grounded turn
# The admin generates + hands out the token (fresh context per
# role — the admin's browser is never the holder's browser).
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-bob")
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
# The holder signs in through the REAL in-app gate (the
# task-04 helper: fill #auth-gate-input → submit → gate hides).
login_with_token(user, app_url, token)
# Use the app: a grounded turn against the seeded KB (mock
# LLM) — the brain bubble renders the deterministic answer.
_ask(user, "How is my Kubernetes cluster set up? (api-tokens-flow)")
# A cited source chip opens the document in the SAME-PAGE
# modal (the require_user content endpoint passes for a
# live token session).
chip = user.locator(".msg.brain a.source-chip").first
expect(chip).to_be_visible(timeout=15_000)
chip.click()
expect(user.locator("#doc-modal")).to_be_visible()
expect(
user.locator("#doc-modal-content .doc-md, #doc-modal-content pre.doc-raw")
).to_have_count(1, timeout=15_000) # the document rendered (not the loading line)
expect(user.locator("#doc-modal-content .doc-modal-loading")).to_have_count(0)
expect(user.locator("#doc-modal-title")).not_to_be_empty()
expect(user.locator("#doc-modal-title")).not_to_have_text("Loading…")
expect(user.locator("#doc-modal-title")).not_to_have_text("Document not found")
# The role-`user` header contract: ALL five admin nav links
# are absent (they reveal only for role === "admin"), and the
# auth pair is the signed-in branch (Sign out, no Sign in).
for link in ADMIN_NAV_LINKS:
expect(user.locator(link)).to_be_hidden()
expect(user.locator("#sign-in-link")).to_be_hidden()
expect(user.locator("#sign-out-btn")).to_be_visible()
# The server agrees: authenticated, role user — NOT admin.
who = _whoami(user)
assert who == {"authenticated": True, "role": "user"}
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 5. Caching: the token lands in localStorage and a reload re-auths
# silently — no gate, no re-entry
# ---------------------------------------------------------------------------
def test_cached_token_survives_reload(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-cache")
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
# The gate's success path caches the entered token in
# localStorage (the owner's sentence: "cache that token in
# browser storage").
login_with_token(user, app_url, token)
assert user.evaluate("() => localStorage.getItem('bor.token')") == token
# A reload re-auths SILENTLY from the cache: no gate, no
# re-entry — the chat UI is interactive straight away, the
# lock is released, the role is still user.
user.reload()
expect(user.locator("#auth-gate")).to_be_hidden(timeout=30_000)
expect(user.locator("#message-input")).to_be_visible()
assert user.evaluate("() => document.getElementById('main').inert === false")
assert _whoami(user) == {"authenticated": True, "role": "user"}
# The cache is what re-authed the page — it survived the reload.
assert user.evaluate("() => localStorage.getItem('bor.token')") == token
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 6. The admin-only walls: every admin surface 403s the token user's
# own session cookie (require_admin, unchanged)
# ---------------------------------------------------------------------------
def test_admin_only_walls_403_for_token_user(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-wall")
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
login_with_token(user, app_url, token)
cookies = _cookies(user)
# The token user's OWN signed session cookie 403s on every
# admin surface — "admin only" (the phase-16 contract),
# never a 401 (the user IS authenticated — just not an
# admin).
for method, url in (
("GET", "/api/tokens"),
("GET", "/api/chats"),
("GET", "/api/docs"),
("POST", "/api/steering"),
("GET", "/api/git-sources"),
):
r = httpx.request(
method,
app_url + url,
cookies=cookies,
timeout=10,
json={"note": "wall check"} if method == "POST" else None,
)
assert r.status_code == 403, (method, url, r.status_code, r.text)
assert r.json() == {"detail": "admin only"}, (method, url, r.text)
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 7. Sign out: the header binding clears the session AND the cached
# token — the gate comes back
# ---------------------------------------------------------------------------
def test_sign_out_clears_the_cached_token(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-signout")
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
login_with_token(user, app_url, token)
assert user.evaluate("() => localStorage.getItem('bor.token')") == token
# Sign out (the header binding): POST /api/logout + drop the
# cached token + reload — ONE logout clears both the server
# session and the localStorage key.
user.click("#sign-out-btn")
expect(user.locator("#auth-gate")).to_be_visible(timeout=30_000)
assert user.evaluate("() => localStorage.getItem('bor.token')") is None
assert _whoami(user) == {"authenticated": False, "role": "anonymous"}
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 8. Revocation closes the door: the admin's UI two-step kills the
# token immediately — the holder's next request 401s (and the
# 401 clears the dead session cookie), and a fresh login attempt
# with the same token is refused
# ---------------------------------------------------------------------------
def test_revocation_closes_the_door(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-revoke")
user_ctx: BrowserContext | None = None
fresh_ctx: BrowserContext | None = None
try:
# The holder is signed in (a fresh context of their own).
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
login_with_token(user, app_url, token)
# The admin revokes through the UI two-step (Revoke → Yes —
# the inline confirm, no native dialog).
page.goto(app_url + "/tokens.html")
row = page.locator("#tokens-tbody tr", has_text="e2e-revoke")
expect(row).to_have_count(1, timeout=15_000)
row.locator("button.token-revoke").click()
expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click()
revoked = page.locator("#tokens-tbody tr", has_text="e2e-revoke")
expect(revoked.locator(".stale-pill")).to_have_text("Revoked", timeout=15_000)
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-revoke".')
# Enforcement is IMMEDIATE on the holder's next request (the
# session stores the row id — no server-side session store;
# the live row check IS the revocation check). Driven with the
# USER context's own cookies (its real browser cookie jar —
# response headers land exactly as in a live browser):
#
# 1. whoami is LAZY by contract — the dead session still
# reports "user" (the endpoint does not live-check)…
assert _whoami(user) == {"authenticated": True, "role": "user"}
# 2. …but the next GATED request 401s — and the 401's
# Set-Cookie clears the dead session from the jar…
resp = user.evaluate(
"""async () => {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'hello?'}),
});
return {status: r.status, body: await r.json()};
}"""
)
assert resp["status"] == 401, resp
assert resp["body"] == {"detail": "authentication required"}, resp
# 3. …so from now on the holder is genuinely anonymous.
assert _whoami(user) == {"authenticated": False, "role": "anonymous"}
# A FRESH login attempt with the same (now revoked) token is
# refused at the gate: the error line shows, the gate stays,
# the visitor is still anonymous.
fresh_ctx = browser.new_context()
fresh = fresh_ctx.new_page()
fresh.set_default_timeout(30_000)
login_with_token(fresh, app_url, token, expect_error=True)
assert _whoami(fresh) == {"authenticated": False, "role": "anonymous"}
finally:
if user_ctx is not None:
user_ctx.close()
if fresh_ctx is not None:
fresh_ctx.close()
# ---------------------------------------------------------------------------
# 9. Wrong token: the gate's one generic error, no enumeration — the
# malformed and the well-formed-unknown failures are byte-equal
# ---------------------------------------------------------------------------
def test_wrong_token_is_one_generic_error(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
# The task-04 helper's default sentinel drives the wrong-token
# contract (bor_ + 32 zeros — never a live token): the role=alert
# error line shows, the gate stays, the visitor is anonymous.
login_with_token(page, app_url) # token defaults to the all-zeros sentinel
assert _whoami(page) == {"authenticated": False, "role": "anonymous"}
# No enumeration: the API's ONE generic 401 — the error body for
# a wrong-FORMAT token equals the one for a well-formed but
# unknown token (no shape hint on a credential endpoint).
malformed = httpx.post(
f"{app_url}/api/token-auth", json={"token": "not-a-token-at-all"}, timeout=10
)
unknown = httpx.post(
f"{app_url}/api/token-auth", json={"token": "bor_" + "0" * 32}, timeout=10
)
assert malformed.status_code == 401
assert unknown.status_code == 401
assert malformed.json() == unknown.json() == {"detail": "invalid token"}
+6 -1
View File
@@ -110,7 +110,6 @@ from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
@@ -118,6 +117,12 @@ from e2e.conftest import (
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): in a combined session run the
# conftest session app already owns its port — a second uvicorn on it
# dies on bind and this suite would silently drive the wrong server.
# The module app binds its own port instead (env-overridable).
APP_PORT = int(os.environ.get("E2E_APP_PORT_ARCHIVE", "8124"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The slow-LLM proxy's port (the conftest's mock LLM stays on MOCK_PORT).
-250
View File
@@ -1,250 +0,0 @@
"""Phase 22 E2E (Playwright): the background layers — now a regression
suite for the phase-25 no-motion design.
Story: ``.agents/user_stories/background-no-motion.md`` (the phase-25
owner direction supersedes this suite's original pins; the phase-22
history lives in ``.agents/user_stories/background-animation.md``)
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_background_animation.py -v --no-cov
Phase 22 proved the phase-08 background (grid drift + whole-layer
breathe) actually animated in a real Chromium viewport. The owner then
reported (2026-08-25, chat): the background "jitters down and to the
right every second and it slowly blinks brighter and darker. It should
be smooth, fluxuating, dimming and brightening, but not moving.
Different bright spots should slowly fade in and out." — so the
phase-25 redesign (``frontend/assets/styles.css``) removed the
movement entirely: the grid (``body::before``) is a STATIC texture
(``bg-grid-drift`` deleted), and three independent soft glow spots run
their own slow opacity-only fades — ``body::after`` runs ``bg-glow-a``
(26s), ``html::before`` runs ``bg-glow-b`` (34s, −12s delay),
``html::after`` runs ``bg-glow-c`` (42s, −23s delay).
This adapted suite now pins the phase-25 contract on the same
layers (the full story gate is
``tests/e2e/test_background_no_motion.py``): the grid is static, the
indigo spot runs ``bg-glow-a`` and the three spot timelines advance,
all four pseudo-layers keep the fixed/z-index −1/pointer-events
none/no-occlusion contract, and there is no 360px overflow.
Test → story mapping (Playwright Mapping Rule):
1. ``test_grid_layer_is_static`` — computed style of ``body::before``:
``animationName`` is ``"none"`` (the 0.73px/s drift is gone), no
``bg-grid-drift`` entry in the document animation list, and the
static grid ``backgroundImage`` is still painted.
2. ``test_glow_layer_animation_running`` — ``body::after`` runs
``bg-glow-a`` (26s, ease-in-out, infinite) with a matching entry in
the document animation list, ``playState === "running"``.
3. ``test_glow_timelines_advance`` — ``currentTime`` of all three spot
timelines sampled, ~500ms waited, all advanced — the fades are truly
running, not paused (headless Chromium starts the document
animation timeline ~1s after load, so the first sample polls until
the timelines are alive).
4. ``test_background_layers_contracts`` — all four pseudo-elements
(body ``::before``/``::after`` + the phase-25 ``html
::before``/``::after`` spots): ``position: fixed``, ``z-index: -1``,
``pointer-events: none``, ``inset: 0`` (UI Structure Check: behind
content, click-through, full-viewport); the page canvas stays on
``<html>`` (``rgb(15, 10, 10)`` = ``var(--bg)``) and ``<body>``
stays transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion
contract.
5. ``test_no_horizontal_overflow_with_layers`` — at a 360px viewport
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin,
replicated locally — the ``fixed; inset: 0`` layers must add no
width).
Chromium note: pseudo-element CSS animations are enumerated by
``document.getAnimations()``, NOT by ``document.body.getAnimations()``
(verified on Chromium 151 — the element-level list is empty for
pseudo-layers), so tests 1–3 match on ``animationName`` in the
document-level list. The two ``html`` pseudo-layers' computed styles
come from ``getComputedStyle(document.documentElement,
"::before"/"::after")``.
"""
from __future__ import annotations
import time
from playwright.sync_api import Browser, Page
GRID_OLD = "bg-grid-drift" # deleted in phase 25 — must not exist anywhere
GLOWS = ("bg-glow-a", "bg-glow-b", "bg-glow-c") # the three phase-25 spot fades
PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a)
# Computed styles of all four pseudo-layers + the html/body background
# contract (single evaluate — one round-trip per test).
JS_LAYER_REPORT = """() => {
const pick = (el, pseudo) => {
const cs = getComputedStyle(el, pseudo);
return {
anim: cs.animationName,
timing: cs.animationTimingFunction,
iterations: cs.animationIterationCount,
position: cs.position,
zIndex: cs.zIndex,
pointerEvents: cs.pointerEvents,
edges: [cs.top, cs.right, cs.bottom, cs.left],
image: cs.backgroundImage,
};
};
return {
grid: pick(document.body, "::before"),
glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
htmlBg: getComputedStyle(document.documentElement).backgroundColor,
bodyBg: getComputedStyle(document.body).backgroundColor,
};
}"""
# The background-layer animations from the Web Animations API
# ({name, playState, currentTime}); the keyframe names are passed as one
# array argument (Playwright serializes the Python list to a JS array).
JS_TIMELINE = """(names) => document.getAnimations()
.filter((a) => names.includes(a.animationName))
.map((a) => ({
name: a.animationName,
playState: a.playState,
t: a.currentTime,
}))"""
def _timeline(page: Page) -> dict[str, float]:
"""animationName → currentTime (ms) for the three glow-spot layers."""
entries = page.evaluate(JS_TIMELINE, list(GLOWS))
return {str(a["name"]): float(a["t"]) for a in entries}
def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None:
"""Poll until all three spot timelines report currentTime > 0.
Headless Chromium starts the document animation timeline shortly
after load (observed ≈1.4s after navigation) — until then
currentTime is 0, so the "did it advance?" sample in
``test_glow_timelines_advance`` must start once the timeline is
alive.
"""
deadline = time.monotonic() + timeout_ms / 1000
while time.monotonic() < deadline:
times = _timeline(page)
if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS):
return
page.wait_for_timeout(100)
raise AssertionError(
f"background animation timeline never started (saw {_timeline(page)!r})"
)
# --------------------------------------------------------------------------
# Tests (story → test mapping, see module docstring)
# --------------------------------------------------------------------------
def test_grid_layer_is_static(page: Page, app_url: str, db_ready: None) -> None:
"""Phase-25 AC1: the grid layer is STATIC in a real viewport —
``animationName`` is ``"none"``, no ``bg-grid-drift`` animation
exists, and the static grid texture is still painted (the owner
rejected the grid's motion, not the grid)."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
grid = report["grid"]
assert grid["anim"] == "none", (
f"body::before must be static (animationName, got {grid['anim']!r})"
)
live = page.evaluate(JS_TIMELINE, [GRID_OLD])
assert not live, (
f"no {GRID_OLD} animation may exist (got {live!r}) — the drift is deleted"
)
assert grid["image"] != "none", (
f"the static grid texture must still be painted (image {grid['image']!r})"
)
def test_glow_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None:
"""Phase-25 AC2: the indigo spot layer (body::after) runs
bg-glow-a — 26s, ease-in-out, infinite — in a real viewport: the
matching CSSAnimation is reported ``running``."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
glow = report["glowA"]
assert glow["anim"] == "bg-glow-a", (
f"body::after must run bg-glow-a (got {glow['anim']!r})"
)
assert glow["iterations"] == "infinite", (
f"body::after must fade forever (got {glow['iterations']!r})"
)
live = page.evaluate(JS_TIMELINE, list(GLOWS))
match = [a for a in live if a["name"] == "bg-glow-a"]
assert match, "no bg-glow-a entry in document.getAnimations() — spot not fading"
assert match[0]["playState"] == "running", (
f"bg-glow-a is {match[0]['playState']!r} — the spot fade must be running"
)
def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None:
"""Phase-25 AC2: all three spot timelines actually advance — the
background is a live animation, not a static (or paused) frame.
Sample currentTime, wait ~500ms, and require real progress on all
three layers."""
page.goto(app_url)
_wait_timelines_alive(page)
before = _timeline(page)
page.wait_for_timeout(500)
after = _timeline(page)
for name in GLOWS:
delta = after[name] - before[name]
assert delta >= 200, (
f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) "
"— paused or static?"
)
def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None:
"""Phase-25 AC4: UI Structure Check — ALL FOUR background
pseudo-layers (body ::before/::after + the phase-25 html
::before/::after spots) stay behind content (fixed, z-index -1,
pointer-events none, full-viewport) and nothing occludes them: the
page canvas is on <html>, <body> transparent."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
for key in ("grid", "glowA", "glowB", "glowC"):
info = report[key]
assert info["position"] == "fixed", f"{key} must stay position:fixed"
assert info["zIndex"] == "-1", (
f"{key} must stay behind content (z-index -1, got {info['zIndex']!r})"
)
assert info["pointerEvents"] == "none", (
f"{key} must stay click-through (pointer-events none)"
)
assert info["edges"] == ["0px", "0px", "0px", "0px"], (
f"{key} must stay full-viewport (inset: 0, got {info['edges']!r})"
)
assert report["htmlBg"] == PAGE_BG, (
f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})"
)
assert report["bodyBg"] == "rgba(0, 0, 0, 0)", (
f"body must stay transparent so the layers show (got {report['bodyBg']!r})"
)
def test_no_horizontal_overflow_with_layers(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""Phase-25 AC6: the background layers add no width — the phase-07
overflow pin (documentElement.scrollWidth <= clientWidth) still
holds at the 360px floor with all four fixed; inset: 0 layers
live."""
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
phone.goto(f"{app_url}/")
scroll, client = phone.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
assert scroll <= client, (
f"horizontal overflow at 360px with the background layers: "
f"{scroll} > {client}"
)
finally:
phone.close()
+151 -317
View File
@@ -1,119 +1,77 @@
"""Phase 25 E2E (Playwright): the background no longer moves — it only fades.
"""Phase 78 E2E (Playwright): the background is fully static.
Story: ``.agents/user_stories/background-no-motion.md`` (supersedes
``background-animation.md``)
Source: ``TODO.md`` L4 — owner direction: "Remove the animated css
background, it's too resource intensive" (supersedes the phase-25
fading-glow contract of the ``background-no-motion`` story; the
superseded chain is 08 → 25 → 78).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov
Owner report (2026-08-25, chat): the phase-22 background "jitters down
and to the right every second and it slowly blinks brighter and darker.
It should be smooth, fluxuating, dimming and brightening, but not
moving. Different bright spots should slowly fade in and out."
The three opacity-fading glow spots (``body::after`` / ``html::before``
/ ``html::after``), their glow keyframes, and the
``prefers-reduced-motion`` rule that stilled them are deleted from
``styles.css`` — the infinite CSS animations ran continuously on every
page, in every tab. The 44px grid texture (``body::before``) STAYS: it
is static (zero animation cost). All other UI animations (typing dots,
spinner, toasts, nav slide, …) are untouched.
The two root causes (phase-22 measurements in
``.agents/reports/22_background_animation/``):
- the "jitter" was the grid's 44px/60s drift (0.73px/s, diagonally
down-right) — a 1px line translated sub-pixel by sub-pixel
rasterizes with per-frame stepping, not smooth motion;
- the "blink" was the whole-layer 14s opacity 0.85↔1 + scale(1)↔
scale(1.05) pulse — one synchronized pulse reads as blinking.
The fix (styles.css, pure CSS, zero JS — A11): the grid (``body::before``)
is a STATIC texture (no animation, ``bg-grid-drift`` deleted), and three
independent soft glow spots each run their own SLOW opacity-only fade —
``body::after`` (phase-08 indigo) runs ``bg-glow-a`` 26s,
``html::before`` (phase-08 cyan) runs ``bg-glow-b`` 34s with −12s delay,
``html::after`` (a third indigo) runs ``bg-glow-c`` 42s with −23s delay.
The out-of-phase 26/34/42s cycles (LCM 4641s) make the total light
fluxuate smoothly and irregularly — no blink, no jitter, no movement.
This suite proves the *behavior* the unit source pins only describe, in
a real Chromium viewport: the grid is static, the three spots run
distinct opacity-only fades whose timelines advance and whose light
measurably changes, no ``bg-*`` keyframe animates anything but
``opacity`` (the deterministic no-movement proof), all four layers keep
the fixed/z-index −1/pointer-events-none/no-occlusion contract,
reduced motion stills all four, and there is no 360px overflow.
This suite proves the *behavior* the unit source pins
(``tests/unit/test_background_no_motion.py``) only describe, in a real
Chromium viewport: the three glow pseudo-elements report no
background-image, ``animation-name: none``, and no box at all (the
rules are gone), no ``bg-*`` keyframes or running ``bg-*`` animations
exist anywhere, the grid is still painted and static, the
no-occlusion canvas contract survives (``<html>`` owns ``var(--bg)``),
reduced motion changes nothing (already static), and there is no
360px overflow.
Test → story mapping (Playwright Mapping Rule):
1. ``test_grid_layer_is_static`` — computed ``animationName`` of
``body::before`` is ``"none"``; no ``bg-grid-drift`` entry in
``document.getAnimations()``; the grid ``backgroundImage`` is still
present (the static texture survives).
2. ``test_three_glow_layers_run_distinct_fades`` — ``body::after`` →
``bg-glow-a`` (26s), ``documentElement::before`` → ``bg-glow-b``
(34s), ``documentElement::after`` → ``bg-glow-c`` (42s); each
``ease-in-out`` + ``infinite`` with a matching
``playState === "running"`` entry in the document animation list;
the three durations are pairwise distinct.
3. ``test_no_motion_properties_in_background_keyframes`` — walks
``document.styleSheets``; across every frame of every ``bg-*``
``KEYFRAMES_RULE`` the set of declared property names is exactly
``{"opacity"}`` — no ``transform``/``background-position`` anywhere
(the deterministic no-movement proof).
4. ``test_glow_timelines_advance`` — poll until all three timelines
report ``currentTime > 0`` (headless Chromium starts the document
timeline ~1s after load), sample, wait ~500ms, each advanced
≥ 200ms.
5. ``test_background_light_actually_changes`` — (a) the computed
``opacity`` of ``body::after`` changes by ≥ 0.05 within ~8s (a real
fade, not a frozen frame); (b) two clipped screenshots ~4s apart of
the bottom-left glow region (the ``html::after`` spot at 14%/86%)
differ in bytes — the light visibly changes while nothing moves
(a fresh ``/`` page has no other animation, so the diff is the
background's).
6. ``test_background_layers_contracts`` — all four pseudo-layers:
``position: fixed``, ``z-index: -1``, ``pointer-events: none``,
top/right/bottom/left all ``0px``; the ``documentElement`` computed
background is ``rgb(15, 10, 10)`` (``var(--bg)`` — the canvas stays
on ``html``); ``document.body`` computed background is
``rgba(0, 0, 0, 0)`` (no occlusion).
7. ``test_reduced_motion_stills_all_layers`` —
``reduced_motion="reduce"`` context: all four pseudo-layers report
computed ``animationName`` ``"none"`` and still carry a
``backgroundImage`` (the static background remains visible).
8. ``test_no_horizontal_overflow_with_layers`` — 360px viewport:
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin).
Chromium notes (verified on Chromium 151, kept from the phase-22 suite):
pseudo-element CSS animations are enumerated by
``document.getAnimations()``, NOT by
``document.body.getAnimations()`` (the element-level list is empty for
pseudo-layers), so the running/advancing checks match on
``animationName`` in the document-level list. And the computed styles
of the two new ``html`` pseudo-layers come from
``getComputedStyle(document.documentElement, "::before")`` /
``("::after")`` — ``document.body`` only carries the two
``body`` pseudo-layers.
1. ``test_background_layers_computed_styles`` — computed styles:
``body::before`` keeps the grid (``backgroundImage`` non-empty,
``animationName: none``); ``body::after`` / ``html::before`` /
``html::after`` report ``backgroundImage: none`` +
``animationName: none`` + ``position: static`` + ``content: none``
(the pseudo-elements have no box — the rules are deleted, not just
stilled); the page canvas stays on ``<html>`` (``rgb(15, 10, 10)`` =
``var(--bg)``) and ``<body>`` stays transparent (no occlusion).
2. ``test_no_background_keyframes_or_animations`` — deterministic
static proof: no ``@keyframes`` rule with a ``bg-`` name in any
live stylesheet, and no running ``bg-*`` entry in
``document.getAnimations()`` (pseudo-element CSS animations are
enumerated by the document-level list, not the element-level one —
verified on Chromium 151).
3. ``test_grid_layer_is_fixed_behind_content`` — the surviving grid
layer keeps the UI-structure contract: ``position: fixed``,
``z-index: -1``, ``pointer-events: none``, ``inset: 0`` (behind
content, click-through, full-viewport).
4. ``test_reduced_motion_background_static`` — a
``reduced_motion="reduce"`` context: the grid is still painted and
static, the deleted glow layers still report no image/animation —
nothing is resurrected under reduced motion.
5. ``test_no_horizontal_overflow_with_layers`` — 360px viewport:
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin —
the background adds no width).
"""
from __future__ import annotations
import time
from playwright.sync_api import Browser, Page
from playwright.sync_api import Browser, FloatRect, Page
GLOW_A = "bg-glow-a" # body::after — phase-08 indigo spot, 26s
GLOW_B = "bg-glow-b" # html::before — phase-08 cyan spot, 34s, -12s delay
GLOW_C = "bg-glow-c" # html::after — third indigo spot, 42s, -23s delay
GLOWS = (GLOW_A, GLOW_B, GLOW_C) # the three spot keyframe names
EASE_IN_OUT = "ease-in-out" # computed animationTimingFunction for ease-in-out
PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a)
# Computed styles of all four pseudo-layers + the html/body background
# contract (single evaluate — one round-trip per test).
# Computed styles of the surviving grid layer + the three deleted glow
# pseudo-layers + the html/body background contract (single evaluate —
# one round-trip per test).
JS_LAYER_REPORT = """() => {
const pick = (el, pseudo) => {
const cs = getComputedStyle(el, pseudo);
return {
anim: cs.animationName,
duration: cs.animationDuration,
timing: cs.animationTimingFunction,
iterations: cs.animationIterationCount,
position: cs.position,
zIndex: cs.zIndex,
pointerEvents: cs.pointerEvents,
content: cs.content,
edges: [cs.top, cs.right, cs.bottom, cs.left],
image: cs.backgroundImage,
};
@@ -128,24 +86,12 @@ JS_LAYER_REPORT = """() => {
};
}"""
# The three background-layer animations from the Web Animations API
# ({name, playState, currentTime}); the keyframe names are passed as one
# array argument (Playwright serializes the Python list to a JS array).
JS_TIMELINE = """(names) => document.getAnimations()
.filter((a) => names.includes(a.animationName))
.map((a) => ({
name: a.animationName,
playState: a.playState,
t: a.currentTime,
}))"""
# Deterministic no-movement audit: walk every same-origin stylesheet and
# collect, for each @keyframes bg-* rule, the property names declared in
# every keyframe frame. Returns {names: [...], props: [...]} — props must
# be exactly ["opacity"].
JS_KEYFRAME_PROPS = """() => {
const names = [];
const props = new Set();
# Deterministic static audit: walk every same-origin stylesheet and
# collect the names of @keyframes rules starting with "bg-" (must be
# empty), plus the animationNames running in document.getAnimations()
# that start with "bg-" (must be empty).
JS_BG_ANIMATION_AUDIT = """() => {
const keyframeNames = [];
for (const sheet of document.styleSheets) {
let rules;
try {
@@ -155,236 +101,114 @@ JS_KEYFRAME_PROPS = """() => {
}
for (const rule of rules) {
if (rule.type === CSSRule.KEYFRAMES_RULE && rule.name.startsWith("bg-")) {
names.push(rule.name);
for (const frame of rule.cssRules) {
for (const p of frame.style) {
props.add(p);
}
}
keyframeNames.push(rule.name);
}
}
}
return { names, props: [...props] };
const running = document.getAnimations()
.map((a) => a.animationName)
.filter((n) => n && n.startsWith("bg-"));
return { keyframeNames, running };
}"""
def _seconds(value: str) -> float:
"""Chromium reports animation durations as "26s" — parse as seconds."""
return float(str(value).replace("s", ""))
def _timeline(page: Page) -> dict[str, float]:
"""animationName → currentTime (ms) for the three glow layers."""
entries = page.evaluate(JS_TIMELINE, list(GLOWS))
return {str(a["name"]): float(a["t"]) for a in entries}
def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None:
"""Poll until all three glow timelines report currentTime > 0.
Headless Chromium starts the document animation timeline shortly
after load (observed ≈1s after navigation) — until then currentTime
is 0, so the "did it advance?" sample in
``test_glow_timelines_advance`` must start once the timeline is
alive.
"""
deadline = time.monotonic() + timeout_ms / 1000
while time.monotonic() < deadline:
times = _timeline(page)
if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS):
return
page.wait_for_timeout(100)
raise AssertionError(
f"glow animation timelines never started (saw {_timeline(page)!r})"
)
# --------------------------------------------------------------------------
# Tests (story → test mapping, see module docstring)
# --------------------------------------------------------------------------
def test_grid_layer_is_static(page: Page, app_url: str, db_ready: None) -> None:
"""AC1: no movement — the grid layer (body::before) runs NO animation
in a real viewport (the phase-22 0.73px/s drift read as a
once-per-second down-right jitter), and its static texture is still
painted (the grid stays — the owner rejected its motion, not the
grid)."""
def test_background_layers_computed_styles(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC1: the animated background no longer exists in a real viewport —
the three glow pseudo-elements report no background-image and
``animation-name: none``, and they have no box at all
(``position: static`` / ``content: none`` — the CSS rules are
deleted, not merely stilled). The static grid (``body::before``)
stays: its texture is still painted and it carries no animation.
The no-occlusion contract survives: the canvas is on ``<html>``,
``<body>`` transparent."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
grid = report["grid"]
assert grid["anim"] == "none", (
f"body::before must be static (animationName, got {grid['anim']!r})"
)
live = page.evaluate(JS_TIMELINE, ["bg-grid-drift"])
assert not live, (
f"no bg-grid-drift animation may exist (got {live!r}) — the drift is deleted"
)
assert grid["image"] != "none", (
f"the static grid texture must still be painted (image {grid['image']!r})"
)
def test_three_glow_layers_run_distinct_fades(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC2: three different bright spots, each on its own slow fade —
body::after runs bg-glow-a (26s), the two html pseudo-layers run
bg-glow-b (34s) and bg-glow-c (42s); every one is ease-in-out,
infinite, reported ``running`` in the document animation list, and
the three durations are pairwise distinct (out of phase)."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
recipe = (
("glowA", GLOW_A, "26s"),
("glowB", GLOW_B, "34s"),
("glowC", GLOW_C, "42s"),
assert grid["anim"] == "none", (
f"body::before must stay static (animationName, got {grid['anim']!r})"
)
durations: list[float] = []
for key, name, expected in recipe:
for key in ("glowA", "glowB", "glowC"):
info = report[key]
assert info["anim"] == name, (
f"{key} must run {name} (got {info['anim']!r})"
assert info["image"] == "none", (
f"{key}: the deleted glow layer must carry no background-image "
f"(got {info['image']!r})"
)
assert info["duration"] == expected, (
f"{key} must run a {expected} cycle (got {info['duration']!r})"
assert info["anim"] == "none", (
f"{key}: the deleted glow layer must not animate "
f"(got {info['anim']!r})"
)
assert info["timing"] == EASE_IN_OUT, (
f"{key} must ease in-out (got {info['timing']!r})"
)
assert info["iterations"] == "infinite", (
f"{key} must fade forever (got {info['iterations']!r})"
)
durations.append(_seconds(info["duration"]))
assert len(set(durations)) == 3, (
f"the three spot cycles must be out of phase (got {durations})"
)
live = page.evaluate(JS_TIMELINE, list(GLOWS))
for name in GLOWS:
match = [a for a in live if a["name"] == name]
assert match, f"no {name} entry in document.getAnimations() — spot not fading"
assert match[0]["playState"] == "running", (
f"{name} is {match[0]['playState']!r} — the spot fade must be running"
assert info["content"] == "none" and info["position"] == "static", (
f"{key}: the glow pseudo-element must have no box "
f"(content {info['content']!r}, position {info['position']!r})"
)
def test_no_motion_properties_in_background_keyframes(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC1: the deterministic no-movement proof — walk the live stylesheets
in Chromium: across every frame of every ``bg-*`` @keyframes rule the
set of declared properties is exactly {opacity}. No transform, no
background-position, nothing that can move a pixel."""
page.goto(app_url)
audit = page.evaluate(JS_KEYFRAME_PROPS)
names = set(str(n) for n in audit["names"])
assert names == {GLOW_A, GLOW_B, GLOW_C}, (
f"expected exactly the three spot keyframes {sorted({GLOW_A, GLOW_B, GLOW_C})}, "
f"found {sorted(names)}"
)
props = {str(p) for p in audit["props"]}
assert props == {"opacity"}, (
f"bg-* keyframes may only animate opacity (found {sorted(props)}) — "
"the background must not move"
)
def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None:
"""AC2: all three spot timelines actually advance — the background is
a live animation, not a static (or paused) frame. Sample
currentTime, wait ~500ms, and require real progress on all three
layers (the document animation timeline in headless Chromium starts
~1s after load, so poll until it is alive first)."""
page.goto(app_url)
_wait_timelines_alive(page)
before = _timeline(page)
page.wait_for_timeout(500)
after = _timeline(page)
for name in GLOWS:
delta = after[name] - before[name]
assert delta >= 200, (
f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) "
"— paused or static?"
)
def test_background_light_actually_changes(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC2/AC3: the light fluxuates — (a) the computed opacity of
body::after measurably changes within a few seconds (a real fade,
not a frozen frame), and (b) a clipped screenshot of the bottom-left
glow region (the html::after spot at 14%/86% of the 1280×800
viewport) differs in bytes ~4s later — the light visibly changes
while nothing moves (a fresh / page runs no other animation, so the
pixel diff is the background's)."""
page.goto(app_url)
# (a) computed opacity of the indigo spot fades by >= 0.05 within ~8s.
def _spot_opacity() -> float:
return float(
page.evaluate("() => getComputedStyle(document.body, '::after').opacity")
)
t0 = _spot_opacity()
deadline = time.monotonic() + 8.0
delta = 0.0
while time.monotonic() < deadline:
delta = abs(_spot_opacity() - t0)
if delta >= 0.05:
break
page.wait_for_timeout(100)
assert delta >= 0.05, (
f"body::after opacity did not fade (Δ={delta:.3f} < 0.05 over 8s) — "
"frozen frame?"
)
# (b) the rendered glow region changes over ~4s (bottom-left spot at
# 14%/86% ≈ (179px, 688px) in the 1280×800 viewport).
clip: FloatRect = {"x": 0, "y": 500, "width": 500, "height": 300}
shot_1 = page.screenshot(clip=clip)
page.wait_for_timeout(4000)
shot_2 = page.screenshot(clip=clip)
assert shot_1 != shot_2, (
"the clipped bottom-left glow region is byte-identical 4s apart — "
"the light must visibly change even though nothing moves"
)
def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None:
"""AC4: UI Structure Check — all four background pseudo-layers (body
::before/::after + the two new html ::before/::after spots) stay
behind content (fixed, z-index -1, pointer-events none, full-viewport)
and nothing occludes them: the page canvas stays on <html>, <body>
stays transparent."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
for key in ("grid", "glowA", "glowB", "glowC"):
info = report[key]
assert info["position"] == "fixed", f"{key} must stay position:fixed"
assert info["zIndex"] == "-1", (
f"{key} must stay behind content (z-index -1, got {info['zIndex']!r})"
)
assert info["pointerEvents"] == "none", (
f"{key} must stay click-through (pointer-events none)"
)
assert info["edges"] == ["0px", "0px", "0px", "0px"], (
f"{key} must stay full-viewport (inset: 0, got {info['edges']!r})"
)
assert report["htmlBg"] == PAGE_BG, (
f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})"
)
assert report["bodyBg"] == "rgba(0, 0, 0, 0)", (
f"body must stay transparent so the layers show (got {report['bodyBg']!r})"
f"body must stay transparent so the grid shows (got {report['bodyBg']!r})"
)
def test_reduced_motion_stills_all_layers(
def test_no_background_keyframes_or_animations(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC1: the deterministic static proof — no ``@keyframes`` rule in
the background keyframe namespace (the ``bg-`` prefix) exists in any
live stylesheet, and no background-namespaced animation is running
in ``document.getAnimations()`` (the owner removed the infinite CSS
animations entirely — they ran on every page, in every tab)."""
page.goto(app_url)
audit = page.evaluate(JS_BG_ANIMATION_AUDIT)
assert not audit["keyframeNames"], (
f"no bg-* @keyframes may remain (found {audit['keyframeNames']!r})"
)
assert not audit["running"], (
f"no bg-* animation may be running (found {audit['running']!r})"
)
def test_grid_layer_is_fixed_behind_content(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC2: UI Structure Check — the surviving grid layer stays behind
the content and can never intercept input: ``position: fixed``,
``z-index: -1``, ``pointer-events: none``, full-viewport
(``inset: 0``)."""
page.goto(app_url)
grid = page.evaluate(JS_LAYER_REPORT)["grid"]
assert grid["position"] == "fixed", "body::before must stay position:fixed"
assert grid["zIndex"] == "-1", (
f"the grid must stay behind content (z-index -1, got {grid['zIndex']!r})"
)
assert grid["pointerEvents"] == "none", (
"the grid must stay click-through (pointer-events none)"
)
assert grid["edges"] == ["0px", "0px", "0px", "0px"], (
f"the grid must stay full-viewport (inset: 0, got {grid['edges']!r})"
)
def test_reduced_motion_background_static(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC5: with prefers-reduced-motion: reduce ALL FOUR pseudo-layers
stop animating (animation-name: none) — the static grid + spot
images remain visible."""
"""AC3: with ``prefers-reduced-motion: reduce`` the background is
already fully static — the grid is still painted and reports
``animation-name: none``, and the deleted glow layers still report
no image/animation (nothing is resurrected; the reduced-motion
blocks that survive in styles.css cover UI animations only)."""
context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
)
@@ -392,13 +216,23 @@ def test_reduced_motion_stills_all_layers(
rpage = context.new_page()
rpage.goto(app_url)
report = rpage.evaluate(JS_LAYER_REPORT)
for key in ("grid", "glowA", "glowB", "glowC"):
grid = report["grid"]
assert grid["image"] != "none", (
f"the static grid must remain visible under reduced motion "
f"(image {grid['image']!r})"
)
assert grid["anim"] == "none", (
f"the grid must stay static under reduced motion (got {grid['anim']!r})"
)
for key in ("glowA", "glowB", "glowC"):
info = report[key]
assert info["anim"] == "none", (
f"{key} must not animate under reduced motion (got {info['anim']!r})"
assert info["image"] == "none", (
f"{key}: no glow background may exist under reduced motion "
f"(got {info['image']!r})"
)
assert info["image"] != "none", (
f"{key}: the static background image must remain visible"
assert info["anim"] == "none", (
f"{key}: no glow animation may exist under reduced motion "
f"(got {info['anim']!r})"
)
finally:
context.close()
@@ -407,9 +241,9 @@ def test_reduced_motion_stills_all_layers(
def test_no_horizontal_overflow_with_layers(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC6: the four background layers add no width — the phase-07
overflow pin (documentElement.scrollWidth <= clientWidth) still holds
at the 360px floor with all four fixed; inset: 0 layers live."""
"""AC4: the background adds no width — the phase-07 overflow pin
(``documentElement.scrollWidth <= clientWidth``) holds at the 360px
floor with the single fixed; inset: 0 grid layer."""
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
phone.goto(f"{app_url}/")
+8 -5
View File
@@ -70,6 +70,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"
@@ -347,7 +348,7 @@ def test_bottom_cluster_pinned_at_every_scroll_position(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
build_conversation(page, n=len(SHORT_QUESTIONS))
@@ -434,9 +435,11 @@ def test_row_geometry_and_alignment(
) -> None:
page.set_default_timeout(30_000)
# No conversation needed for the row — static markup, always present
# (the page fixture's viewport is the 1280×800 desktop).
page.goto(app_url)
# No conversation needed for the row's GEOMETRY — static markup,
# always present (the page fixture's viewport is the 1280×800
# desktop). Phase 79: the touch-target section below seeds ONE
# turn, so the visitor signs in first (chat is require_user).
login(page, app_url, next="/")
# Exactly ONE .chat-actions, inside the pinned unit, holding exactly
# the two pills — and the A5 DOM order: New chat, then Share (the
@@ -546,7 +549,7 @@ def test_buttons_still_work_from_the_bottom(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# One real conversation: the buttons have something to act on (and
# the auto-save path fires — the row is cleaned up by _reset_db).
+11 -3
View File
@@ -23,7 +23,7 @@ from typing import Any
import httpx
from playwright.sync_api import Page
from e2e.auth_helpers import login
from e2e.auth_helpers import ADMIN_PASSWORD, login
REPO = Path(__file__).resolve().parents[2]
CHAT_QUESTION = "How is my Kubernetes cluster set up?"
@@ -60,9 +60,17 @@ def _admin_cookies(page: Page) -> dict[str, str]:
def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
"""Minimal SSE chat request (same pattern as ``test_chat_rag.py``):
POST /api/chat and collect the ``data:`` frames until the stream ends."""
POST /api/chat and collect the ``data:`` frames until the stream ends.
Phase 79: POST /api/chat is require_user-gated — the httpx client
signs in as the admin first (the middleware under test never touches
the auth contract; this is purely the request's credentials)."""
client = httpx.Client(timeout=60.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=60.0
) as r:
assert r.status_code == 200
+8 -7
View File
@@ -130,7 +130,7 @@ def test_conversation_survives_reload(
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask(page, QUESTION)
# The turn is persisted: versioned payload, RAW text (no HTML), and the
@@ -179,7 +179,7 @@ def test_deflected_turn_restores_styling(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask_deflected(page, OFF_TOPIC)
# The deflected metadata (suggestions) is persisted with the answer.
@@ -226,7 +226,7 @@ def test_new_chat_clears_conversation(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask(page, QUESTION)
expect(page.locator(".msg")).to_have_count(2)
assert _stored(page) is not None
@@ -267,18 +267,19 @@ def test_persists_across_page_navigation(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask(page, QUESTION)
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
# A trip to Sources (phase 16: the catalog is admin-only — the trip
# starts with a real form login). Phase 76 (task 02): the shell
# A trip to Sources (phase 16: the catalog is admin-only — the
# test's form login above already holds the session, so the trip is
# a plain navigation). Phase 76 (task 02): the shell
# carries the chat view (with its New chat button) in the DOM on
# EVERY view — hidden + inert — so the button EXISTS here but must
# be HIDDEN (the view-scoped absence pattern; it left the shared
# bar at owner request, 2026-08-28 — pinned in
# tests/e2e/test_shared_header.py).
login(page, app_url, next="/sources.html")
page.goto(f"{app_url}/sources.html")
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#new-chat-btn")).to_be_hidden()
+11 -3
View File
@@ -27,6 +27,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 ADMIN_PASSWORD, login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -78,7 +79,7 @@ def test_on_topic_question_streams_grounded_answer(
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Healthy KB: the offline banner must stay hidden.
expect(page.locator("#kb-banner")).to_be_hidden()
@@ -117,7 +118,7 @@ def test_on_topic_question_streams_grounded_answer(
def test_chat_logs_query(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)
login(page, app_url, next="/")
page.fill("#message-input", QUESTION)
page.click("#send-btn")
expect(
@@ -147,8 +148,15 @@ def test_sse_stream_shape(app_url: str, mock_llm: int, db_ready: None) -> None:
"""Raw transport contract (PLAN §4): delta events, then one done."""
_reset_db(mock_llm, seed=True)
# Phase 79: POST /api/chat is require_user-gated — the httpx client
# signs in as the admin first (the form login's API side: 204 + the
# signed session cookie in the jar).
client = httpx.Client(timeout=60.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": QUESTION}, timeout=60.0
) as r:
assert r.status_code == 200
+2 -1
View File
@@ -52,6 +52,7 @@ import httpx
import pytest
from playwright.sync_api import Page, expect
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
@@ -215,7 +216,7 @@ def test_other_pages_titles_carry_the_name(page: Page, testy_server: str) -> Non
def test_chat_status_label_uses_custom_name_pre_token(
page: Page, testy_server: str, db_ready: None
) -> None:
page.goto(testy_server + "/")
login(page, testy_server, next="/")
expect(page.locator("#send-btn")).to_be_enabled(timeout=15_000)
# The ``think out loud`` marker (mock_llm.py) makes the answer
+58 -54
View File
@@ -1,4 +1,4 @@
"""Phase 08 E2E (Playwright): dark tech theme — palette, no emoji, animated
"""Phase 08 E2E (Playwright): dark tech theme — palette, no emoji, static
background, reduced motion, behavior intact, all assets local.
Story: ``.agents/user_stories/dark-tech-theme.md``
@@ -13,14 +13,17 @@ Test → story mapping (Playwright Mapping Rule):
(same contrast helper as Phase 07).
2. ``test_no_emoji_in_chrome`` — neither page's ``innerText`` nor raw
``outerHTML`` contains any emoji code point.
3. ``test_animated_background`` — the background layers (fixed,
pointer-events none, carrying images): the grid (``body::before``)
is STATIC (phase 25, owner 2026-08-25: no movement) and the three
glow spots run their opacity-only fades ``bg-glow-a/b/c`` at
26s/34s/42s (``body::after`` + the two ``html`` pseudo-layers).
3. ``test_static_background`` — the background is fully STATIC (phase
78, owner direction: the animated glow layers were removed as too
resource-intensive): the grid (``body::before``) is a static
texture (fixed, pointer-events none, image still painted,
``animationName: none``) and the three glow pseudo-layers
(``body::after`` + the two ``html`` pseudo-layers) are deleted —
no background-image, no animation, no box.
4. ``test_reduced_motion_honored`` — a context with
``reduced_motion="reduce"`` → ``animation-name: none`` on all four
layers (the static grid + spot images remain).
``reduced_motion="reduce"`` → the grid stays painted and static
(``animation-name: none``) and the deleted glow pseudo-layers carry
no image or animation.
5. ``test_behavior_unchanged_smoke`` — on-topic question streams an answer
+ a source chip + the send button recovers (state machine intact under
the new skin).
@@ -36,7 +39,6 @@ from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Browser, Page, expect
from sqlalchemy import text
@@ -156,11 +158,6 @@ def _assert_aa(pair: Any, label: str) -> None:
assert ratio >= 4.5, f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
def _seconds(value: str) -> float:
"""Chromium reports animation durations as "60s" — parse as seconds."""
return float(str(value).replace("s", ""))
# --------------------------------------------------------------------------
# Tests (story → test mapping, see module docstring)
# --------------------------------------------------------------------------
@@ -176,8 +173,8 @@ def test_dark_palette_and_contrast(
# The visible page background is the <html> canvas (rgb(15, 10, 10)
# = #0f0a0a — the 2026-08-28 dark-red rebrand; it was the phase-08
# #0a0e17 canvas). <body> itself stays transparent so the
# z-index:-1 grid/glow layers (test_animated_background) are not
# painted over.
# z-index:-1 grid layer (test_static_background) is not painted
# over.
bg = page.evaluate(
"() => getComputedStyle(document.documentElement).backgroundColor"
)
@@ -187,8 +184,10 @@ def test_dark_palette_and_contrast(
f"{path}: body must stay transparent (the background layers need to show)"
)
# Chat page pairs.
page.goto(f"{app_url}/")
# Chat page pairs. Phase 79: the onboarding chips are drawn from
# /api/suggestions (require_user-gated) — the visitor signs in
# first; the palette pins are auth-independent.
login(page, app_url, next="/")
page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
@@ -260,16 +259,15 @@ def test_no_emoji_in_chrome(page: Page, app_url: str, db_ready: None) -> None:
assert not hits, f"emoji in {label} on {path}: {sorted(named)}"
def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
"""AC3: the background is subtly animated, pure CSS, zero JS, and can
never block or dim content. Phase-25 contract (owner 2026-08-25:
"not moving. Different bright spots should slowly fade in and out")
supersedes the phase-08 recipe: the grid (``body::before``) is a
STATIC texture (``animationName: none``, image still painted) and
the three glow spots each run their own opacity-only fade —
``body::after`` → ``bg-glow-a`` 26s, ``html::before`` →
``bg-glow-b`` 34s, ``html::after`` → ``bg-glow-c`` 42s — all fixed,
pointer-events:none, carrying an image."""
def test_static_background(page: Page, app_url: str, db_ready: None) -> None:
"""AC3: the background is fully static, pure CSS, zero JS, and can
never block or dim content. Phase-78 contract (owner direction,
TODO.md L4: the animated background was removed as too
resource-intensive) supersedes the phase-25 recipe: the grid
(``body::before``) is a STATIC texture (``animationName: none``,
image still painted, fixed, pointer-events:none), and the three
glow spots (``body::after`` + the two ``html`` pseudo-layers) are
DELETED — no background-image, no animation, no box."""
page.goto(app_url)
report = page.evaluate(
"""() => {
@@ -278,7 +276,6 @@ def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
return {
image: cs.backgroundImage,
anim: cs.animationName,
duration: cs.animationDuration,
position: cs.position,
pointerEvents: cs.pointerEvents,
};
@@ -291,34 +288,32 @@ def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
};
}"""
)
for key in ("grid", "glowA", "glowB", "glowC"):
info = report[key]
assert info["image"] != "none", f"{key} must carry a background image"
assert info["position"] == "fixed", f"{key} must be position:fixed"
assert info["pointerEvents"] == "none", f"{key} must not intercept input"
# The phase-25 recipe: a static grid + three out-of-phase spot fades.
assert report["grid"]["anim"] == "none", (
f"the grid must be static (got {report['grid']['anim']!r})"
grid = report["grid"]
assert grid["image"] != "none", (
"the static grid must carry a background image"
)
for key, name, seconds in (
("glowA", "bg-glow-a", 26.0),
("glowB", "bg-glow-b", 34.0),
("glowC", "bg-glow-c", 42.0),
):
assert grid["anim"] == "none", f"the grid must be static (got {grid['anim']!r})"
assert grid["position"] == "fixed", "the grid must be position:fixed"
assert grid["pointerEvents"] == "none", "the grid must not intercept input"
# The phase-78 recipe: the animated glow layers are gone entirely.
for key in ("glowA", "glowB", "glowC"):
info = report[key]
assert info["anim"] == name, f"{key} must run {name} (got {info['anim']!r})"
assert _seconds(info["duration"]) == pytest.approx(seconds), (
f"{key} must run its {seconds:.0f}s fade (got {info['duration']!r})"
assert info["image"] == "none", (
f"{key}: the deleted glow layer must carry no background image "
f"(got {info['image']!r})"
)
assert info["anim"] == "none", (
f"{key}: the deleted glow layer must not animate (got {info['anim']!r})"
)
def test_reduced_motion_honored(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC4: with prefers-reduced-motion: reduce ALL FOUR background
layers stop animating (animation-name: none) — the static grid +
spot images remain (phase 25 stills the two html pseudo-layers as
well as the body pair)."""
"""AC4: with prefers-reduced-motion: reduce the background is
already fully static (phase 78 deleted the animated glow layers) —
the grid stays painted and static (``animation-name: none``) and
the deleted glow pseudo-layers carry no image or animation."""
context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
)
@@ -339,12 +334,21 @@ def test_reduced_motion_honored(
};
}"""
)
for key in ("grid", "glowA", "glowB", "glowC"):
grid = report["grid"]
assert grid["anim"] == "none", (
f"the grid must stay static under reduced motion (got {grid['anim']!r})"
)
assert grid["image"] != "none", (
"grid: the static background image must remain visible"
)
for key in ("glowA", "glowB", "glowC"):
assert report[key]["anim"] == "none", (
f"{key} must not animate under reduced motion (got {report[key]['anim']!r})"
f"{key}: no glow animation may exist under reduced motion "
f"(got {report[key]['anim']!r})"
)
assert report[key]["image"] != "none", (
f"{key}: the static background image must remain visible"
assert report[key]["image"] == "none", (
f"{key}: no glow image may exist under reduced motion "
f"(got {report[key]['image']!r})"
)
finally:
context.close()
@@ -358,7 +362,7 @@ def test_behavior_unchanged_smoke(
button recovers (never stale)."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
expect(page.locator("#kb-banner")).to_be_hidden()
page.fill("#message-input", QUESTION)
+2 -1
View File
@@ -104,7 +104,7 @@ def test_back_from_chat_returns_to_chat(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat AND the viewer content are gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
@@ -187,6 +187,7 @@ def test_malicious_back_param_is_rejected(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
login(page, app_url, next="/") # phase 79: the viewer content is gated
errors: list[str] = []
dialogs: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
+2 -1
View File
@@ -48,6 +48,7 @@ from app.rag.chunker import chunk_document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.retriever import RetrievedChunk, retrieve
from e2e.auth_helpers import login
from tests.e2e.mock_llm import TOKEN_RE, embed_text
REPO = Path(__file__).resolve().parents[2]
@@ -116,7 +117,7 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
def _ask(page: Page, app_url: str, question: str) -> Any:
"""Submit *question* and wait for the streamed brain bubble."""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble")
+5 -1
View File
@@ -92,7 +92,7 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
def _ask_for_chip(page: Page, app_url: str) -> Any:
"""Drive one chat turn and return the kubernetes.md source chip."""
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
@@ -342,6 +342,10 @@ def test_standalone_page_still_works(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
# Phase 79: the viewer content is gated — the signed-in session sees
# the phase-10 contract unchanged (the not-found cards below come
# from the 404 / missing-params paths, not the auth gate).
login(page, app_url, next="/")
errors: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
+20 -23
View File
@@ -36,8 +36,9 @@ The admin edits through the REAL browser flow (form login → the
viewer's Edit button → the inline editor → Save); the re-embed itself
is then verified against the live database (the chunk's NEW content, a
FRESH non-NULL vector, the content chunks byte-for-byte untouched — the
D4 re-embed scope) and against the public content endpoint (no cookie —
the viewer stays public, phase 16).
D4 re-embed scope) and against the content endpoint (phase 79 superseded the
phase-16 soft rule: the endpoint is require_user, so the pin runs
under the admin session — the shape is unchanged).
"""
from __future__ import annotations
@@ -57,7 +58,7 @@ from app.db import SessionLocal
from app.models import Chunk, Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.auth_helpers import ADMIN_PASSWORD, login
from e2e.mock_llm import TOKEN_RE
REPO = Path(__file__).resolve().parents[2]
@@ -183,12 +184,16 @@ def _chunk_state() -> dict[str, Any]:
def _api_summary(app_url: str) -> str | None:
"""The public content endpoint's ``summary`` — NO cookie (the viewer
stays public, phase 16; a fresh httpx client carries no session)."""
r = httpx.get(
"""The content endpoint's ``summary``. Phase 79: the endpoint is
require_user (the phase-16 soft rule is superseded) — the fresh
httpx client signs in as the admin first (every caller is an
admin-flow test)."""
client = httpx.Client(timeout=10)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
r = client.get(
f"{app_url}/api/documents/content",
params={"source": SOURCE, "path": DOC_PATH},
timeout=10,
)
assert r.status_code == 200, r.text
return r.json()["summary"]
@@ -334,27 +339,19 @@ def test_anonymous_cannot(page: Page, app_url: str) -> None:
page.set_default_timeout(30_000)
content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8")
expected = _expected_summary(content, SOURCE, DOC_PATH)
digest_line, _ = expected.split("\n", 1)
# Fresh context (the function-scoped page fixture — no login): the
# panel renders the digest, but the edit affordance is ABSENT — no
# button, no header row, no editor wiring. The section keeps the
# phase-36 byte-for-byte shape: a bare h2 + the text-node <p>.
# Fresh context (the function-scoped page fixture — no login).
# Phase 79 (task 05): the content endpoint is require_user — the
# anonymous viewer meets the inline token gate (the content fetch
# never runs — no summary panel, no not-found card either), so the
# edit affordance is absent by construction.
page.goto(_doc_url(app_url))
panel = page.locator(".doc-summary")
expect(panel).to_have_count(1)
expect(panel).to_be_visible()
expect(panel).to_contain_text(digest_line)
expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#doc-title")).to_have_text("Loading…")
expect(page.locator(".doc-summary")).to_have_count(0)
expect(page.locator(".doc-summary-edit")).to_have_count(0)
expect(page.locator(".doc-summary-head")).to_have_count(0)
expect(page.locator(".doc-summary-editor")).to_have_count(0)
children = page.evaluate(
"() => [...document.querySelector('.doc-summary').children]"
".map((el) => el.className)"
)
assert children == ["doc-summary-title", "doc-summary-text"], (
f"anonymous panel drifted from the phase-36 shape: {children}"
)
# The endpoint is admin-gated (D4): an anonymous PATCH → 403
# "admin only" (a fresh httpx client carries no session), and the
+6 -1
View File
@@ -74,13 +74,18 @@ from app.models import GitSource
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_GITADMIN", "8125"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The five pages that ship the header (phase 34 contract) — the pages
+35 -4
View File
@@ -516,6 +516,12 @@ def test_anonymous_cannot_manage(
expect(page.locator("#nav-sources")).to_be_hidden()
expect(page.locator("#nav-tuning")).to_be_hidden()
# Phase 79 (task 05): the anonymous visitor meets the token gate —
# the whole page (list AND create form) sits inert behind it, so
# "anonymous cannot manage" is enforced by the gate first.
expect(page.locator("#auth-gate")).to_be_visible(timeout=15_000)
assert page.evaluate("() => document.getElementById('main').inert") is True
# The list stays on its empty state even though a note exists…
expect(page.locator("#tune-list .tuning-note")).to_have_count(0)
expect(page.locator("#tune-empty")).to_be_visible()
@@ -536,15 +542,40 @@ def test_anonymous_cannot_manage(
row = db.get(SteeringNote, note_id)
assert row is not None and row.note == SEED_NOTE, "the note must stay untouched"
# The create form 403s gracefully: an inline error (role=alert) with
# the API detail, and the typed instruction survives in the textarea.
page.fill("#tune-note", "an anonymous attempt")
# The gate is only the first lock: the create form 403s gracefully
# for a NON-ADMIN (token user) too — an inline error (role=alert)
# with the API detail, and the typed instruction survives in the
# textarea. Admin signs in via the header, mints a token, signs out
# again, then unlocks the page through the gate as that user.
login(page, app_url, next=TUNING_URL)
expect(page.locator("#nav-tuning")).to_be_visible(timeout=15_000)
token = page.evaluate(
"""async () => {
const r = await fetch('/api/tokens', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({label: 'e2e-tuning-user'}),
});
if (!r.ok) throw new Error('token create failed: ' + r.status);
return (await r.json()).token;
}"""
)
page.click("#sign-out-btn")
page.wait_for_selector("#auth-gate:not([hidden])", timeout=15_000)
page.fill("#auth-gate-input", token)
page.click("#auth-gate-form button")
page.wait_for_selector("#auth-gate", state="hidden", timeout=15_000)
# The token user unlocked the page but is still NOT admin: the
# Tuning nav link stays hidden, the form is reachable only by
# direct URL — and the API refuses the create with 403.
expect(page.locator("#nav-tuning")).to_be_hidden()
page.fill("#tune-note", "a non-admin attempt")
page.click("#tune-save")
error = page.locator("#tune-form .tuning-error")
expect(error).to_have_attribute("role", "alert")
expect(error).to_be_visible(timeout=15_000)
assert "admin only" in (error.inner_text() or "").lower()
expect(page.locator("#tune-note")).to_have_value("an anonymous attempt")
expect(page.locator("#tune-note")).to_have_value("a non-admin attempt")
# Nothing was created: the DB still holds only the seeded note.
with SessionLocal() as db:
+3 -2
View File
@@ -82,6 +82,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document
from e2e.auth_helpers import login
from tests.e2e.mock_llm import (
GREP_TEACH_MARKER,
GREP_TEACH_PATTERN,
@@ -373,7 +374,7 @@ def test_regex_grep_self_corrects_to_plain_form(
) -> None:
page.set_default_timeout(30_000)
_reset_db_fixture()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, GREP_TEACH_QUESTION)
@@ -434,7 +435,7 @@ def test_plain_search_flow_not_swallowed_by_new_trigger(
) -> None:
page.set_default_timeout(30_000)
_reset_db_fixture()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
# Turn 1 — the GREP-TEACH flow (the incident's regex-shaped first
+4 -3
View File
@@ -77,6 +77,7 @@ from app.db import SessionLocal
from app.models import Chunk, Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from tests.e2e.mock_llm import SEARCH_PATTERN, embed_text
REPO = Path(__file__).resolve().parents[2]
@@ -379,7 +380,7 @@ def test_read_flow_lines_answer_sources_no_raw_markup(
) -> None:
page.set_default_timeout(30_000)
_reset_db_read_pair()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, READ_QUESTION)
@@ -443,7 +444,7 @@ def test_grep_flow_line_then_answer(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db_search_fixture(mock_llm)
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, SEARCH_QUESTION)
@@ -488,7 +489,7 @@ def test_wire_argument_rule_across_both_flows(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db_read_pair()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
# Turn 1 — the READ flow (ls → read on the combined path).
+21 -4
View File
@@ -120,12 +120,16 @@ def test_header_height_identical_across_pages_desktop(
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
heights = _header_heights(page, app_url)
# Anonymous: chat + sources bars. Phase 79: the viewer's DATA is
# require_user-gated (its title row needs the content), so the
# document bar is measured signed-in, below — the bar height itself
# is auth-independent, which is exactly what both measurements pin.
page.goto(app_url + "/")
heights = {"chat": _box_height(page, ".app-header")}
page.goto(app_url + SOURCES_URL)
heights["sources"] = _box_height(page, ".app-header")
assert heights["chat"] == DESKTOP_HEADER_H, f"chat header {heights['chat']}px"
assert heights["sources"] == DESKTOP_HEADER_H, f"sources header {heights['sources']}px"
assert heights["document"] == DESKTOP_HEADER_H, (
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
@@ -139,6 +143,17 @@ def test_header_height_identical_across_pages_desktop(
expect(page.locator("#sign-in-link")).to_be_hidden()
assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
# The document bar (row 1 — the standard bar of the two-row viewer
# header), signed in: identical to the other pages' bars.
page.goto(app_url + VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text(
"Kubernetes Homelab Cluster", timeout=15_000
)
heights["document"] = _box_height(page, ".doc-header .app-header")
assert heights["document"] == DESKTOP_HEADER_H, (
f"document header {heights['document']}px (was content-sized)"
)
# ---------------------------------------------------------------------------
# 2. Mobile (≤640px): all three pages, one identical 58px bar
@@ -150,6 +165,7 @@ def test_header_height_identical_across_pages_mobile(
) -> None:
page.set_viewport_size({"width": 375, "height": 812})
_seed_db(mock_llm)
login(page, app_url, next="/") # phase 79: the viewer's title needs the content
heights = _header_heights(page, app_url)
assert heights["chat"] == MOBILE_HEADER_H, f"chat header {heights['chat']}px"
@@ -172,6 +188,7 @@ def test_viewer_header_content_still_fits(
survives in the titlebar ROW, and row 1 stays the pinned standard
bar — single-line on both desktop and mobile."""
_seed_db(mock_llm)
login(page, app_url, next="/") # phase 79: the viewer content is gated
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
page.set_viewport_size({"width": width, "height": 800})
+3 -3
View File
@@ -376,7 +376,7 @@ def test_reload_after_hidden_tab_restores_one_bubble(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask_long(page)
answer = _wait_streaming(page)
@@ -416,7 +416,7 @@ def test_baseline_no_pagehide_still_completes(
an over-eager fix changing the ordinary settle)."""
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask_long(page)
answer = _wait_streaming(page)
@@ -470,7 +470,7 @@ def test_pre_token_guard_survives_hidden_window(
# 120 s guard among them) becomes test-controlled, while the mock's
# real-time stream is unaffected (the network is not a timer).
page.clock.install()
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
+11 -3
View File
@@ -29,6 +29,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 ADMIN_PASSWORD, login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -86,7 +87,7 @@ def test_off_topic_question_deflects_honestly(
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
page.fill("#message-input", OFF_TOPIC)
@@ -130,7 +131,7 @@ def test_deflection_suggestions_are_clickable(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", OFF_TOPIC)
page.click("#send-btn")
chips = _deflected_chips(page)
@@ -162,8 +163,15 @@ def test_deflected_done_event_and_query_log(app_url: str, mock_llm: int, db_read
"""Raw SSE contract for a deflected turn + the durable query_log row."""
_reset_db(mock_llm, seed=True)
# Phase 79: POST /api/chat is require_user-gated — the httpx client
# signs in as the admin first (the form login's API side: 204 + the
# signed session cookie in the jar).
client = httpx.Client(timeout=60.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": OFF_TOPIC}, timeout=60.0
) as r:
assert r.status_code == 200
+2 -1
View File
@@ -46,6 +46,7 @@ from app.models import Document, KbOverview, QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.overview import build_overview_prompt, regenerate_overview
from e2e.auth_helpers import login
from tests.e2e.mock_llm import TOKEN_RE
REPO = Path(__file__).resolve().parents[2]
@@ -128,7 +129,7 @@ def _ask(page: Page, app_url: str, question: str, done_text: str) -> Any:
marker (the no-row control, where the echo is absent by design).
"""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble")
+4 -3
View File
@@ -47,6 +47,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"
@@ -149,7 +150,7 @@ def test_followup_receives_history_and_thinking(
# Cold start: no restored conversation — every prior turn the model
# sees on turn 2 is the one this test just sent.
page.add_init_script("localStorage.clear()")
page.goto(app_url)
login(page, app_url, next="/")
# Turn 1 — grounded + the thinking trigger: the brain record must
# carry the streamed scratchpad in its ``thinking`` key.
@@ -184,7 +185,7 @@ def test_first_question_has_no_history(
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.add_init_script("localStorage.clear()")
page.goto(app_url)
login(page, app_url, next="/")
# Cold start: the request body's history is empty — no phantom
# prior turns, no phantom thinking.
@@ -207,7 +208,7 @@ def test_deflected_followup_receives_history(
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.add_init_script("localStorage.clear()")
page.goto(app_url)
login(page, app_url, next="/")
_ask(page, T1)
brain1 = _wait_record(page, 2)["messages"][1]
+5 -4
View File
@@ -76,6 +76,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, QueryLog
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
REPO = Path(__file__).resolve().parents[2]
@@ -319,7 +320,7 @@ def test_dead_then_recovered_deflected(
) -> None:
_reset_db()
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, DEFLECT_Q)
@@ -370,7 +371,7 @@ def test_dead_then_recovered_grounded(
) -> None:
_reset_db()
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, GROUNDED_Q)
@@ -423,7 +424,7 @@ def test_embedding_retry_completes(
) -> None:
_reset_db()
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, EMBED_Q)
@@ -465,7 +466,7 @@ def test_exhaustion_lands_on_the_error_banner(
) -> None:
_reset_db()
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, EXHAUST_Q)
+6 -5
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"
@@ -179,7 +180,7 @@ def test_typing_indicator_during_slow_think(
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
@@ -212,7 +213,7 @@ def test_button_state_machine(
(class removed) + the input is focused back."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
@@ -241,7 +242,7 @@ def test_streaming_appends_live(
shorter than a later one (no 'whole answer appears at once')."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", ON_TOPIC)
page.click("#send-btn")
@@ -278,7 +279,7 @@ def test_reduced_motion_keeps_feedback(
_reset_db(mock_llm, seed=True)
page.emulate_media(reduced_motion="reduce")
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
@@ -302,7 +303,7 @@ def test_error_banner_on_llm_down(
with the actionable retry hint, and the button recovers (never a
zombie)."""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", ON_TOPIC)
page.click("#send-btn")
+6 -1
View File
@@ -90,13 +90,18 @@ from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_LOCALDIR", "8126"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
+3 -2
View File
@@ -31,6 +31,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"
@@ -83,7 +84,7 @@ def test_long_answer_streams_to_completion(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(45_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
@@ -118,7 +119,7 @@ def test_normal_answer_unaffected(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", NORMAL_QUESTION)
page.click("#send-btn")
+2 -2
View File
@@ -121,7 +121,7 @@ def _ask_table_answer(page: Page, app_url: str) -> Any:
"""Drive the trigger question and return the brain bubble once the
whole byte-stable table answer has streamed in (the wide table's
last cell lands last)."""
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").first
@@ -333,7 +333,7 @@ def test_plain_pipe_stays_text(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", PLAIN_QUESTION)
page.click("#send-btn")
+37 -9
View File
@@ -113,6 +113,20 @@ def _open_menu(page: Page) -> None:
expect(page.locator("#app-nav")).to_have_css("opacity", "1")
def _js_open_menu(page: Page) -> None:
"""Phase 79 (task 05): the in-app token gate is a full-viewport
overlay for ANONYMOUS visitors — it physically covers the header,
so a real click on #nav-toggle is intercepted by the gate (the gate
is the only interactive surface; the header is locked out with the
rest of the page). The binding is identical, so the menu contract
is driven programmatically: a JS-dispatched click runs the exact
same listener a real click would."""
page.evaluate("() => document.querySelector('#nav-toggle').click()")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
expect(page.locator("#app-nav")).to_have_css("opacity", "1")
def _assert_menu_closed(page: Page) -> None:
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
assert "is-open" not in (page.locator("#app-nav").get_attribute("class") or ""), (
@@ -189,14 +203,19 @@ def test_anonymous_menu_contents(
visible link — "Chat". The three admin-only links keep their
ship-hidden state INSIDE the menu (the phase-19/35 contract is
preserved by reusing the same <nav> element); opening flips
aria-expanded true."""
aria-expanded true.
Phase 79 (task 05): the anonymous visitor meets the token gate — a
full-viewport overlay that covers the header — so the toggle is
driven programmatically (the binding is identical; see
_js_open_menu)."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
_assert_menu_closed(page)
_open_menu(page)
_js_open_menu(page)
assert _visible_nav_links(page) == ["Chat"], (
"anonymous: the menu must show exactly one visible link (Chat)"
)
@@ -204,7 +223,7 @@ def test_anonymous_menu_contents(
expect(page.locator(sel)).to_be_hidden()
# A second click closes it again — aria-expanded round-trips.
page.click("#nav-toggle")
page.evaluate("() => document.querySelector('#nav-toggle').click()")
_assert_menu_closed(page)
finally:
page.close()
@@ -284,8 +303,11 @@ def test_esc_and_outside_close(
page.goto(app_url)
_wait_settled_anonymous(page)
# Phase 79 (task 05): the anonymous visitor's toggle click is
# intercepted by the gate overlay — drive the identical binding
# programmatically (see _js_open_menu).
# Esc closes + refocuses the opener.
_open_menu(page)
_js_open_menu(page)
page.keyboard.press("Escape")
_assert_menu_closed(page)
assert page.evaluate("() => document.activeElement.id") == "nav-toggle", (
@@ -294,8 +316,11 @@ def test_esc_and_outside_close(
# Outside click: the menu STAYS open (accepted behavior — the
# locked close set is Esc + link + resize, not backdrop click).
_open_menu(page)
page.locator("footer span").first.click() # a neutral, non-link point
# Phase 79 (task 05): for the anonymous visitor the "outside"
# point is the gate overlay itself — a REAL mouse click below
# the centered card (outside the nav, intercepted by the gate).
_js_open_menu(page)
page.mouse.click(10, 780) # the gate overlay — a neutral, non-nav point
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
assert "is-open" in (page.locator("#app-nav").get_attribute("class") or ""), (
"accepted behavior: an outside click must NOT close the menu"
@@ -339,8 +364,9 @@ def test_animation_and_reduced_motion(
f"got {report['property']!r}"
)
# Opening flips class + aria together (the animated state).
_open_menu(page)
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
# Phase 79 (task 05): the anonymous toggle click is intercepted
# by the gate overlay — programmatic drive, same binding.
_js_open_menu(page)
page.keyboard.press("Escape")
_assert_menu_closed(page)
finally:
@@ -363,7 +389,9 @@ def test_animation_and_reduced_motion(
assert _stilled("#app-nav") == "0s", (
f"reduced motion: closed state must not transition, got {_stilled('#app-nav')!r}"
)
rpage.click("#nav-toggle")
# Phase 79 (task 05): programmatic drive (the gate overlay
# intercepts the anonymous real click — same binding).
_js_open_menu(rpage)
expect(rpage.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
expect(rpage.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
assert _stilled("#app-nav") == "0s", (
+12 -3
View File
@@ -233,9 +233,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
page.goto(app_url + url)
_wait_settled(page, admin=admin)
if name == "viewer":
# The document itself has settled (rendered, not Loading…/
# not-found) so the bar is measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# The viewer has settled, so the bar is measured on the real
# page. Phase 79 (task 05): content is require_user-gated —
# admin settles on the title; anonymous settles on the inline
# token gate (#doc-auth-gate — the content fetch never runs,
# the title stays "Loading…").
if admin:
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
else:
expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#doc-title")).to_have_text("Loading…")
# The per-role VISIBLE inventory (the story: no button pops in or
# out because of which page you are on).
@@ -439,6 +446,7 @@ def test_viewer_row1_height_matches_chat_and_titlebar_present(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_seed_db(mock_llm)
login(page, app_url, next="/") # phase 79: the viewer's title needs the content
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
page.set_viewport_size({"width": width, "height": 800})
@@ -474,6 +482,7 @@ def test_viewer_back_link_honors_back_param(
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
login(page, app_url, next="/") # phase 79: the viewer's title needs the content
# Positive: back=/ (same-origin relative) is honored — href "/",
# label "Chat", and the click returns to the chat page.
+9 -6
View File
@@ -112,11 +112,13 @@ CURRENT_LINK: dict[str, str | None] = {
#: The four primary nav links, in their physical DOM order — the labels
#: after the phase-48 swap (ids/hrefs unchanged).
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
#: The FIFTH a.nav-link in every page header (phase 53 saved-chat
#: history — ship-hidden, revealed by header.js for admins). The DOM
#: enumeration below therefore always sees it (pre-existing since phase
#: 53; the list below now matches the real nav).
NAV_TAIL = ("History",)
#: The FIFTH + SIXTH a.nav-link in every page header (phase 53
#: saved-chat history + phase 79 task 06 access tokens — both
#: ship-hidden, revealed by header.js for admins). The DOM enumeration
#: below therefore always sees them (pre-existing; the list matches the
#: real nav — the phase-34 one-bar contract ships the SAME nav, incl.
#: the Tokens link, on every page).
NAV_TAIL = ("History", "Tokens")
#: The login.js script — route pattern for the redirect suppression.
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
@@ -197,7 +199,8 @@ def _assert_renamed_labels(page: Page, name: str) -> None:
expect(git).to_have_attribute("href", "/git-sources.html")
# The nav links (class nav-link) in physical DOM order — the four
# primaries plus the phase-53 admin-only History link.
# primaries plus the admin-only History (phase 53) and Tokens
# (phase 79 task 06) tail links.
nav_texts = page.eval_on_selector_all(
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
)
+1 -1
View File
@@ -515,7 +515,7 @@ def test_baseline_no_switch_still_completes(
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask_long(page)
answer = _wait_streaming(page, page.locator(ANSWER))
+629
View File
@@ -0,0 +1,629 @@
"""Phase 77 E2E (Playwright): navbar re-shows refresh the view's data —
the story suite for the phase-77 refresh hook + the History refresh
button.
Source: TODO.md L3 — "Clicking navbar icons should refresh the relevant
page. For example, clicking 'history' doesn't load new history until I
refresh. The history page should also have a refresh button."
Since the phase-76 shell, a view's data was fetched exactly once, at
mount (mount-once, hide-forever) — a History view opened at 10:00 still
showed 10:00's data at 10:30. Phase 77 makes every USER-INITIATED
re-show of an already-mounted view re-fetch its list: the router
dispatches ``bor:view-refresh`` on the view's section (a switch back
onto it, a re-click of its own nav link — NO pushState — or back/
forward onto it; the first show and boot never), the four data views
(History, RAG, Sources, Tuning) listen and re-run their loads, and the
History view gains an explicit Refresh button in its page-head (the
owner's second half of L3). The Chat view is deliberately excluded
(the in-flight stream and local conversation persist — the phase-76
contract; the unchanged ``test_nav_switch_keeps_stream.py`` suite is
the additional control).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov
The "freshness" proof (the house pattern from the phase overview):
create new backing data via the API AFTER a view has loaded, nav back
(or re-click / press Refresh), assert the new row — with the phase-76
canonical same-document sentinel (a ``window`` global set before the
nav clicks is still readable after — no document load).
Test → story mapping (Playwright Mapping Rule):
1. ``test_reshow_refetches_history`` — THE L3 REPRO: saved chat A
visible on History; chat B created via the API while Chat is up;
Chat → History: row B appears (replaced list — no duplicates), the
window sentinel survives every click (same document).
2. ``test_active_view_reclick_refetches`` — a re-click of the ACTIVE
view's own nav link re-fetches (today a no-op): chat C created via
the API while History is up; click History AGAIN → C appears, the
URL is still /history.html and ``history.length`` is unchanged (no
pushState).
3. ``test_history_refresh_button`` — the explicit Refresh control
(TODO.md L3's second half): chat D created via the API; click
#history-refresh → D appears, #history-status announces "Saved
chats refreshed.", the button is disabled while the request is in
flight (the house hold-the-request pattern — deterministic, not a
race) and re-enabled after (accessible name + keyboard focus
pinned too).
4. ``test_popstate_refetches_history`` — back/forward onto an
already-mounted view re-fetches: Chat → History (loads) → Chat →
create E via the API → ``page.go_back()`` (popstate) → History →
E present.
5. ``test_all_four_data_views_refetch_on_reshow`` — one assertion per
data view (History, RAG, Sources, Tuning): the GET to the view's
list endpoint is exactly +1 on a re-show (the request-log pattern
from task 02) — the first show (the mount) is the baseline.
6. ``test_stream_surval_control`` — the phase-76 CONTRACT control with
the hook in place: send a question (the mock LLM's ~8 s stream) →
mid-stream nav to RAG → back to Chat → the FULL answer completes
and the turn settles (one query_log row). The unchanged
``test_nav_switch_keeps_stream.py`` run in isolation is the
additional control.
"""
from __future__ import annotations
import asyncio
import re
import time
from collections.abc import Callable
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Locator, Page, Request, 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 login
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: The phase-11 on-topic long-answer phrasing (house pattern,
#: test_nav_switch_keeps_stream.py): the honesty gate is HIGH and the
#: ~900-word answer streams for ~8–9 s — the guaranteed mid-stream
#: window.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
#: Distinct saved-chat titles per test (the DB is truncated at each
#: test's start, so fixed titles stay unambiguous within a test).
TITLE_A = "Phase77 re-show A"
TITLE_B = "Phase77 re-show B"
TITLE_C = "Phase77 re-click C"
TITLE_D = "Phase77 refresh button D"
TITLE_E = "Phase77 popstate E"
# The four data views (task 02's refresh joiners): the nav link, the
# view's URL (the pushState target), and the list endpoint the view's
# load hits (the request-log assertion's target — the sync/upload
# pollers hit OTHER paths and only run while a job is in flight).
DATA_VIEWS: tuple[tuple[str, str, str], ...] = (
("#nav-history", "/history.html", "/api/chats"),
("#nav-sources", "/sources.html", "/api/docs"),
("#nav-git-sources", "/git-sources.html", "/api/git-sources"),
("#nav-tuning", "/tuning.html", "/api/steering"),
)
# ---------------------------------------------------------------------------
# DB reset + API helpers (the house pattern from test_nav_switch_keeps_stream)
# ---------------------------------------------------------------------------
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's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
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) -> ImportSummary:
"""House reset + the prompt-shaping tables: steering notes and the
KB overview would otherwise append deterministic suffixes to every
mock answer and break the exact-text assertions; saved_chats is
cleared too (each test creates its own rows via the API)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, "
"steering_notes, kb_overview, saved_chats"
)
)
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
"""The settled-row count over the whole (truncated) log — the
house pattern: a row finalizes ONLY when the LLM finished AND the
persistence succeeded, so 0 = cancelled, 1 = settled."""
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login —
the test's API side sees exactly what the signed-in browser sees."""
return {c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c}
def _create_chat(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]:
"""POST /api/chats (the public save surface) — the API-side
mutation the freshness proofs hinge on: the row is created while
the view is NOT showing, so only a re-fetch can surface it."""
r = httpx.post(
f"{app_url}/api/chats",
timeout=10,
cookies=cookies,
json={
"title": title,
"messages": [
{"who": "user", "text": f"Saved via the API for the phase-77 suite: {title}"},
{"who": "brain", "text": "Saved."},
],
},
)
assert r.status_code == 201, f"POST /api/chats for {title!r}: {r.status_code} {r.text}"
return r.json()
def _row(page: Page, title: str) -> Locator:
"""The saved chat's row (the Title cell's Open link text is the
title — textContent only, so an exact-text locator is stable)."""
return page.locator("#history-tbody a.history-title-link", has_text=title)
def _wait_until(page: Page, pred: Callable[[], bool], timeout: float = 15.0) -> None:
"""Poll ``pred`` until it holds — the tick is ``page.wait_for_timeout``
(NOT a raw ``time.sleep``): the sync API delivers ``page.on`` events
only while a Playwright call is in flight, so the request log the
predicate reads is drained on every tick (a raw sleep starves the
event queue)."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if pred():
return
page.wait_for_timeout(50)
raise AssertionError("timeout waiting for the condition")
def _get_logger(page: Page) -> list[str]:
"""Every GET the browser issues (the request-log pattern from task
02 — the re-fetch proof where a cheap row-delta is not available).
POST/PUT (auto-saves, form posts) are deliberately not logged: the
assertion is about the view's LIST fetch."""
seen: list[str] = []
def on_request(req: Request) -> None:
if req.method == "GET":
seen.append(req.url)
page.on("request", on_request)
return seen
# ---------------------------------------------------------------------------
# Shared stream helpers (mirrored from test_nav_switch_keeps_stream.py —
# the phase-76 suite must stay unchanged; this file is the story gate)
# ---------------------------------------------------------------------------
def _no_error_banner(page: Page) -> None:
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
def _ask_long(page: Page) -> None:
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
def _wait_streaming(page: Page, answer: Locator) -> Locator:
"""Wait until answer text is visibly streaming (a few delta frames
rendered — the mid-stream moment, well inside the ~8–9 s stream)."""
answer.wait_for(state="visible", timeout=30_000)
partial = ""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
partial = answer.inner_text()
if len(partial.split()) >= 8:
break
time.sleep(0.05)
assert len(partial.split()) >= 8, "no answer deltas before the view switch"
# In flight at the switch: the button IS the enabled Stop control.
expect(page.locator("#send-label")).to_have_text("Stop")
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
return answer
def _wait_done(page: Page, answer: Locator) -> str:
"""Wait for the ``done`` settle: the Send button is back and the
bubble carries the unique final line — no error banner on the way."""
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000)
_no_error_banner(page)
return answer.inner_text()
def _assert_full_answer(text: str) -> None:
"""The bubble carries the FULL mock answer — every one of the 40
numbered steps plus the unique final line (a truncated stream
would be missing its tail)."""
for i in range(1, LONG_ANSWER_LINES + 1):
assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer"
assert LONG_ANSWER_END in text
# ---------------------------------------------------------------------------
# 1. THE L3 REPRO: a switch back onto History re-fetches — the new row
# appears (list replaced, no duplicates), same document throughout
# ---------------------------------------------------------------------------
def test_reshow_refetches_history(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
"""Sign in → create saved chat A via POST /api/chats → nav to
History (row A visible) → create chat B via the API while Chat is
up → nav to Chat → nav back to History: row B is visible (the
re-show re-fetched — before phase 77 the 10:00 list stayed), row A
appears EXACTLY ONCE (the re-load replaces the list, never
duplicates it), and the window sentinel set before the nav clicks
is still readable after (the phase-76 canonical no-document-load
proof)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-history")).to_be_visible()
cookies = _admin_cookies(page)
_create_chat(app_url, cookies, TITLE_A)
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000)
# THE REPRO: B is created while History is NOT showing…
page.evaluate("() => { window.__shell_boot = 'phase77'; }")
_create_chat(app_url, cookies, TITLE_B)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
# …and the switch back re-fetches: B is visible without a reload.
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000)
# Replaced list, never duplicated: each title EXACTLY ONCE.
expect(_row(page, TITLE_A)).to_have_count(1)
expect(_row(page, TITLE_B)).to_have_count(1)
# Same document through every click (a real navigation would wipe
# the window sentinel).
assert page.evaluate("() => window.__shell_boot") == "phase77", (
"a real navigation would have wiped the window sentinel — "
"the switches must be same-document"
)
# ---------------------------------------------------------------------------
# 2. A re-click of the ACTIVE view's own nav link re-fetches (no-op
# before phase 77) — NO pushState (the URL is already the path)
# ---------------------------------------------------------------------------
def test_active_view_reclick_refetches(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""With History visible (rows A, B): create C via the API; click
the History nav link AGAIN (the active view's own link) → C
appears. The URL is still /history.html and history.length is
UNCHANGED — the re-click dispatches the refresh event instead of
a bare return, and it must not pushState (the URL already IS this
view's path)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
_create_chat(app_url, cookies, TITLE_A)
_create_chat(app_url, cookies, TITLE_B)
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000)
length_before = page.evaluate("() => window.history.length")
_create_chat(app_url, cookies, TITLE_C)
page.click("#nav-history") # re-click the ACTIVE link
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_C)).to_be_visible(timeout=15_000)
expect(_row(page, TITLE_A)).to_have_count(1)
length_after = page.evaluate("() => window.history.length")
assert length_after == length_before, (
"the active re-click must NOT pushState — the URL is already "
f"this view's path ({length_before} → {length_after})"
)
# ---------------------------------------------------------------------------
# 3. The explicit Refresh button (TODO.md L3's second half): the
# visible, keyboard-reachable control with its in-flight lifecycle
# ---------------------------------------------------------------------------
def test_history_refresh_button(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
"""The Refresh control (TODO.md L3: "The history page should also
have a refresh button."): visible for admin in the page-head,
keyboard-reachable (Tab-able, focusable, accessible name), and the
in-flight lifecycle — create D via the API; click #history-refresh
→ D appears, #history-status announces "Saved chats refreshed.",
the button is disabled DURING the in-flight request (asserted
deterministically: the request is held in the browser via
page.route — the house pattern from test_archive_upload_sources)
and re-enabled after."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
_create_chat(app_url, cookies, TITLE_A)
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000)
btn = page.locator("#history-refresh")
expect(btn).to_be_visible()
expect(btn).to_be_enabled()
# WCAG 2.1 AA basics: the accessible name (icon-only below 640px —
# the aria-label carries it in both) + keyboard-reachable.
assert btn.get_attribute("aria-label") == "Refresh saved chats"
btn.focus()
active = page.evaluate("() => document.activeElement && document.activeElement.id")
assert active == "history-refresh", "the Refresh button must be keyboard-focusable"
# Hold the refresh's GET in the browser so the in-flight state is
# observable deterministically instead of racing the fast
# localhost round-trip (the house pattern): the flag is armed only
# around the button's click.
hold = {"on": False}
def handle(route: Any) -> None:
if hold["on"]:
time.sleep(1.0)
route.continue_()
page.route("**/api/chats", handle)
_create_chat(app_url, cookies, TITLE_D)
hold["on"] = True
btn.click()
# In flight (§7.4): the button is disabled — no double-fire.
expect(btn).to_be_disabled()
hold["on"] = False
# Settled: D is visible (the re-fetch replaced the list), the live
# region carries the exact success line, the button is re-enabled.
expect(_row(page, TITLE_D)).to_be_visible(timeout=15_000)
expect(_row(page, TITLE_A)).to_have_count(1)
expect(page.locator("#history-status")).to_have_text("Saved chats refreshed.")
expect(btn).to_be_enabled()
# ---------------------------------------------------------------------------
# 4. Back/forward (popstate) onto an already-mounted view re-fetches
# ---------------------------------------------------------------------------
def test_popstate_refetches_history(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Chat → History (the mount's own load) → back to Chat → create E
via the API → ``page.go_back()`` (popstate onto the already-mounted
History view) → E is present: the popstate path flows through
switchTo, so it inherits the wasMounted gating — a re-show
dispatches bor:view-refresh, the first show never did (E was not
visible on the original show)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
page.click("#nav-history") # Chat → History (mount + first load)
expect(page).to_have_url(app_url + "/history.html")
expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000)
page.click('a.nav-link[href="/"]') # → Chat (pushState)
expect(page).to_have_url(app_url + "/")
_create_chat(app_url, cookies, TITLE_E)
page.go_back() # popstate → /history.html (the already-mounted view)
expect(page).to_have_url(app_url + "/history.html")
expect(page.locator("#view-history")).not_to_be_hidden()
expect(_row(page, TITLE_E)).to_be_visible(timeout=15_000)
# ---------------------------------------------------------------------------
# 5. All four data views re-fetch on a re-show (the request-log pattern
# from task 02 — one assertion per view)
# ---------------------------------------------------------------------------
def test_all_four_data_views_refetch_on_reshow(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""One assertion per data view (History, RAG, Sources, Tuning):
after the first show (the mount — its load is the baseline), a
switch back onto the view issues EXACTLY ONE more GET to the
view's list endpoint — the re-fetch the phase is about. The
windowed counts (before/after the re-show click) make the
assertion immune to the boot-time fetches (the header's steering
panel loads once at shell boot) and to the sync/upload pollers
(other paths, and only while a job is in flight). For History the
row-delta rides along: a chat created before the re-show appears
after it."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible() # admin: every link revealed
expect(page.locator("#nav-history")).to_be_visible()
expect(page.locator("#nav-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
cookies = _admin_cookies(page)
log = _get_logger(page)
def count(endpoint: str) -> int:
return sum(1 for u in log if u.endswith(endpoint))
for nav_sel, view_path, endpoint in DATA_VIEWS:
# First show (the mount) — wait until the mount's own load
# lands in the log (the baseline).
page.click(nav_sel)
expect(page).to_have_url(app_url + view_path)
_wait_until(page, lambda ep=endpoint: count(ep) >= 1)
before = count(endpoint)
# History: create the row-delta's backing data while hidden.
if endpoint == "/api/chats":
_create_chat(app_url, cookies, "Phase77 four-view row")
# Back to Chat, then a re-show of the view: the refresh event
# must issue exactly one more list GET.
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
page.click(nav_sel)
expect(page).to_have_url(app_url + view_path)
_wait_until(page, lambda b=before, ep=endpoint: count(ep) >= b + 1)
assert count(endpoint) == before + 1, (
f"{nav_sel}: a re-show must re-fetch exactly once "
f"({before} → {count(endpoint)} GETs to {endpoint})"
)
if endpoint == "/api/chats":
expect(_row(page, "Phase77 four-view row")).to_be_visible(timeout=15_000)
# ---------------------------------------------------------------------------
# 6. The phase-76 CONTRACT control: with the refresh hook in place, an
# in-flight stream still survives a mid-stream nav switch
# ---------------------------------------------------------------------------
def test_stream_survival_control(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
"""The phase-76 contract holds with the hook in place: send a
question (the mock LLM's ~8 s stream) → mid-stream nav to RAG (a
re-show-capable, LISTENING view — the hook is live) → back to Chat
→ the FULL answer completes and the turn settles (one query_log
row). The Chat view never listens for bor:view-refresh (the
negative unit pin) — its in-flight SSE reader and local
conversation persist through every switch. The unchanged
tests/e2e/test_nav_switch_keeps_stream.py run in isolation is the
additional control."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
_ask_long(page)
answer = _wait_streaming(page, page.locator(ANSWER))
# THE SWITCH (mid-stream): the window sentinel set BEFORE the click
# is still readable AFTER it — same document.
page.evaluate("() => { window.__shell_boot = 'phase77'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase77", (
"a real navigation would have wiped the window sentinel"
)
# The RAG view actually showed (the fixture docs' rows are listed)
# and the chat view is hidden (the stream fills it in the
# background — that persistence IS the phase-76 fix).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
# Stay on RAG while the stream keeps running, then return to Chat.
page.wait_for_timeout(2000)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
assert page.evaluate("() => window.__shell_boot") == "phase77"
# The answer COMPLETED — the FULL mock answer, no error banner —
# and the turn SETTLED (one query_log row; a cancelled turn would
# leave none).
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
assert _query_log_count() == 1, "the completed turn must finalize exactly one settled row"
# ---------------------------------------------------------------------------
# 7. Sanity: the API-side rows the suite creates carry the bor.chat.v1
# record shape (guards _create_chat against a schema drift that
# would silently change what History renders)
# ---------------------------------------------------------------------------
def test_api_created_chats_carry_the_bor_chat_v1_shape(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The suite's API-side mutation helper (POST /api/chats) produces
rows in the exact bor.chat.v1 record shape the History table and
the ?chat= restore path render: who/text messages, the message
count, and no stray keys (the schema's extra=forbid rejects them
at the boundary with a 422 — a 201 here proves the shape) — and
History renders the row (the Open link's text IS the title)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
created = _create_chat(app_url, cookies, "Phase77 shape check")
r = httpx.get(f"{app_url}/api/chats/{created['id']}", timeout=10, cookies=cookies)
assert r.status_code == 200
body = r.json()
assert body["title"] == "Phase77 shape check"
assert [m["who"] for m in body["messages"]] == ["user", "brain"]
assert body["message_count"] == 2
# The bor.chat.v1 record shape: who/text present; only the schema's
# optional keys may accompany them (explicit nulls are preserved —
# the plain model_dump round-trips byte-identical).
allowed = {"who", "text", "sources", "deflected", "suggestions",
"thinking", "tools", "stopped"}
assert all({"who", "text"} <= set(m) <= allowed for m in body["messages"])
# The History row renders it (the Open link's text IS the title).
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, "Phase77 shape check")).to_be_visible(timeout=15_000)
+7 -6
View File
@@ -64,6 +64,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"
@@ -242,7 +243,7 @@ def test_no_autoscroll_during_long_answer(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Turn 1 (settled) makes the document overflow the 800px viewport.
submit(page, LONG_QUESTION)
@@ -308,7 +309,7 @@ def test_no_autoscroll_during_thinking(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# One settled long turn so the document overflows (scrollable).
submit(page, LONG_QUESTION)
@@ -379,7 +380,7 @@ def test_submit_reveals_user_message(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# A populated conversation that overflows the viewport.
submit(page, LONG_QUESTION)
@@ -420,7 +421,7 @@ def test_restore_landing_one_shot(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Settle a conversation (phase-14 persistence): long + short turns.
submit(page, LONG_QUESTION)
@@ -469,7 +470,7 @@ def test_restore_landing_one_shot(
def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# The long answer streams to completion with its sources ...
submit(page, LONG_QUESTION)
@@ -527,7 +528,7 @@ def test_submit_does_not_hop_up(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# A settled long answer (the document overflows the 800px viewport),
# and the user has scrolled to the very bottom to read it — the
+5 -4
View File
@@ -66,6 +66,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"
@@ -285,7 +286,7 @@ def test_composer_pinned_at_every_scroll_position(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
build_conversation(page, n=len(SHORT_QUESTIONS))
@@ -357,7 +358,7 @@ def test_stop_is_reachable_from_scrolled_up(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Turn 1 (settled long answer) overflows the viewport — the user now
# has earlier content to read while turn 2 streams.
@@ -513,7 +514,7 @@ def test_empty_chat_composer_sits_at_the_screen_bottom(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# A fresh visitor: the empty state, nothing to scroll — and yet the
# input is already at the bottom of the screen.
@@ -555,7 +556,7 @@ def test_pin_holds_on_mobile_under_the_header(
page = browser.new_page(viewport=MOBILE)
try:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# An empty phone chat rests the input at the bottom of the screen.
expect(page.locator("#empty-state")).to_be_visible()
+1 -1
View File
@@ -188,7 +188,7 @@ def test_jinja_retrievable_not_deflected(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", JINJA_QUESTION)
page.click("#send-btn")
+24 -6
View File
@@ -72,7 +72,6 @@ from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
@@ -81,6 +80,13 @@ from e2e.conftest import (
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app (and the unconfigured one
# next to it, derived below) bind their own ports instead (a same-port
# second uvicorn dies on bind and would drive the wrong server).
# Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_DOCS", "8127"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The unconfigured app's port (task 07: a second app boot WITHOUT
#: ``BOR_DOCS_REPO`` — a separate fixture on the next port, so it can
@@ -352,9 +358,17 @@ def _stream_chat_answer(app_url: str, message: str) -> str:
"""Replay one turn through the raw SSE endpoint (the
``test_chat_rag.py`` transport pattern) and return the EXACT answer
text — the markdown source the UI accumulates into ``m.text``,
byte-identical for the deterministic mock (same KB, same question)."""
byte-identical for the deterministic mock (same KB, same question).
Phase 79: POST /api/chat is require_user-gated — the replay client
signs in as the admin first (every caller of this helper is an
admin-flow test; the guest flow never reaches it)."""
client = httpx.Client(timeout=120.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0
) as r:
assert r.status_code == 200
@@ -567,10 +581,14 @@ def test_guest_has_no_button(
page.goto(app_url)
expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000)
_ask(page, app_url, QUESTION_1) # the grounded answer streams for guests too
# The "Save as doc" action is admin-only: ABSENT (not hidden) on
# the completed bubble, whatever the docs config says.
# Phase 79 (task 05): the guest meets the token gate — the composer
# is inert behind it, so the guest can never send a turn at all, and
# the admin-only Save-as-doc affordance (which renders only on
# completed brain bubbles) can never appear on their surface.
expect(page.locator("#auth-gate")).to_be_visible(timeout=15_000)
assert page.evaluate("() => document.getElementById('main').inert") is True
expect(page.locator(".msg.brain .bubble")).to_have_count(0)
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
# The draft API 403s anonymous callers (httpx, no cookie at all).
+62 -8
View File
@@ -206,13 +206,13 @@ def test_no_horizontal_overflow_at_viewports(
for width, height in VIEWPORTS:
page = browser.new_page(viewport={"width": width, "height": height})
try:
page.goto(f"{app_url}/")
login(page, app_url, next="/") # phase 79: the chips need a session
page.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
_assert_no_doc_overflow(page, f"chat @ {width}px")
login(page, app_url, next="/sources.html") # phase 16: admin-only
page.goto(f"{app_url}/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:
@@ -309,10 +309,62 @@ def test_a11y_landmarks_and_labels(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC3: landmarks, working skip link, labeled input, named controls,
and a visible :focus-visible outline on every keyboard focusable."""
and a visible :focus-visible outline on every keyboard focusable.
Phase 79 (task 05): the anonymous visitor meets the token gate —
the page body is inert behind it, and the gate's reveal contract
puts focus on the token input — so the skip-link / full-tab-walk
contract is checked for the SIGNED-IN admin (byte-identical to
before), and the gate gets its own focus contract: reveal focuses
the input, and input + submit both show the global 3px
:focus-visible outline."""
def _outline() -> dict[str, str]:
return page.evaluate(
"""() => {
const el = document.activeElement;
const cs = getComputedStyle(el);
return {id: el.id,
cls: String(el.className).split(" ")[0],
outline_style: cs.outlineStyle,
outline_width: cs.outlineWidth};
}"""
)
def _assert_outlined(info: dict[str, str], label: str, path: str) -> None:
width_px = float(info["outline_width"].replace("px", ""))
assert info["outline_style"] == "solid" and width_px >= 2, (
f"no visible focus outline on the gate {label} on {path} "
f"({info['outline_style']} {info['outline_width']})"
)
# --- Anonymous: the token gate's focus contract (phase 79) --------
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
page.wait_for_load_state("networkidle")
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
# Reveal contract: focus lands on the token input — the first
# (and only) body control an anonymous visitor can act on.
info = _outline()
assert info["id"] == "auth-gate-input", (
f"gate reveal must focus the token input ({path}), on {info['id']!r}"
)
_assert_outlined(info, "input", path)
# Tab from the input lands on the gate submit, also outlined.
page.keyboard.press("Tab")
info = _outline()
assert info["cls"] == "auth-gate-submit", (
f"Tab from the gate input must land on the gate submit ({path}), "
f"on {info['id'] or info['cls']!r}"
)
_assert_outlined(info, "submit", path)
# --- Signed-in: the original AC3 contract (admin, gate hidden) ----
login(page, app_url)
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
page.wait_for_load_state("networkidle")
expect(page.locator("#auth-gate")).to_be_hidden()
# Landmarks (PLAN §7.2).
assert page.locator("header.app-header").count() == 1, f"header missing on {path}"
@@ -380,8 +432,10 @@ def test_contrast_pairs_pass_aa(
) -> None:
"""AC4: every PLAN §7.2 color pair computed from the live computed
styles meets WCAG 2.1 AA (>= 4.5:1)."""
# Chat page: ink/surface, white/brand, chip-ink/chip-bg, deflection pair.
page.goto(f"{app_url}/")
# Chat page: ink/surface, white/brand, chip-ink/chip-bg, deflection
# pair. Phase 79: the chips need /api/suggestions (require_user-
# gated) — sign in first; the color pins are auth-independent.
login(page, app_url, next="/")
page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
@@ -451,7 +505,7 @@ def test_reduced_motion_respected(
# Control: default context — the dots run the `typing` animation.
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
expect(page.locator(TYPING)).to_be_visible(timeout=1_000)
@@ -470,7 +524,7 @@ def test_reduced_motion_respected(
)
rpage = context.new_page()
try:
rpage.goto(app_url)
login(rpage, app_url, next="/") # phase 79: chat is require_user-gated
rpage.fill("#message-input", SLOW_QUESTION)
rpage.click("#send-btn")
expect(rpage.locator(TYPING)).to_be_visible(timeout=1_000)
@@ -538,7 +592,7 @@ def test_long_content_wraps_without_overflow(
# Chat @ 375px: unbroken 60-char tokens wrap inside both bubbles.
chat = browser.new_page(viewport={"width": 375, "height": 812})
try:
chat.goto(app_url)
login(chat, app_url, next="/") # phase 79: chat is require_user-gated
chat.set_default_timeout(30_000)
chat.fill("#message-input", f"what do the notes say about {'x' * 60}")
chat.click("#send-btn")
+3 -3
View File
@@ -129,7 +129,7 @@ def test_gitlab_question_is_grounded_with_gitlab_chip(
must land on the tool's own document — not a generic template."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, GITLAB_QUESTION)
expect(page.locator(".msg.user .bubble")).to_contain_text(GITLAB_QUESTION)
@@ -160,7 +160,7 @@ def test_keyword_only_question_beats_vector_ranking(
weak — the lexical branch is what grounds the answer."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, KEYWORD_QUESTION)
bubble = page.locator(".msg.brain .bubble").first
@@ -190,7 +190,7 @@ def test_off_topic_still_deflects_with_chips(
"sourdough" matches nothing in the KB lexically → honest deflection."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, OFF_TOPIC)
bubble = page.locator(".msg.brain.is-deflected .bubble").first
+5 -4
View File
@@ -53,6 +53,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"
@@ -221,7 +222,7 @@ def test_retry_redoes_in_place(
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Two grounded turns: the conversation is [u1, b1, u2, b2].
_ask(page, Q1)
@@ -290,7 +291,7 @@ def test_retry_on_stopped_partial(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_stop_long_answer_mid_stream(page)
@@ -344,7 +345,7 @@ def test_retry_deflected(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask_deflected(page, OFF_TOPIC)
@@ -401,7 +402,7 @@ def test_retry_inert_while_in_flight(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# One completed turn: the last (only) brain bubble carries Retry.
_ask(page, Q1)
+14 -3
View File
@@ -73,7 +73,6 @@ from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
@@ -82,6 +81,11 @@ from e2e.conftest import (
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SAVEDOC", "8129"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
BRANCH = "bor-docs"
@@ -342,9 +346,16 @@ def _stream_chat_answer(app_url: str, message: str) -> str:
answer is a pure function of the LAST user message + the document
context (both identical whether or not the browser's phase-74
history rode along), so a bare replay recovers the same bytes the
multi-turn browser session rendered."""
multi-turn browser session rendered.
Phase 79: POST /api/chat is require_user-gated — the replay client
signs in as the admin first (the caller is an admin-flow test)."""
client = httpx.Client(timeout=120.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0
) as r:
assert r.status_code == 200
+20 -11
View File
@@ -12,10 +12,14 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov
The owner-locked loop under test (2026-08-31):
The owner-locked loop under test (2026-08-31). Phase 79 note: chat is
now ``require_user``-gated (only shared chats stay open), so the
"signed-out visitor" of the L3/L4 pins signs in first — the mechanism
pins (no Save control, silent auto-save, exactly one row, share toast,
unshare, layout) are unchanged.
* **Anonymous auto-save (L4 / A2)** — there is NO Save control in the
DOM at any width; a signed-out visitor's first question auto-upserts
* **Auto-save (L4 / A2)** — there is NO Save control in the
DOM at any width; the visitor's first question auto-upserts
EXACTLY ONE ``saved_chats`` row (the auto-title, both messages) —
verified through the admin's ``GET /api/chats`` (the management
surface stays admin-only, phase 55 task 01) with no button press
@@ -253,9 +257,11 @@ def test_anonymous_auto_save(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
# Anonymous by construction: the page fixture's context never logs
# in — this is the signed-out visitor.
page.goto(app_url + "/")
# Phase 79: chat is require_user-gated — the phase-55 "signed-out
# visitor" no longer exists (shared chats are the only open
# surface), so the default visitor signs in first; every other pin
# (no Save control, silent auto-save, exactly one row) is unchanged.
login(page, app_url, next="/")
# L4: there is NO Save control at any width — the element is gone
# from the DOM (phase 55, task 02), the pills are New chat + Share.
@@ -300,7 +306,7 @@ def test_no_duplicate_row_across_reload(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url + "/")
login(page, app_url, next="/") # phase 79: chat is require_user-gated
q1 = "How is my Kubernetes cluster set up? (save-ux-reload)"
_ask(page, q1)
@@ -350,11 +356,14 @@ def test_anonymous_share_toast_and_unshare(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
# Anonymous by construction — no login anywhere in this test.
page.goto(app_url + "/")
# Phase 79: the phase-55 "no login anywhere" visitor no longer
# exists (chat is gated) — the visitor signs in first; the FRESH
# incognito context below (the read-only shared view) stays
# anonymous, which is the open surface this story pins.
login(page, app_url, next="/")
# L3: the Share pill is visible WITHOUT login (static markup — the
# phase-51 admin-only reveal gate is gone, task 03).
# L3: the Share pill is visible (static markup — the phase-51
# admin-only reveal gate is gone, task 03).
share = page.locator("#share-chat-btn")
expect(share).to_be_visible()
expect(share).to_have_attribute("aria-label", "Share chat")
+4 -3
View File
@@ -75,6 +75,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
from tests.e2e.mock_llm import SEARCH_PATTERN, SEARCH_TRIGGER
REPO = Path(__file__).resolve().parents[2]
@@ -337,7 +338,7 @@ def test_search_flow_searches_and_answers_from_match(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db(mock_llm)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, SEARCH_QUESTION)
@@ -415,7 +416,7 @@ def test_search_adds_no_source_by_itself(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db(mock_llm)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, SEARCH_QUESTION)
@@ -457,7 +458,7 @@ def test_search_tool_line_re_renders_after_reload(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db(mock_llm)
page.goto(app_url)
login(page, app_url, next="/")
_submit(page, SEARCH_QUESTION)
_wait_settled(page)
+11 -3
View File
@@ -194,9 +194,17 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa
expect(nav).to_be_hidden()
if page_kind == "viewer":
# The document itself has settled (rendered, not Loading…/not-found)
# so the bar is being measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# The viewer has settled, so the bar is measured on the real
# page. Phase 79 (task 05): the content endpoint is
# require_user-gated — admin settles on the document title;
# anonymous settles on the inline token gate (#doc-auth-gate —
# the phase-16 not-found-card surface is replaced by the gate:
# the content fetch never runs, the title stays "Loading…").
if admin:
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
else:
expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#doc-title")).to_have_text("Loading…")
# The bar height never moves: 64px desktop / 58px ≤640px (phase 12),
# bounding-box measurement — the new pills must fit inside it.
+7 -1
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import httpx
from playwright.sync_api import Page, expect
from e2e.auth_helpers import login
def test_health_endpoint(app_url: str) -> None:
r = httpx.get(f"{app_url}/api/health", timeout=5)
@@ -26,7 +28,11 @@ def test_index_page_loads_locally(page: Page, app_url: str) -> None:
def test_chat_roundtrip_never_stale_button(page: Page, app_url: str) -> None:
page.goto(app_url)
# Phase 79: chat is require_user-gated (and the in-app token gate
# locks #main for anonymous) — the round-trip signs in as admin
# first (real form login; DB-free, so the story's DB-down error-
# banner path still works).
login(page, app_url, next="/")
page.locator("#message-input").fill("hello brain")
page.locator("#send-btn").click()
+6 -1
View File
@@ -103,13 +103,18 @@ from app.models import Document, GitSource
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_REMOVAL", "8131"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
+3 -3
View File
@@ -424,7 +424,7 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", HESITATE_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -482,7 +482,7 @@ def test_completed_turn_unaffected(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, QUESTION)
# The done save point: full answer + metadata, exactly as phase 14.
@@ -526,7 +526,7 @@ def test_new_chat_still_clears_conversation(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, QUESTION)
assert _stored(page) is not None
+4 -2
View File
@@ -25,6 +25,8 @@ from __future__ import annotations
from playwright.sync_api import Page, expect
from e2e.auth_helpers import login
# The locked replacement copy (owner-locked A1 / A2 / A3 — same literals
# as tests/unit/test_stale_ui_copy.py; the unit pins guard the files,
# this suite guards what the visitor actually sees).
@@ -64,7 +66,7 @@ def test_chat_page_shows_no_homelab_or_deployment_text(
rendered suggestion chips are covered by this body scan (case-
insensitive; a fresh page context has no saved chats, so the empty
state is what renders)."""
page.goto(f"{app_url}/")
login(page, app_url, next="/")
expect(page.locator("#suggestions .suggestion-chip")).to_have_count(len(CHIPS))
body = page.evaluate("() => document.body.innerText.toLowerCase()")
assert "homelab" not in body, f"homelab-era text rendered on the chat page: {body!r}"
@@ -79,7 +81,7 @@ def test_chat_page_suggestion_chips_are_the_locked_list(
"""The four rendered empty-state chips (from ``GET
/api/suggestions``, the code default) are the locked (A2) list, in
order."""
page.goto(f"{app_url}/")
login(page, app_url, next="/")
chips = page.locator("#suggestions .suggestion-chip")
expect(chips).to_have_count(len(CHIPS))
for i, chip in enumerate(CHIPS):
+4 -3
View File
@@ -48,10 +48,11 @@ NOTE = "STEEER-MARKER be concise"
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
#: The shell (index.html — served for BOTH / and /tuning.html since
#: phase 76 task 01, when the Tuning view folded into it) ships exactly
#: FOUR classic/module script tags: the phase-39 brand.js classic layer
#: FIVE classic/module script tags: the phase-39 brand.js classic layer
#: + markdown.js + the chat module (app.js) + the shell router
#: (router.js, which lazy-imports the tuning.js view module).
BASE_SCRIPT_COUNT = 4
#: (router.js, which lazy-imports the tuning.js view module) + the
#: phase-79 token gate (token-gate.js, phase 79 task 05).
BASE_SCRIPT_COUNT = 5
async def _import_fixtures(mock_port: int) -> ImportSummary:
+1
View File
@@ -252,6 +252,7 @@ def test_doc_header_stuck_at_top_on_long_document(
page.set_default_timeout(30_000)
# The dedicated viewer page (phase 10/26 contract) on the generated
# long doc — source ``gen``, path ``doc-long.md``.
login(page, app_url, next="/") # phase 79: the viewer content is gated
page.goto(f"{app_url}/document.html?source=gen&path=doc-long.md")
expect(page.locator("#doc-title")).to_have_text("Sticky long document")
expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
+4 -3
View File
@@ -47,6 +47,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"
@@ -226,7 +227,7 @@ def test_stop_mid_stream(
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
stopped_text = _stop_mid_stream(page)
@@ -266,7 +267,7 @@ def test_stop_pre_token(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_stop_pre_token(page, SLOW_QUESTION, via_enter=False)
@@ -302,7 +303,7 @@ def test_stopped_turn_survives_reload(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
stopped_text = _stop_mid_stream(page)
assert _query_log_count() == 0
+265 -32
View File
@@ -1,37 +1,110 @@
"""Phase 05 E2E (Playwright): onboarding suggestion chips, one-tap submit.
"""Phase 80 E2E (Playwright): onboarding chips = the last 3 questions asked.
Story: ``.agents/user_stories/suggestion-chips.md`` (phase 05) —
REWRITTEN in place for the phase-80 semantics (the phase-76 precedent:
a semantic change rewrites the story suite in place). Source:
``TODO.md`` L6.
The new contract (owner decision A6): the empty-state chip row is the
3 most recent user questions across ALL saved chats — chats walked
newest-``updated_at`` first, each chat's messages newest-first,
exact (case-sensitive) de-duplicated, cap 3. A fresh deployment — zero
saved questions — gets the SEED list instead (``BOR_SUGGESTIONS`` /
the built-in default). 1–2 saved questions → exactly those chips (NO
mixing with the seed). The row refetches when the empty state comes
back (New chat), so it is never stale. The deflection "Maybe try"
chips are a separate contract (``derive_suggestions``) — untouched.
The four states pinned here:
* **seed** — fresh DB (no saved chats) → the chip texts equal the
built-in default list EXACTLY (the ``SEED`` literal below is the
pin for the exact seed list — ``tests/unit/test_config.py`` pins
only the shape) — rendered as accessible buttons in the role=list
group, exactly as the phase-05 component contract;
* **last-3** — two saved chats with 5 user questions total (the older
one saved FIRST — the API stamps ``updated_at``) → a fresh page
load shows EXACTLY the 3 newest questions, newest-first;
* **partial** — exactly 2 saved questions deployment-wide → exactly
2 chips (no seed top-up — the A6 contract, visible in the UI);
* **refetch** — boot with the seed chips, save a chat whose newest
question is Q via the API, click New chat (``#new-chat-btn``) → the
chips now are Q, and the request log shows a SECOND
``GET /api/suggestions`` (the boot fetch was the first).
Carried-over story behavior (unchanged semantics from the phase-05
suite): one-tap submit (chip click → composer filled → submitted →
the mock-LLM brain bubble), Tab+Enter keyboard reachability of the
chips (the keyboard-walk assertion), and the mobile single
horizontal-scroll row.
The endpoint is authed (phase 79, ``require_user``), so every test
signs in as admin first (``auth_helpers.login``). ``saved_chats`` is
global state on the shared e2e Postgres AND the state this contract
reads — the autouse fixture truncates it before and after EVERY test
(including the ones whose turns auto-save a row), so each test starts
from — and leaves — an empty deployment.
Story: ``.agents/user_stories/suggestion-chips.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov
The onboarding row in the empty state renders real ``<button>`` chips from
``GET /api/suggestions`` (settings defaults). Clicking — or Tab + Enter —
fills the composer AND submits: one tap produces a user bubble with the
chip's exact text and a streamed Brain reply. On mobile (375px) the row
becomes a single horizontally scrollable line.
"""
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
import pytest
from playwright.sync_api import Browser, Page, expect
from playwright.sync_api import Browser, Page, Request, 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 login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The EXACT built-in onboarding SEED (phase 80, TODO.md L6): the chip
#: row of a brand-new deployment, shown only before any question has
#: ever been saved. This literal is the E2E pin for the exact seed
#: list — ``tests/unit/test_config.py`` pins only the SHAPE (>=3
#: non-blank distinct strings), and the e2e app under test is forced
#: to the code default by conftest's leak guard — keep in sync with
#: the ``Settings.suggestions`` default in ``app/config.py``.
SEED: list[str] = [
"What documents are in the knowledge base?",
"Which source does each answer come from?",
"How do I add a new source?",
"Summarize the most recent document.",
]
@pytest.fixture(autouse=True)
def clean_chats(db_ready: None) -> Iterator[None]:
"""``saved_chats`` is the state the phase-80 contract reads:
truncate it before and after every test so each state test starts
from (and leaves) an empty deployment. Unlike the KB tables, this
reset is non-optional — the chips ARE these rows, and the
carried-over submit tests auto-save a row per turn, which would
otherwise leak into the later state tests."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
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"}
@@ -58,7 +131,9 @@ def _run_in_thread(coro: Any) -> Any:
def _seed_kb(mock_port: int) -> ImportSummary:
"""Deterministic KB: truncate everything, import the fixture docs."""
"""Deterministic KB: truncate the KB tables, import the fixture
docs (needed by the carried-over submit tests' grounded answers).
``saved_chats`` is the autouse fixture's job."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
@@ -67,42 +142,75 @@ def _seed_kb(mock_port: int) -> ImportSummary:
return summary
def _api_suggestions(app_url: str) -> list[str]:
body = httpx.get(f"{app_url}/api/suggestions", timeout=10).json()
return body["suggestions"]
def _user(q: str) -> dict[str, Any]:
return {"who": "user", "text": q}
def _brain(text: str = "Grounded mock brain reply.") -> dict[str, Any]:
return {"who": "brain", "text": text}
def _save_chat(page: Page, app_url: str, messages: list[dict[str, Any]]) -> dict[str, Any]:
"""Save one conversation as the signed-in admin (``POST /api/chats``)
and return the 201 body. ``updated_at`` is the API's stamp (server
``now()`` at INSERT) — the save ORDER is what makes the chip-walk
order deterministic in the state tests."""
r = page.request.post(
f"{app_url}/api/chats",
data=json.dumps({"messages": messages}),
headers={"Content-Type": "application/json"},
timeout=10_000,
)
assert r.status == 201, r.text
return r.json()
def _api_suggestions(page: Page, app_url: str) -> list[str]:
# Phase 79: the endpoint is require_user-gated — the request rides
# the page's signed-in context (each test signs in above).
r = page.request.get(f"{app_url}/api/suggestions", timeout=10)
assert r.status == 200, r.text
return r.json()["suggestions"]
def _chip_locator(page: Page) -> Any:
return page.locator("#suggestions .suggestion-chip")
def test_onboarding_chips_render(
page: Page, app_url: str, mock_llm: int, db_ready: None
def _chip_texts(page: Page) -> list[str]:
return [c.strip() for c in _chip_locator(page).all_inner_texts()]
def test_seed_state_chips_are_the_builtin_default(
page: Page, app_url: str, db_ready: None
) -> None:
_seed_kb(mock_llm)
"""Fresh deployment (no saved chats) → the chip row is EXACTLY the
built-in seed list — texts, count, and order — rendered in
``#suggestions`` (role="list") as accessible buttons, exactly as
the phase-05 component contract."""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Accessible group: role=list + a name screen readers can announce.
group = page.locator("#suggestions")
expect(group).to_have_attribute("role", "list")
expect(group).to_have_attribute("aria-label", "Suggested questions")
# 3+ visible chips, real buttons, each with non-empty text — and the
# texts match what the API returned (chips are drawn from the endpoint).
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert chips.count() >= 3
api_texts = _api_suggestions(app_url)
# The EXACT seed list, in order (the E2E pin — see the SEED literal).
assert _chip_texts(page) == SEED
# ...drawn from the endpoint (the API returns the same exact list).
assert _api_suggestions(page, app_url) == SEED
# Real buttons, each with non-empty text, one per seed entry.
assert chips.count() == len(SEED)
for i in range(chips.count()):
chip = chips.nth(i)
expect(chip).to_be_visible()
expect(chip).to_have_attribute("type", "button")
expect(chip).to_have_attribute("role", "listitem")
text = chip.inner_text().strip()
assert text, "every chip needs non-empty label text"
assert text in api_texts
assert len(set(api_texts)) >= 3
assert chip.inner_text().strip(), "every chip needs non-empty label text"
# Chips live in the empty state, which is visible before any message.
expect(page.locator("#empty-state")).to_be_visible()
@@ -115,17 +223,136 @@ def test_onboarding_chips_render(
assert box is not None and box["height"] >= 44
def test_last_three_questions_state(page: Page, app_url: str, db_ready: None) -> None:
"""5 user questions across two saved chats (the older one saved
FIRST — the API stamps ``updated_at`` at INSERT) → a fresh page
load shows EXACTLY the 3 newest questions, newest-first: the
newer chat is walked first, then the older chat newest-first."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
old_q = [
"How did I install the GitLab runner on the Proxmox node?",
"Which disk holds the Borg backup archives?",
"How is the nftables firewall rule set ordered?",
]
new_q = [
"What TLS termination does Traefik do for homelab.local?",
"Which provider is the primary DNS for reeseapps.com?",
]
# The OLDER chat first: the API stamps ``updated_at`` (server
# now()), so save order IS walk order. The short pause keeps the
# two stamps strictly apart (and the assert below pins that the
# order the walk sees is the order the test intended).
older = _save_chat(
page,
app_url,
[
_user(old_q[0]), _brain(),
_user(old_q[1]), _brain(),
_user(old_q[2]), _brain(),
],
)
time.sleep(0.05)
newer = _save_chat(
page,
app_url,
[
_user(new_q[0]), _brain(),
_user(new_q[1]), _brain(),
],
)
assert datetime.fromisoformat(newer["updated_at"]) > datetime.fromisoformat(
older["updated_at"]
), "the two API-stamped updated_at values must be strictly ordered"
# A FRESH page load (a new boot fetch, not the pre-save boot):
# the chips are exactly the 3 newest questions, newest first.
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
expected = [new_q[1], new_q[0], old_q[2]]
assert _chip_texts(page) == expected
assert _api_suggestions(page, app_url) == expected
# The two older questions (and everything seed-shaped) are gone.
assert old_q[0] not in _chip_texts(page)
assert old_q[1] not in _chip_texts(page)
def test_partial_state_no_seed_topup(page: Page, app_url: str, db_ready: None) -> None:
"""Exactly 2 saved questions deployment-wide → EXACTLY 2 chips
(newest first) — NO mixing/top-up with the seed (the A6
contract, visible in the UI)."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
a = "How do I rotate the WireGuard keys on the VPN node?"
b = "What cron schedule runs the restic prune?"
_save_chat(page, app_url, [_user(a), _brain()])
time.sleep(0.05)
_save_chat(page, app_url, [_user(b), _brain()])
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert chips.count() == 2, "exactly 2 chips — the row is never padded toward 3"
texts = _chip_texts(page)
assert texts == [b, a]
assert not (set(texts) & set(SEED)), "no seed text may appear once a question is saved"
def test_new_chat_refetches_the_chips(page: Page, app_url: str, db_ready: None) -> None:
"""The row is never stale: boot with the seed chips → save a chat
whose newest question is Q via the API → click New chat
(``#new-chat-btn``) → the empty state comes back with the
REFETCHED row (exactly Q — the deployment now has one saved
question), and the request log shows a SECOND
``GET /api/suggestions`` (the boot fetch was the first)."""
page.set_default_timeout(30_000)
sugg_gets: list[float] = []
def on_request(req: Request) -> None:
if req.url.endswith("/api/suggestions"):
sugg_gets.append(time.monotonic())
page.on("request", on_request)
login(page, app_url, next="/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert _chip_texts(page) == SEED, "boot state: the seed row"
assert len(sugg_gets) == 1, "exactly one GET /api/suggestions at boot"
q = "Which service fronts the Pi-hole DNS on the network?"
_save_chat(page, app_url, [_user(q), _brain()])
clicked_at = time.monotonic()
page.click("#new-chat-btn")
# The refetch re-renders #suggestions in place: the 4 seed chips
# are replaced by exactly Q (the partial state, live).
expect(chips).to_have_count(1, timeout=15_000)
expect(chips.first).to_have_text(q, timeout=15_000)
assert len(sugg_gets) == 2, "New chat triggered the refetch"
assert sugg_gets[1] > clicked_at, "the second GET is AFTER the click — the refetch"
def test_chip_click_submits(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Carried over (phase 05, unchanged semantics): one tap = one
question — the click fills AND submits; a grounded mock reply
follows. The chip submitted is the SEED row's first entry (the
autouse fixture guarantees the seed state)."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
first = _chip_locator(page).first
expect(first).to_be_visible(timeout=30_000)
chip_text = first.inner_text().strip()
assert chip_text
assert chip_text == SEED[0]
# One tap = one question: the click fills AND submits.
first.click()
@@ -148,9 +375,12 @@ def test_chip_click_submits(
def test_chips_keyboard_accessible(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Carried over (phase 05, unchanged semantics): the first chip is
keyboard-reachable BEFORE the composer input (skip-link + nav
links come first), and Enter activates it — submitting."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
first = _chip_locator(page).first
expect(first).to_be_visible(timeout=30_000)
chip_text = first.inner_text().strip()
@@ -193,13 +423,16 @@ def test_chips_keyboard_accessible(
def test_chips_mobile_row(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
browser: Browser, app_url: str, db_ready: None
) -> None:
_seed_kb(mock_llm)
"""Carried over (phase 05, unchanged semantics): on mobile (375px)
the row is a single horizontally scrollable line — the seed state
(4 chips) overflows into scroll, nothing wraps, chips stay
>=44px tall on one line."""
page = browser.new_page(viewport={"width": 375, "height": 720})
try:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
row = page.locator("#suggestions")
expect(row).to_be_visible(timeout=30_000)
+19 -10
View File
@@ -24,8 +24,11 @@ This phase only surfaces the stored field: the content endpoint returns
``summary`` (task 01) and the shared ``renderDocument`` core draws the
labeled ``.doc-summary`` panel above the content on BOTH surfaces (task
02) — the full-page viewer and the chat/sources modal. The tests assert
exactly that contract, plus the phase-16 soft rule: the content
endpoint stays public (anonymous fetch → 200, no admin cookie needed).
exactly that contract. Phase 79 supersedes the phase-16 soft rule: the
content endpoint is ``require_user``-gated, so the viewer surfaces and
the API shape pin run under a signed-in session (the shape itself —
``summary`` for the yaml, ``null`` for the markdown control — is
unchanged).
"""
from __future__ import annotations
@@ -140,6 +143,7 @@ def test_full_page_shows_summary_and_original_together(
cannot contain, is rendered in the raw ``<pre>``."""
_reset_db_and_import(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: the viewer content is gated
digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
page.goto(f"{app_url}/document.html?source={SOURCE}&path={YAML_URL_PATH}")
@@ -279,6 +283,7 @@ def test_markdown_doc_has_no_summary_panel(
before (first child of the content container is the doc body)."""
_reset_db_and_import(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: the viewer content is gated
# Full page: no panel, markdown column untouched.
page.goto(f"{app_url}/document.html?source={SOURCE}&path={MD_URL_PATH}")
@@ -294,7 +299,8 @@ def test_markdown_doc_has_no_summary_panel(
assert order == ["doc-md"], f"markdown doc gained children: {order}"
# Modal: same story — no panel, .doc-md is the sole content child.
login(page, app_url)
# (the session is already signed in — the form login above)
page.goto(f"{app_url}/sources.html")
row = page.locator("#docs-tbody tr", has_text=MD_PATH)
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
@@ -312,18 +318,21 @@ def test_markdown_doc_has_no_summary_panel(
# ---------------------------------------------------------------------------
# 4. API shape (cheap, via the page context's request — still anonymous)
# 4. API shape (cheap, via the page context's request — signed in since
# phase 79 gated the endpoint)
# ---------------------------------------------------------------------------
def test_content_api_summary_shape_anonymous(
def test_content_api_summary_shape(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""``GET /api/documents/content`` carries ``summary`` — the string
for the summarized yaml, ``null`` for the markdown control — and
stays PUBLIC: no admin cookie is set anywhere in this test, so both
200s prove the phase-16 soft rule (viewer public) is unchanged."""
for the summarized yaml, ``null`` for the markdown control. Phase 79
superseded the phase-16 soft rule: the endpoint is require_user, so
the pin runs under the form-login session (the shape itself is
unchanged)."""
_reset_db_and_import(mock_llm)
login(page, app_url, next="/")
digest_line, _ = _summary_lines(SOURCE, YAML_PATH)
expected_summary = f"{digest_line}\nSource: {SOURCE}/{YAML_PATH}"
@@ -331,7 +340,7 @@ def test_content_api_summary_shape_anonymous(
resp = page.context.request.get(
f"{app_url}/api/documents/content?source={SOURCE}&path={YAML_URL_PATH}"
)
assert resp.status == 200, "anonymous viewer access must stay public"
assert resp.status == 200
body = resp.json()
assert body["source"] == SOURCE
assert body["path"] == YAML_PATH
@@ -343,7 +352,7 @@ def test_content_api_summary_shape_anonymous(
resp_md = page.context.request.get(
f"{app_url}/api/documents/content?source={SOURCE}&path={MD_URL_PATH}"
)
assert resp_md.status == 200, "anonymous viewer access must stay public"
assert resp_md.status == 200
md_body = resp_md.json()
assert md_body["format"] == "md"
assert md_body["summary"] is None, "markdown docs never carry a summary"
+10 -2
View File
@@ -44,13 +44,18 @@ from app.models import KbOverview
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCBTN", "8132"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The unique sentinel inside the fixture doc (task 03 step 1) — its
@@ -162,7 +167,10 @@ def _truncate_kb() -> None:
"""Fresh KB per test (the E2E isolation pattern): the sync's counts
and the ``kb_overview`` row must be the sync's own doing."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview"))
# git_sources too: the shared Postgres may carry leftover
# registry rows from other suites, and effective_sources prefers
# the DB list over this app's BOR_GIT_SOURCES env fallback.
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
db.commit()
+12 -3
View File
@@ -65,13 +65,19 @@ from app.models import KbOverview
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module's healthy app binds its own port
# instead (a same-port second uvicorn dies on bind and would drive the
# wrong server); the dead-model app stays derived from it. Env-
# overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCDOWN", "8134"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The dead-model app's port — distinct so the isolated run never
#: clashes with the session app (``APP_PORT``) or the mock (8901).
@@ -241,9 +247,12 @@ def app_url(healthy_app_url: str) -> str:
def _truncate_kb() -> None:
"""Fresh KB per test (the E2E isolation pattern): any counts and the
``kb_overview`` row the healthy run produces are its own doing."""
``kb_overview`` row the healthy run produces are its own doing.
``git_sources`` too — the shared Postgres may carry leftover
registry rows that would take precedence over the apps' env
fallback repos."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview"))
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
db.commit()
+6 -1
View File
@@ -102,7 +102,6 @@ from app.models import GitSource
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
@@ -110,6 +109,12 @@ from e2e.conftest import (
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCUP", "8133"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
+6 -5
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"
@@ -134,7 +135,7 @@ def test_thinking_block_streams_open_then_collapses(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", THINK_QUESTION)
page.evaluate("() => document.querySelector('#composer').requestSubmit()")
expect(page.locator(".msg.user .bubble").last).to_contain_text(THINK_QUESTION)
@@ -172,7 +173,7 @@ def test_thinking_block_streams_open_then_collapses(
def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_QUESTION)
last = page.locator(".msg.brain").last
@@ -206,7 +207,7 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -
def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_QUESTION)
# The live block is collapsed; capture what it shows and what the
@@ -245,7 +246,7 @@ def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: Non
def test_no_thinking_block_without_trigger(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, PLAIN_QUESTION)
# No trigger → no thinking events → no block anywhere on the page.
@@ -265,7 +266,7 @@ def test_no_thinking_block_without_trigger(page: Page, app_url: str, seeded_kb:
def test_thinking_with_deflection(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_DEFLECT_QUESTION)
last = page.locator(".msg.brain").last
+9 -8
View File
@@ -94,6 +94,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
from e2e.mock_llm import compose_thinking
REPO = Path(__file__).resolve().parents[2]
@@ -288,7 +289,7 @@ def test_thinking_window_user_scrollable(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, HESITATE_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -391,7 +392,7 @@ def test_thinking_window_follows_while_pinned(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, HESITATE_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -462,7 +463,7 @@ def test_thinking_window_stops_on_scroll_up(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, HESITATE_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -523,7 +524,7 @@ def test_thinking_window_resumes_on_return(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, HESITATE_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -595,7 +596,7 @@ def test_thinking_window_css_contract(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_QUESTION)
details = page.locator(".msg.brain").last.locator("details.thinking")
@@ -625,7 +626,7 @@ def test_answer_bubble_still_scrollable(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(45_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
@@ -690,7 +691,7 @@ def test_restored_collapsed_thinking_unaffected(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_QUESTION)
details = page.locator(".msg.brain").last.locator("details.thinking")
@@ -734,7 +735,7 @@ def test_thinking_window_follows_across_paragraph_breaks(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, PARAS_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(PARAS_QUESTION)
+39
View File
@@ -0,0 +1,39 @@
"""Quick diagnostic: does the toggle still work after SPA navigation?"""
from playwright.sync_api import expect
from e2e.auth_helpers import login
def test_toggle_after_spa_nav(browser, app_url, db_ready):
"""After SPA navigation, the hamburger toggle should still open the menu."""
page = browser.new_page(viewport={"width": 375, "height": 812})
try:
login(page, app_url, next="/")
# Wait for whoami to settle (admin)
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
)
# Open the menu
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
# Navigate to a different view via SPA
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html", timeout=15_000)
# Menu should be closed
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
# NOW try to open the menu again - THIS IS THE BUG CHECK
page.click("#nav-toggle")
try:
expect(page.locator("#nav-toggle")).to_have_attribute(
"aria-expanded", "true", timeout=3000
)
print("PASS: Toggle still works after SPA navigation")
except Exception as e:
print(f"FAIL: Toggle does NOT work after SPA navigation: {e}")
finally:
page.close()
+3 -2
View File
@@ -70,6 +70,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document
from e2e.auth_helpers import login
from tests.e2e.mock_llm import (
LS_TEACH_TRIGGER,
TOOLS_TRIGGER,
@@ -367,7 +368,7 @@ def test_ls_misuse_self_corrects_to_noarg_listing(
) -> None:
page.set_default_timeout(30_000)
_reset_db_fixture()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, LS_TEACH_QUESTION)
@@ -421,7 +422,7 @@ def test_plain_tool_flow_not_swallowed_by_new_trigger(
) -> None:
page.set_default_timeout(30_000)
_reset_db_fixture()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
# Turn 1 — the LS-TEACH flow (the incident's misuse → the
@@ -70,6 +70,7 @@ from sqlalchemy import select, text
from app.db import SessionLocal
from app.models import QueryLog
from e2e.auth_helpers import login
from tests.e2e.mock_llm import SCAFFOLD_RECOVERY_ANSWER, compose_answer
# --------------------------------------------------------------------------
@@ -291,7 +292,7 @@ def test_recovery_strips_scaffolding_and_streams_clean_answer(
) -> None:
page.set_default_timeout(30_000)
_reset_db_empty()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, RECOVERY_Q)
@@ -345,7 +346,7 @@ def test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable(
) -> None:
page.set_default_timeout(30_000)
_reset_db_empty()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, TERMINAL_Q)
@@ -406,7 +407,7 @@ def test_plain_turn_never_recovers_and_streams_byte_clean(
) -> None:
page.set_default_timeout(30_000)
_reset_db_empty()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, PLAIN_Q)
+2 -1
View File
@@ -40,6 +40,7 @@ from app.models import Chunk, Document, QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.retriever import TRUNCATION_MARKER
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
REPO = Path(__file__).resolve().parents[2]
@@ -172,7 +173,7 @@ def _run_in_thread(coro: Any) -> Any:
def _ask(page: Page, app_url: str, question: str) -> Any:
"""Submit *question* and wait for the streamed brain bubble."""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble")
+6 -4
View File
@@ -278,7 +278,7 @@ def test_chat_column_wide_vs_base(
at 1280×800 — the base below the breakpoint."""
wide = browser.new_page(viewport=WIDE_VIEWPORT)
try:
wide.goto(f"{app_url}/")
login(wide, app_url, next="/") # phase 79: the chips need a session
wide.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
@@ -289,7 +289,7 @@ def test_chat_column_wide_vs_base(
base = browser.new_page(viewport=BASE_VIEWPORT)
try:
base.goto(f"{app_url}/")
login(base, app_url, next="/") # phase 79: the chips need a session
base.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
@@ -316,6 +316,7 @@ def test_document_column_wide_vs_base(
wide = browser.new_page(viewport=WIDE_VIEWPORT)
try:
login(wide, app_url, next="/") # phase 79: the viewer content is gated
wide.goto(app_url + FIXTURE_DOC_URL)
expect(wide.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
expect(wide.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
@@ -331,6 +332,7 @@ def test_document_column_wide_vs_base(
base = browser.new_page(viewport=BASE_VIEWPORT)
try:
login(base, app_url, next="/") # phase 79: the viewer content is gated
base.goto(app_url + FIXTURE_DOC_URL)
expect(base.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
expect(base.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
@@ -410,7 +412,7 @@ def test_narrow_unchanged(
content box instead, so 736px is the discriminator)."""
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
phone.goto(f"{app_url}/")
login(phone, app_url, next="/") # phase 79: the chips need a session
phone.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
@@ -421,7 +423,7 @@ def test_narrow_unchanged(
tablet = browser.new_page(viewport={"width": 900, "height": 800})
try:
tablet.goto(f"{app_url}/")
login(tablet, app_url, next="/") # phase 79: the chips need a session
tablet.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
+53 -4
View File
@@ -1,11 +1,25 @@
"""Integration tests: HTTP API surface (no database required)."""
"""Integration tests: HTTP API surface (mostly no database required).
Phase 79 note: the user-gated endpoints (chat, suggestions) are driven
here by a signed-in ADMIN client — the admin path of ``require_user``
short-circuits before any DB touch, so this module stays database-free
(the 401 auth contract itself is pinned in ``test_auth_api.py``).
Phase 80 note: the suggestions pins are the exception — the chips are
the last 3 questions asked once any are saved, so the env-override
pin (the override is the SEED) needs an empty ``saved_chats``;
the full state matrix lives in ``test_suggestions_api.py``.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import get_settings
from tests.conftest import ADMIN_PASSWORD
def test_health_reports_ok(client) -> None:
@@ -114,16 +128,28 @@ def test_config_docs_flag_tracks_settings(client) -> None:
def test_suggestions_returns_list(client) -> None:
# Phase 79: the chips are user-gated — sign in as the admin first
# (the test's purpose is the list shape, not the auth contract).
# Phase 80: the chips are the last 3 questions asked OR the seed —
# the per-state exact lists are pinned in test_suggestions_api.py;
# here the DB-free shape pin holds in EVERY state: a list of
# non-blank strings (1–3 chips once questions exist, the seed
# while none do).
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.get("/api/suggestions")
assert r.status_code == 200
suggestions = r.json()["suggestions"]
assert isinstance(suggestions, list)
assert len(suggestions) >= 3
assert all(isinstance(s, str) and s.strip() for s in suggestions)
def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
"""GET /api/suggestions reflects the BOR_SUGGESTIONS JSON env override."""
def test_suggestions_honors_bor_suggestions_env_override(
monkeypatch, db: Session
) -> None:
"""GET /api/suggestions reflects the BOR_SUGGESTIONS JSON env
override — as the SEED (phase 80): it appears while ZERO questions
have been saved, so the pin needs an empty ``saved_chats`` (the
full state matrix is test_suggestions_api.py)."""
from fastapi.testclient import TestClient
from app.main import create_app
@@ -134,13 +160,22 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
"How do I deploy a service?",
"What proxy fronts reeseapps.com?",
]
db.execute(text("TRUNCATE saved_chats"))
db.commit()
get_settings.cache_clear()
try:
monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(override))
fresh_client = TestClient(create_app())
finally:
get_settings.cache_clear()
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# Phase 79: sign the fresh client in as the admin (the chips are
# user-gated; the override's value is what this test pins).
assert fresh_client.post(
"/api/login", json={"password": ADMIN_PASSWORD}
).status_code == 204
r = fresh_client.get("/api/suggestions")
assert r.status_code == 200
assert r.json() == {"suggestions": override}
@@ -167,6 +202,10 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
# shell-body marker (the History view section is inside the
# shell; the old standalone page's title is client-side now).
("/history.html", 'id="view-history"'), # phase 76: shell route (was "Saved chats")
# Phase 79 (task 06): /tokens.html is a SHELL route too — the
# shell-body marker (the Tokens view section is inside the
# shell; the per-view title is client-side now).
("/tokens.html", 'id="view-tokens"'), # phase 79: shell route
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
],
)
@@ -206,6 +245,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
"path",
["/sources.html", "/document.html", "/login.html", "/tuning.html",
"/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03)
"/tokens.html", # phase 79 task 06: + Tokens (shell route)
"/shared.html"], # phase 51: + the anonymous shared page
)
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
@@ -226,6 +266,11 @@ def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
("/sources.html", 'id="view-rag"', "Sources · Brain of Reese"),
("/git-sources.html", 'id="view-git-sources"', "Git sources · Brain of Reese"),
("/history.html", 'id="view-history"', "Saved chats · Brain of Reese"), # phase 76 task 03
# phase 79 task 06: the sixth view — there was never a
# standalone tokens.html, so "old_title" is the router's
# client-side title: the pin asserts the shell never carries
# the per-view title statically (the router writes it).
("/tokens.html", 'id="view-tokens"', "Access tokens · Brain of Reese"), # phase 79 task 06
],
)
def test_shell_routes_serve_the_shell_no_cache_versioned(
@@ -374,5 +419,9 @@ def test_ui_chrome_has_no_emoji(client, path: str) -> None:
def test_chat_requires_message(client) -> None:
"""The empty-message 422 validation pin (phase 79: the anonymous
caller now 401s BEFORE validation — sign in as the admin so this
test keeps testing validation, not the auth contract)."""
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.post("/api/chat", json={"message": ""})
assert r.status_code == 422
+254 -19
View File
@@ -1,11 +1,18 @@
"""Integration: the auth surface (phase 16) + the public-API regression
guards.
"""Integration: the auth surface (phase 16 admin; phase 79 token users).
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.
Covers the full admin 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 phase-79
token flows: ``POST /api/token-auth`` (valid → 204 + the ``user`` role;
invalid / malformed / revoked / empty → ONE generic 401), the
three-role ``whoami``, logout clearing the token session, and the
LIVE revocation check (a revoked token is refused on the next request
and the dead session's keys are dropped — whoami falls to anonymous).
The phase-16 anonymous pins are SUPERSEDED by the phase-79 contract:
the ONLY anonymous content is the shared chats (plus the login/infra
endpoints the gate itself needs) — anonymous chat / suggestions /
document content now 401 ``authentication required``.
Requires: podman compose up -d db
"""
@@ -32,11 +39,16 @@ 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"))
"""Docs + steering + query log + tokens are global state: reset
around tests."""
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, api_tokens")
)
db.commit()
yield
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, api_tokens")
)
db.commit()
@@ -54,7 +66,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
def _seed_one_doc(db) -> Document:
"""One minimal document (for the public document-content endpoint)."""
"""One minimal document (for the user-gated document-content endpoint)."""
doc = Document(
source="docs",
path="homelab/kubernetes.md",
@@ -69,6 +81,28 @@ def _seed_one_doc(db) -> Document:
return doc
def _admin_client() -> TestClient:
"""A SEPARATE client signed in as the admin — for tests that need an
admin and a token user at the same time (the shared ``client``
fixture is the token holder in those)."""
admin = TestClient(fastapi_app)
r = admin.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
return admin
def _create_token(admin: TestClient, label: str = "alice") -> tuple[str, str]:
"""Admin issues one token; returns ``(token_id, plaintext)`` — the
plaintext is the 201 body's one-and-only wire moment (task 02)."""
r = admin.post("/api/tokens", json={"label": label})
assert r.status_code == 201, r.text
body = r.json()
return body["id"], body["token"]
# ---------- phase 16: the admin lifecycle (unchanged contract) ----------
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
@@ -93,7 +127,8 @@ def test_wrong_password_401_and_still_gated(client: TestClient) -> None:
def test_login_logout_lifecycle(client: TestClient) -> None:
# Anonymous shape before anything.
# Anonymous shape before anything (phase 79: the anonymous whoami
# gains the explicit role — the UI keys its admin surfaces off it).
who = client.get("/api/whoami").json()
assert who == {"authenticated": False, "role": "anonymous"}
@@ -145,8 +180,205 @@ def test_forged_cookie_is_rejected(client: TestClient) -> None:
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."""
# ---------- phase 79: the token login + the user role ----------
def test_token_auth_valid_signs_in_token_user(client: TestClient) -> None:
"""A valid token → 204 + session cookie → whoami reports the THIRD
role: ``{authenticated: true, role: "user"}``."""
admin = _admin_client()
_token_id, token = _create_token(admin)
r = client.post("/api/token-auth", json={"token": token})
assert r.status_code == 204
assert "bor_session" in client.cookies # the signed session cookie
assert client.get("/api/whoami").json() == {
"authenticated": True,
"role": "user",
}
def test_token_auth_all_failures_share_one_generic_401(client: TestClient) -> None:
"""Malformed / unknown / revoked / empty are INDISTINGUISHABLE — one
401 ``{"detail": "invalid token"}`` for every failure shape (the
phase-16 no-enumeration pattern; a 422 would hint at input-shape
differences on a credential endpoint)."""
admin = _admin_client()
_token_id, token = _create_token(admin)
failures = [
token + "0", # unknown (no row for this hash)
"bor_" + "0" * 32, # well-shaped, never issued
"short", # malformed: too short
"wrongprefix_" + "0" * 32, # malformed: wrong prefix
"", # empty
" ", # whitespace only
]
for bad in failures:
r = client.post("/api/token-auth", json={"token": bad})
assert r.status_code == 401, bad
assert r.json() == {"detail": "invalid token"}, bad
assert "bor_session" not in client.cookies # no session on failure
# A failed attempt must not sign anyone in.
assert client.get("/api/whoami").json() == {
"authenticated": False,
"role": "anonymous",
}
# Revoked → the SAME 401 (not a different "revoked" message).
_bob_id, bob_token = _create_token(admin, "bob")
bob_id = next(
t["id"] for t in admin.get("/api/tokens").json()["tokens"] if t["label"] == "bob"
)
assert admin.post(f"/api/tokens/{bob_id}/revoke").status_code == 204
r = client.post("/api/token-auth", json={"token": bob_token})
assert r.status_code == 401
assert r.json() == {"detail": "invalid token"}
def test_token_user_surface_matrix(client: TestClient, db, seeded_kb: FakeRagLLM) -> None:
"""The owner's sentence (phase 79): a token user gets the app surface
— chat streams, suggestion chips, cited document content — and NO
admin surface (tokens, docs list, saved chats, steering, git
sources stay 403 for them; saved chats have no per-user
attribution)."""
admin = _admin_client()
_token_id, token = _create_token(admin)
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
# Chat streams (the mocked LLM — the real turn pipeline).
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")
assert frames[-1]["type"] == "done"
assert frames[-1]["deflected"] is False
assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md"
# Suggestion chips + the cited document's content (the viewer).
assert client.get("/api/suggestions").status_code == 200
r = client.get(
"/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
)
assert r.status_code == 200
assert r.json()["title"] == "Kubernetes Homelab Cluster"
# The admin surface stays admin-only for a token user (A3 scope).
for path in ("/api/tokens", "/api/docs", "/api/chats", "/api/steering", "/api/git-sources"):
r = client.get(path)
assert r.status_code == 403, path
assert r.json() == {"detail": "admin only"}, path
def test_token_user_cannot_create_or_list_tokens(client: TestClient) -> None:
"""The token ADMIN surface is closed to token holders on every
method, not just GET (A3: only the admin manages tokens)."""
admin = _admin_client()
_token_id, token = _create_token(admin)
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
assert client.post("/api/tokens", json={"label": "evil"}).status_code == 403
assert client.get("/api/tokens").status_code == 403
assert client.post("/api/tokens/00000000-0000-0000-0000-000000000000/revoke").status_code == 403
# The admin's own token list is untouched.
assert [t["label"] for t in admin.get("/api/tokens").json()["tokens"]] == ["alice"]
def test_logout_clears_token_session(client: TestClient) -> None:
"""One logout, one session dict: a token holder's logout drops the
``user`` role — whoami anonymous and the gated surface 401s again."""
admin = _admin_client()
_token_id, token = _create_token(admin)
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
assert client.get("/api/whoami").json()["role"] == "user"
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/suggestions").status_code == 401
assert client.post("/api/chat", json={"message": "hi"}).status_code == 401
def test_revocation_mid_session_enforced_live(
client: TestClient, db, seeded_kb: FakeRagLLM
) -> None:
"""The live check (A4): a token revoked MID-SESSION is refused on
the holder's next request — chat 401 — and the dead session's keys
are dropped right there (whoami falls to anonymous without a
logout)."""
admin = _admin_client()
token_id, token = _create_token(admin)
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
assert client.get("/api/whoami").json()["role"] == "user"
# The turn BEFORE the revocation still streams.
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
status, _ct, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert status == 200
assert frames[-1]["type"] == "done"
# The admin revokes (idempotent 204 from task 02).
assert admin.post(f"/api/tokens/{token_id}/revoke").status_code == 204
# The NEXT request is refused — 401, not a stream — and the session
# is dead (the live check popped both user keys).
r = client.post("/api/chat", json={"message": "hello"})
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
assert client.get("/api/whoami").json() == {
"authenticated": False,
"role": "anonymous",
}
# …and a FRESH login attempt with the revoked token fails too (the
# token itself is dead, not just this session).
r = client.post("/api/token-auth", json={"token": token})
assert r.status_code == 401
assert r.json() == {"detail": "invalid token"}
# ---------- phase 79: the enforcement matrix (the 401 contract) ----------
def test_anonymous_chat_now_401s(client: TestClient, db) -> None:
"""The phase-16 "chat still streams anonymously" pin is SUPERSEDED:
anonymous chat gets a plain 401 ``authentication required`` (401,
not 403 — no higher privilege would unblock them)."""
r = client.post("/api/chat", json={"message": "hello"})
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
def test_anonymous_suggestions_and_document_content_401(client: TestClient, db) -> None:
"""The other two gated endpoints share the same 401 contract."""
r = client.get("/api/suggestions")
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
_seed_one_doc(db)
r = client.get(
"/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
)
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
def test_document_content_admin_contract(client: TestClient, db) -> None:
"""The phase-16 soft rule (viewer public) is superseded: the viewer
content is token-or-admin — the admin gets the full DocContent
shape, unknown pairs still 404 (no enumeration of titles)."""
client.post("/api/login", json={"password": ADMIN_PASSWORD})
_seed_one_doc(db)
r = client.get(
@@ -168,15 +400,18 @@ def test_anonymous_document_content_stays_public(client: TestClient, db) -> None
}
assert body["summary"] is None
# Unknown docs still 404 anonymously (no enumeration of titles).
# Unknown docs still 404 (same shape as the phase-16 pin).
r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"})
assert r.status_code == 404
assert r.json() == {"detail": "document not found"}
def test_anonymous_chat_still_streams(
client: TestClient, seeded_kb: FakeRagLLM
) -> None:
"""Regression guard: sign-in must not have locked chat (A10 public)."""
def test_admin_chat_still_streams(client: TestClient, db, seeded_kb: FakeRagLLM) -> None:
"""The streaming assertions of the old anonymous pin survive under a
signed-in (admin) client — sign-in locked out strangers, not the
owner."""
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
status, content_type, frames = _stream_chat(client, QUESTION)
@@ -74,8 +74,9 @@ def file_validators(page_file: Path) -> tuple[str, str]:
#: computation below must use the file that actually backs the
#: response, or the conditional-GET probe would carry a validator no
#: browser ever saw. Tasks 02/03 extended this as the remaining views
#: folded in (task 03 — History — completes the set: all four
#: non-chat navbar views). The page CONTRACT itself is unchanged: the
#: folded in (task 03 — History — completed the phase-76 set: all
#: four non-chat navbar views; phase 79 task 06 adds the sixth —
#: Tokens). The page CONTRACT itself is unchanged: the
#: phase-33 middleware wraps the whole app and lists the path in
#: HTML_PAGES, so the shell-route response is normalized exactly like
#: a static page (200, no-cache, ?v=, no validators — asserted by
@@ -85,6 +86,7 @@ SHELL_BACKED_PAGES = {
"/sources.html": "index.html", # phase 76 task 02
"/git-sources.html": "index.html", # phase 76 task 02
"/history.html": "index.html", # phase 76 task 03
"/tokens.html": "index.html", # phase 79 task 06
}
+12
View File
@@ -32,6 +32,7 @@ from app.rag import agent
from app.rag.agent import AGENT_TOOLS
from app.rag.importer import import_sources
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece
from tests.conftest import ADMIN_PASSWORD
if TYPE_CHECKING:
from app.rag.scaffolding import ScaffoldingFilter
@@ -220,6 +221,17 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
db.commit()
@pytest.fixture(autouse=True)
def _admin_signed_in(client: TestClient) -> None:
"""Phase 79 (task 03): ``POST /api/chat`` is user-gated — every turn
in this module runs as the signed-in ADMIN, so the shared ``client``
logs in once per test (the TestClient cookie jar carries the session
for every request of the test). The anonymous 401 contract itself is
pinned in ``test_auth_api.py``."""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
def _stream_chat(client: TestClient, message: str) -> tuple[int, str, list[dict[str, Any]]]:
with client.stream("POST", "/api/chat", json={"message": message}) as r:
assert r.status_code == 200
+32 -17
View File
@@ -4,7 +4,10 @@ Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
* GET: 200 with the full field set for a seeded document (all formats);
* GET: ``summary`` surfaced for summarized docs, ``null`` for markdown
(phase 36);
* GET: anonymous access stays 200 (phase 16 soft rule — public viewer);
* GET: user-gated (phase 79 — the phase-16 "public viewer" soft rule is
superseded; the shared chats are the anonymous surface now), so the
shared ``client`` is signed in as the admin for the module's contract
tests and the anonymous 403/401 pins build their own client;
* GET: 404 for an unknown (source, path) pair;
* GET: 404 for traversal-style ``path`` values (no filesystem access → no
leak).
@@ -32,11 +35,23 @@ from sqlalchemy import select, text
from sqlalchemy.orm import Session
import app.api.docs as docs_api
from app.main import app as fastapi_app
from app.models import Chunk, Document
from app.rag.llm import EmbeddingError
from tests.conftest import ADMIN_PASSWORD
from tests.fakes import FakeEmbedder
@pytest.fixture(autouse=True)
def _user_signed_in(client: TestClient) -> None:
"""Phase 79 (task 03): the document-content endpoint is user-gated —
the shared ``client`` signs in as the admin for this module's GET /
PATCH contract tests. The ONE test that pins the anonymous PATCH 403
builds its own client (it must stay unsigned)."""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
def _seed_doc(
db,
source: str = "Homelab",
@@ -195,8 +210,8 @@ def test_summary_patch_update_reembeds_summary_chunk(
if not v[3]:
assert v == before[cid] # content chunks untouched, byte for byte
assert fake.calls == [[new]] # one embed call, the new text only
# The public viewer's data source now carries the new summary
# verbatim (anonymous — the viewer stays public, phase 16).
# The viewer's data source now carries the new summary verbatim
# (the ``client`` is signed in — phase 79 gated the viewer).
g = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
@@ -323,15 +338,17 @@ def test_summary_patch_404_unknown_pair(admin_client: TestClient, db: Session) -
db.commit()
def test_summary_patch_403_anonymous(client: TestClient, db: Session) -> None:
"""The edit affordance is admin-only (phase 57, D4 — the viewer stays
public): an anonymous PATCH gets 403 ``admin only`` and touches
nothing."""
def test_summary_patch_403_anonymous(db: Session) -> None:
"""The edit affordance is admin-only (phase 57, D4 — the viewer is
user-gated since phase 79): an anonymous PATCH gets 403 ``admin
only`` and touches nothing. A FRESH client — the module's autouse
fixture signed the shared one in."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
anonymous = TestClient(fastapi_app)
try:
r = client.patch(
r = anonymous.patch(
"/api/documents/summary",
json={
"source": "Homelab",
@@ -417,11 +434,9 @@ def test_content_200_all_fields(client, db) -> None:
def test_content_summary_surfaced_for_summarized_doc(client, db) -> None:
"""A non-markdown document with a phase-30 summary returns it verbatim
(phase 36 — the viewer's data contract gains the nullable field).
Anonymous by design: the ``client`` fixture carries no admin cookie,
so the 200 here re-confirms the phase-16 soft rule (public viewer).
"""
(phase 36 — the viewer's data contract gains the nullable field),
for a signed-in caller (phase 79 — the viewer is token-or-admin; the
anonymous 401 contract is pinned in ``test_auth_api.py``)."""
summary = (
"GitLab CE runs in a Podman compose stack on the homelab NAS with a "
"persistent volume for data and a backup job."
@@ -438,7 +453,7 @@ def test_content_summary_surfaced_for_summarized_doc(client, db) -> None:
"/api/documents/content",
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
)
assert r.status_code == 200 # anonymous (no cookie) — public viewer
assert r.status_code == 200
body = r.json()
assert body["summary"] == summary # verbatim, no wrapping
assert body["content"] == "services:\n gitlab:\n image: gitlab/gitlab-ce"
@@ -448,8 +463,8 @@ def test_content_summary_surfaced_for_summarized_doc(client, db) -> None:
def test_content_summary_null_for_markdown_doc(client, db) -> None:
"""Markdown documents carry no summary (phase 30) → JSON ``null``, and
anonymous access still returns 200 (phase 16 soft rule)."""
"""Markdown documents carry no summary (phase 30) → JSON ``null``
(signed-in caller — phase 79 gated the viewer)."""
_seed_doc(
db,
path="kubernetes.md",
@@ -461,7 +476,7 @@ def test_content_summary_null_for_markdown_doc(client, db) -> None:
"/api/documents/content",
params={"source": "Homelab", "path": "kubernetes.md"},
)
assert r.status_code == 200 # anonymous (no cookie) — public viewer
assert r.status_code == 200
body = r.json()
assert "summary" in body
assert body["summary"] is None
+12
View File
@@ -34,11 +34,23 @@ from app.models import Document, KbOverview
from app.rag.importer import import_sources
from app.rag.prompts import build_deflect_prompt, build_high_prompt
from app.rag.retriever import retrieve, weak_hit_titles
from tests.conftest import ADMIN_PASSWORD
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
OFF_TOPIC = "How do I bake sourdough bread?"
@pytest.fixture(autouse=True)
def _admin_signed_in(client: TestClient) -> None:
"""Phase 79 (task 03): ``POST /api/chat`` is user-gated — this module
reuses ``test_chat_api._stream_chat`` with the shared (module-local
to THIS file) ``client``, so it signs the admin in once per test
(the autouse in ``test_chat_api`` does not apply across the import).
"""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
#: A multi-line, multi-bullet outline: the section must carry it whole
#: (well within ``BOR_KB_OVERVIEW_MAX_CHARS``) and the per-turn log line
#: records its length.
+313
View File
@@ -0,0 +1,313 @@
"""Integration: migration 0012 (api_tokens) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0011.py`` (information_schema / pg_indexes assertions
on the state the migration must leave). The tests target revision
``0012`` explicitly so later migrations cannot break them:
* upgrade 0011 → 0012 → the ``api_tokens`` table exists with the full
column contract (``id`` UUID PK; ``label`` VARCHAR(120) NOT NULL;
``token_hash`` VARCHAR(64) NOT NULL + the UNIQUE index
``ix_api_tokens_token_hash`` — the stored credential;
``created_at`` TIMESTAMPTZ NOT NULL default now(); ``last_used_at`` /
``revoked_at`` TIMESTAMPTZ NULL);
* inserted rows round-trip: ``created_at`` is stamped server-side and
``last_used_at`` / ``revoked_at`` are NULL until the service
(tasks 02/03) sets them; explicit lifecycle values round-trip
verbatim;
* two identical token hashes are rejected by the unique index (the hash
is the unique lookup key), while a repeated ``label`` is fine
(display-only);
* downgrade to 0011 → the table and index are gone (A13 — reversible),
the rest of the schema (e.g. ``doc_drafts.token``) survives;
* upgrade back to 0012 → the table and the unique index are back
(round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import hashlib
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
def _hash(token: str = "bor_0123456789abcdef0123456789abcdef") -> str:
"""The stored credential: the sha256 hex digest of the FULL token
string (always 64 hex chars — exactly what the String(64) column
width pins). The probes use fixed tokens distinct from any real
``bor_`` + 32-hex token an operator might hold."""
return hashlib.sha256(token.encode()).hexdigest()
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown upgrades
to head no matter what happened, so the dev DB is never left below
head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _table_exists(db: Session, table: str) -> bool:
count: Any = db.execute(
text(
"SELECT count(*) FROM information_schema.tables"
" WHERE table_schema = 'public' AND table_name = :t"
),
{"t": table},
).scalar()
assert count is not None, "information_schema count must be an int"
return int(count) == 1
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default, character_maximum_length)
for one table column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default, character_maximum_length"
" FROM information_schema.columns"
" WHERE table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
).fetchone()
return tuple(row) if row is not None else None
def _unique_hash_index(db: Session) -> int:
"""1 iff ``ix_api_tokens_token_hash`` exists as a UNIQUE index."""
count: Any = db.execute(
text(
"SELECT count(*) FROM pg_indexes"
" WHERE tablename = 'api_tokens'"
" AND indexname = 'ix_api_tokens_token_hash'"
),
).scalar()
assert count is not None, "pg_indexes count must be an int"
is_unique: Any = db.execute(
text(
"SELECT indisunique FROM pg_index"
" WHERE indexrelid ="
" (SELECT oid FROM pg_class WHERE relname = 'ix_api_tokens_token_hash')"
),
).scalar()
return int(count) if is_unique else 0
def _insert(db: Session, *, label: str, token_hash: str | None = None) -> uuid.UUID:
"""Insert one api_tokens row. ``token_hash=None`` is not a valid
state (NOT NULL) — the migration carries no server default; the
service (task 02) always supplies the digest of the full token."""
sql = (
"INSERT INTO api_tokens (id, label, token_hash) "
"VALUES (gen_random_uuid(), :l, :h) RETURNING id"
)
token_id: uuid.UUID = db.execute(
text(sql), {"l": label, "h": token_hash or _hash()}
).scalar_one()
db.commit()
return token_id
def _delete(db: Session, token_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM api_tokens WHERE id = :i"), {"i": token_id})
db.commit()
def test_upgrade_to_0012_adds_api_tokens(db: Session, alembic: Config) -> None:
"""Upgrade 0011 → 0012: the table + the unique token-hash index
exist with the full column contract; the table is absent at 0011."""
command.downgrade(alembic, "0011") # start from the pre-0012 state
assert _version(db) == "0011"
assert not _table_exists(db, "api_tokens"), "api_tokens must be absent at 0011"
assert _unique_hash_index(db) == 0, "the token-hash index must be absent at 0011"
command.upgrade(alembic, "0012")
assert _version(db) == "0012", "alembic_version must be at 0012"
assert _table_exists(db, "api_tokens"), "api_tokens must exist at 0012"
id_col = _column(db, "api_tokens", "id")
assert id_col is not None, "api_tokens.id is missing"
assert id_col[0] == "uuid", "api_tokens.id must be UUID"
assert id_col[1] == "NO", "api_tokens.id must be NOT NULL (PK)"
label = _column(db, "api_tokens", "label")
assert label is not None, "api_tokens.label is missing"
assert label[0] == "character varying", "api_tokens.label must be VARCHAR"
assert label[1] == "NO", "api_tokens.label must be NOT NULL"
assert label[3] == 120, "api_tokens.label must be String(120)"
token_hash = _column(db, "api_tokens", "token_hash")
assert token_hash is not None, "api_tokens.token_hash is missing"
assert token_hash[0] == "character varying", "api_tokens.token_hash must be VARCHAR"
assert token_hash[1] == "NO", "api_tokens.token_hash must be NOT NULL"
assert token_hash[3] == 64, (
"api_tokens.token_hash must be String(64) — the sha256 hex digest"
)
assert _unique_hash_index(db) == 1, "the unique token-hash index is missing"
created = _column(db, "api_tokens", "created_at")
assert created is not None, "api_tokens.created_at is missing"
assert created[0] == "timestamp with time zone", (
"api_tokens.created_at must be TIMESTAMPTZ"
)
assert created[1] == "NO", "api_tokens.created_at must be NOT NULL"
assert str(created[2]).startswith("now("), (
"api_tokens.created_at must have server default now()"
)
for name in ("last_used_at", "revoked_at"):
col = _column(db, "api_tokens", name)
assert col is not None, f"api_tokens.{name} is missing"
assert col[0] == "timestamp with time zone", (
f"api_tokens.{name} must be TIMESTAMPTZ"
)
assert col[1] == "YES", f"api_tokens.{name} must be NULL until set"
def test_inserted_rows_round_trip_the_lifecycle_states(
db: Session, alembic: Config
) -> None:
"""At 0012, an inserted row has a server-stamped ``created_at`` and
NULL ``last_used_at`` / ``revoked_at`` (the fresh-credential state);
explicit lifecycle values round-trip verbatim (the service's
mark_used / revoke paths, tasks 02/03)."""
command.upgrade(alembic, "head")
token_id = _insert(db, label="alice", token_hash=_hash())
try:
row = db.execute(
text(
"SELECT label, token_hash, created_at, last_used_at, revoked_at"
" FROM api_tokens WHERE id = :i"
),
{"i": token_id},
).fetchone()
assert row is not None, "the token row must exist"
assert row[0] == "alice", "the label must round-trip verbatim"
assert row[1] == _hash(), "the token hash must round-trip verbatim"
assert row[2] is not None, "created_at must be stamped server-side"
assert row[3] is None, "last_used_at must be NULL until first use"
assert row[4] is None, "revoked_at must be NULL while active"
# The service's lifecycle updates (mark_used / revoke) round-trip.
db.execute(
text(
"UPDATE api_tokens SET last_used_at = now(), revoked_at = now()"
" WHERE id = :i"
),
{"i": token_id},
)
db.commit()
used = db.execute(
text(
"SELECT last_used_at, revoked_at FROM api_tokens WHERE id = :i"
),
{"i": token_id},
).fetchone()
assert used is not None, "the updated row must exist"
assert used[0] is not None and used[1] is not None, (
"last_used_at/revoked_at must round-trip explicit values"
)
finally:
_delete(db, token_id)
def test_unique_index_rejects_duplicate_hashes_label_is_not_unique(
db: Session, alembic: Config
) -> None:
"""Two identical token hashes are rejected by the unique index —
the hash is the unique lookup key (the share-token precedent,
phase 51); a repeated ``label`` is fine (display-only)."""
command.upgrade(alembic, "head")
dup_hash = _hash("bor_11111111111111111111111111111111")
first_id = _insert(db, label="alice", token_hash=dup_hash)
other_id: uuid.UUID | None = None
twin_id: uuid.UUID | None = None
try:
try:
_insert(db, label="bob", token_hash=dup_hash)
except IntegrityError:
db.rollback() # the aborted transaction must not leak
else:
pytest.fail("a duplicate api_tokens.token_hash must be rejected")
# A different hash is fine — only the exact duplicate is unique.
other_id = _insert(db, label="bob", token_hash=_hash("bor_deadbeef" * 4))
# The same label under a different hash is fine — display-only.
twin_id = _insert(db, label="alice", token_hash=_hash("bor_cafebabe" * 4))
finally:
_delete(db, first_id)
if other_id is not None:
_delete(db, other_id)
if twin_id is not None:
_delete(db, twin_id)
def test_downgrade_to_0011_drops_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0011: the table and the unique index are gone
(A13 — reversible) while the rest of the schema survives."""
command.downgrade(alembic, "0011")
assert _version(db) == "0011"
assert not _table_exists(db, "api_tokens"), "api_tokens must be dropped"
assert _unique_hash_index(db) == 0, "the token-hash index must be dropped"
token_col = _column(db, "doc_drafts", "token")
assert token_col is not None and token_col[0] == "uuid", (
"doc_drafts.token must survive the downgrade"
)
meta = _column(db, "saved_chats", "share_token")
assert meta is not None and meta[0] == "uuid", (
"saved_chats.share_token must survive the downgrade"
)
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0011, then upgrade back to 0012: the table and the
unique index are back."""
command.downgrade(alembic, "0011")
command.upgrade(alembic, "0012")
assert _version(db) == "0012", "round-trip upgrade must land at 0012"
assert _table_exists(db, "api_tokens"), "api_tokens must be back"
assert _unique_hash_index(db) == 1, "the unique token-hash index must be back"
token_hash = _column(db, "api_tokens", "token_hash")
assert token_hash is not None and token_hash[1] == "NO", (
"token_hash must be VARCHAR NOT NULL after the round-trip"
)
assert token_hash[3] == 64, "token_hash must be String(64) after the round-trip"
created = _column(db, "api_tokens", "created_at")
assert created is not None and created[1] == "NO", (
"created_at must be TIMESTAMPTZ NOT NULL after the round-trip"
)
assert str(created[2]).startswith("now("), (
"created_at must default to now() after the round-trip"
)
+318
View File
@@ -0,0 +1,318 @@
"""Integration: the onboarding-chips endpoint (phase 80, task 01) —
the full state matrix of ``GET /api/suggestions``.
The chips are the **last 3 questions asked** — the three most recent
user questions across ALL saved chats: chats are walked newest-
``updated_at`` first (``created_at`` tiebreak), each chat's
``bor.chat.v1`` message list is walked newest-first, exact-
(case-sensitive) de-duplicated, capped at 3. A fresh deployment —
zero saved questions — gets the SEED list instead
(``get_settings().suggestions``: the ``BOR_SUGGESTIONS`` override or
the built-in default). The override's JSON parsing is pinned at unit
level (``tests/unit/test_config.py``), so this suite stays
env-agnostic: the empty-DB contract is "exactly
``get_settings().suggestions``, whatever the environment makes that".
Matrix (task item 2):
* empty DB → exactly ``get_settings().suggestions``;
* cap + order: 4 questions in ONE chat → the 3 newest, newest first;
* chat order: two chats with DISTINCT ``updated_at`` (stamped
explicitly) → the newer chat's questions outrank the older chat's
newest-LOOKING question;
* dedup: the same text asked in two chats → exactly once; a
differently-cased variant is KEPT (exact dedup);
* partial: 1–2 saved questions deployment-wide → exactly those chips
(NO seed top-up — the A6 contract);
* brain-only: all-``brain`` (or blank user texts) contribute nothing;
an all-brain deployment → the seed;
* anonymous → 401 ``authentication required`` (the phase-79 contract,
pinned here too).
The deflection "Maybe try" chips (``app.rag.suggestions.
derive_suggestions``) are a SEPARATE contract — untouched.
Real Postgres (``podman compose up -d db``).
Requires: podman compose up -d db
"""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import SavedChat
#: Fixed question texts — the matrix asserts EXACT chip lists, so the
#: texts are distinct per purpose.
Q_ONE = "How did I install gitlab?"
Q_TWO = "Which node runs my Borg backups?"
Q_THREE = "How do I prune deleted docs?"
Q_FOUR = "What proxy fronts reeseapps.com?"
Q_FIVE = "How is my K3S cluster set up?"
Q_SIX = "How do I deploy a service?"
@pytest.fixture(autouse=True)
def clean_chats(db: Session) -> Iterator[None]:
"""``saved_chats`` is global state: reset around every test."""
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
db.execute(text("TRUNCATE saved_chats"))
db.commit()
def _user(text: str) -> dict[str, Any]:
return {"who": "user", "text": text}
def _brain(text: str = "You've got this!") -> dict[str, Any]:
return {"who": "brain", "text": text}
def _add_chat(
db: Session,
*,
title: str,
messages: list[dict[str, Any]],
updated_at: datetime,
) -> None:
"""One saved-chat row with EXPLICIT ``created_at``/``updated_at``
stamps (deterministic order — no reliance on ``now()``
resolution)."""
db.add(
SavedChat(
title=title,
messages=messages,
created_at=updated_at,
updated_at=updated_at,
)
)
db.commit()
def _chips(admin_client: TestClient) -> list[str]:
r = admin_client.get("/api/suggestions")
assert r.status_code == 200, r.text
return r.json()["suggestions"]
# ---------- empty DB: the seed ----------
def test_empty_db_returns_seed(admin_client: TestClient) -> None:
"""Zero saved questions → exactly the seed list — env-agnostic:
``get_settings().suggestions`` (the ``BOR_SUGGESTIONS`` override or
the built-in default, whatever the environment makes it)."""
r = admin_client.get("/api/suggestions")
assert r.status_code == 200
assert r.json() == {"suggestions": get_settings().suggestions}
# ---------- cap + order within one chat ----------
def test_cap_three_and_newest_first_within_a_chat(
admin_client: TestClient, db: Session
) -> None:
"""4 user questions (brain replies between them) in ONE chat →
exactly the 3 NEWEST, newest first."""
_add_chat(
db,
title="one long chat",
messages=[
_user(Q_ONE), _brain("a1"),
_user(Q_TWO), _brain("a2"),
_user(Q_THREE), _brain("a3"),
_user(Q_FOUR), _brain("a4"),
],
updated_at=datetime.now(UTC),
)
assert _chips(admin_client) == [Q_FOUR, Q_THREE, Q_TWO]
# ---------- chat order across chats ----------
def test_newer_chat_walked_first(admin_client: TestClient, db: Session) -> None:
"""Two chats with DISTINCT ``updated_at`` (stamped explicitly):
the newer chat is walked FIRST — its single question outranks the
older chat's newest-LOOKING (last-in-conversation) question."""
base = datetime.now(UTC)
_add_chat(
db,
title="older chat",
messages=[_user(Q_FIVE), _brain("…"), _user(Q_SIX), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="newer chat",
messages=[_user(Q_ONE), _brain("…")],
updated_at=base + timedelta(hours=2),
)
# Newer chat first (Q_ONE), then the older chat newest-first
# (Q_SIX — its LAST question — before Q_FIVE).
assert _chips(admin_client) == [Q_ONE, Q_SIX, Q_FIVE]
# ---------- dedup ----------
def test_verbatim_reask_counts_once_across_chats(
admin_client: TestClient, db: Session
) -> None:
"""The SAME question text asked in two chats appears EXACTLY ONCE
in the chips."""
base = datetime.now(UTC)
_add_chat(
db,
title="older",
messages=[_user(Q_THREE), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="newer",
messages=[
_user(Q_ONE), _brain("…"),
_user(Q_THREE), _brain("…"), # verbatim re-ask (newer chat)
],
updated_at=base + timedelta(hours=2),
)
# Newest first: the re-ask (LAST message of the newer chat) leads —
# and it appears exactly once (the older chat's copy is deduped).
chips = _chips(admin_client)
assert chips == [Q_THREE, Q_ONE]
assert chips.count(Q_THREE) == 1
def test_dedup_is_exact_not_case_insensitive(
admin_client: TestClient, db: Session
) -> None:
"""A differently-cased re-ask is a DIFFERENT question (exact,
case-sensitive dedup — case-insensitive would drop it): both
variants show, and the verbatim re-ask in the older chat still
counts once."""
base = datetime.now(UTC)
lower_variant = Q_THREE.lower()
_add_chat(
db,
title="older",
messages=[_user(Q_THREE), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="newer",
messages=[
_user(lower_variant), _brain("…"),
_user(Q_THREE), _brain("…"),
_user(Q_ONE), _brain("…"),
],
updated_at=base + timedelta(hours=2),
)
# Newer chat walked newest-first: Q_ONE, Q_THREE, lower_variant —
# all three kept (the case variant is NOT a duplicate).
assert _chips(admin_client) == [Q_ONE, Q_THREE, lower_variant]
# ---------- partial: no seed top-up ----------
def test_exactly_two_questions_give_exactly_two_chips(
admin_client: TestClient, db: Session
) -> None:
"""1–2 saved questions deployment-wide → EXACTLY those chips — NO
mixing/top-up with the seed (the A6 contract)."""
base = datetime.now(UTC)
_add_chat(
db,
title="a",
messages=[_user(Q_TWO), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="b",
messages=[_user(Q_ONE), _brain("…")],
updated_at=base + timedelta(hours=1),
)
assert _chips(admin_client) == [Q_ONE, Q_TWO]
def test_exactly_one_question_gives_exactly_one_chip(
admin_client: TestClient, db: Session
) -> None:
"""The 1-question boundary of the same contract: exactly one chip,
never padded toward the cap or mixed with the seed."""
_add_chat(
db,
title="a",
messages=[_user(Q_TWO), _brain("…")],
updated_at=datetime.now(UTC),
)
assert _chips(admin_client) == [Q_TWO]
# ---------- brain-only / blank user texts ----------
def test_brain_and_blank_user_texts_contribute_nothing(
admin_client: TestClient, db: Session
) -> None:
"""A chat whose messages are all ``who == "brain"`` (plus a blank
user text) contributes NOTHING: the chips hold exactly the one
real question from the other chat — no brain text, no blank, no
seed top-up."""
base = datetime.now(UTC)
_add_chat(
db,
title="brain only + blank user",
messages=[
_brain("just brain talking"),
_user(" "), # blank user text — skipped
_brain("more brain"),
],
updated_at=base,
)
_add_chat(
db,
title="real question",
messages=[_user(Q_FOUR), _brain("…")],
updated_at=base + timedelta(hours=1),
)
assert _chips(admin_client) == [Q_FOUR]
def test_all_brain_deployment_returns_seed(admin_client: TestClient, db: Session) -> None:
"""A deployment with ONLY brain/blank conversations (zero saved
questions) → the full seed list."""
_add_chat(
db,
title="all brain",
messages=[_brain("a"), _user("\t"), _brain("b")],
updated_at=datetime.now(UTC),
)
r = admin_client.get("/api/suggestions")
assert r.status_code == 200
assert r.json() == {"suggestions": get_settings().suggestions}
# ---------- auth pin (phase 79) ----------
def test_anonymous_is_401(client: TestClient) -> None:
"""The phase-79 contract, pinned here too: an unsigned-in caller
cannot read the chips."""
r = client.get("/api/suggestions")
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
+198
View File
@@ -0,0 +1,198 @@
"""Integration: the admin token API (phase 79, task 02).
The admin surface for issued access tokens, driven through the real app
(TestClient keeps the cookie jar — the house ``test_auth_api`` admin-login
pattern):
* anonymous → 403 ``admin only`` on all three endpoints (router-level
``require_admin``; a token USER, once task 03 lands, is 403 here too —
pinned in task 03's matrix);
* create → 201 with the plaintext ``token`` (the ONE wire moment it
exists, A4) — and ``GET /tokens`` NEVER exposes it: no ``token`` key,
no ``token_hash`` key, and the hash string itself absent from the
serialized body;
* labels are display-only and NOT unique (two tokens, one label);
* blank/over-long labels → 422 (the house ``ValueError`` pattern);
* revoke → 204, idempotent (re-revoke 204, original stamp kept), the
list shows ``revoked: true`` and the row keeps its ``last_used_at``;
unknown id → 404 ``token not found``;
* the list is newest-first (``created_at desc``).
Real Postgres (``podman compose up -d db``); no LLM involved — tokens
are plain rows, so the suite is deterministic without a fake.
Requires: podman compose up -d db
"""
from __future__ import annotations
import hashlib
import re
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text, update
from sqlalchemy.orm import Session
from app.models import ApiToken
TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$")
#: A fixed "already used" stamp — the row keeps it through revocation
#: (task 03's mark_used lands later; here the test stamps the column
#: directly to pin the "revoke preserves last_used_at" contract).
USED_STAMP = datetime(2026, 9, 6, 12, 0, 0, tzinfo=UTC)
@pytest.fixture(autouse=True)
def clean_tokens(db: Session) -> Iterator[None]:
"""api_tokens is global state: reset around every test."""
db.execute(text("TRUNCATE api_tokens"))
db.commit()
yield
db.execute(text("TRUNCATE api_tokens"))
db.commit()
def _create(admin_client: TestClient, label: str = "alice") -> dict:
"""POST a token (201) and return the response body."""
r = admin_client.post("/api/tokens", json={"label": label})
assert r.status_code == 201, r.text
return r.json()
def test_anonymous_403_on_all_three(client: TestClient) -> None:
"""Router-level ``require_admin``: every route is 403 for the
unsigned-in caller (one fixed detail — no enumeration)."""
r = client.post("/api/tokens", json={"label": "anon"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.get("/api/tokens")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
def test_admin_create_returns_plaintext_exactly_once(
admin_client: TestClient, db: Session
) -> None:
"""201 carries the well-formed plaintext; the row stores only the
hash; the LIST never exposes either the plaintext or the hash."""
body = _create(admin_client, " alice ")
# The 201 shape: exactly the display fields + the plaintext once.
assert set(body) == {"id", "label", "token", "created_at"}
assert body["label"] == "alice" # the service strips
assert TOKEN_SHAPE.fullmatch(body["token"]), body["token"]
datetime.fromisoformat(body["created_at"]) # parses
# The stored row carries only the hash of the FULL token string.
row = db.execute(
select(ApiToken).where(ApiToken.id == uuid.UUID(body["id"]))
).scalar_one()
assert row.token_hash == hashlib.sha256(body["token"].encode("utf-8")).hexdigest()
assert row.token_hash != body["token"]
# The list: no `token` key, no `token_hash` key, and NEITHER the
# plaintext NOR the hash string appears anywhere in the body.
r = admin_client.get("/api/tokens")
assert r.status_code == 200
items = r.json()["tokens"]
assert len(items) == 1
item = items[0]
assert set(item) == {"id", "label", "created_at", "last_used_at", "revoked"}
assert item["id"] == body["id"]
assert item["label"] == "alice"
assert item["last_used_at"] is None # not used yet
assert item["revoked"] is False
serialized = r.text
assert body["token"] not in serialized
assert row.token_hash not in serialized
def test_two_tokens_same_label_both_created(admin_client: TestClient) -> None:
"""Labels are display-only and NOT unique — two tokens may share a
label (the column has no unique constraint, task 01)."""
a = _create(admin_client, "alice")
b = _create(admin_client, "alice")
assert a["id"] != b["id"]
assert a["token"] != b["token"]
items = admin_client.get("/api/tokens").json()["tokens"]
assert len(items) == 2
assert {i["label"] for i in items} == {"alice"}
def test_create_blank_or_overlong_label_422(admin_client: TestClient) -> None:
"""The schema fails loud (house ``ValueError`` pattern): whitespace
only, empty, and >120 chars after strip are all 422."""
for label in ("", " ", "x" * 121):
r = admin_client.post("/api/tokens", json={"label": label})
assert r.status_code == 422, (label, r.status_code, r.text)
# A padded-but-valid label passes (trim-before-constrain).
assert _create(admin_client, " carol ")["label"] == "carol"
def test_revoke_204_idempotent_and_preserves_last_used(
admin_client: TestClient, db: Session
) -> None:
"""Revoke → 204; the list item flips to ``revoked: true`` and keeps
its ``last_used_at``; a second revoke is still 204 with the ORIGINAL
stamp preserved (no re-stamp)."""
body = _create(admin_client, "dave")
token_id = uuid.UUID(body["id"])
# Stamp "already used" directly (task 03's mark_used is API-side).
db.execute(
update(ApiToken).where(ApiToken.id == token_id).values(last_used_at=USED_STAMP)
)
db.commit()
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
item = admin_client.get("/api/tokens").json()["tokens"][0]
assert item["revoked"] is True
assert datetime.fromisoformat(item["last_used_at"]) == USED_STAMP
db.expire_all()
original_stamp = db.execute(
select(ApiToken).where(ApiToken.id == token_id)
).scalar_one().revoked_at
assert original_stamp is not None
# Re-revoke: still 204, the original stamp survives (no re-stamp).
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
db.expire_all()
row = db.execute(select(ApiToken).where(ApiToken.id == token_id)).scalar_one()
assert row.revoked_at == original_stamp
assert datetime.fromisoformat(
admin_client.get("/api/tokens").json()["tokens"][0]["last_used_at"]
) == USED_STAMP
def test_revoke_unknown_id_404(admin_client: TestClient) -> None:
"""One fixed message for every unknown id (no enumeration)."""
r = admin_client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
assert r.status_code == 404
assert r.json() == {"detail": "token not found"}
def test_list_is_newest_first(admin_client: TestClient, db: Session) -> None:
"""``created_at desc, id desc``: forcing a deterministic gap pins the
ordering (transaction timestamps can be coarser than the insert
gap, and uuid4 ids would tie-break randomly)."""
older = _create(admin_client, "first")
newer = _create(admin_client, "second")
db.execute(
update(ApiToken)
.where(ApiToken.id == uuid.UUID(older["id"]))
.values(created_at=datetime.now(UTC) - timedelta(hours=1))
)
db.commit()
items = admin_client.get("/api/tokens").json()["tokens"]
assert [i["id"] for i in items] == [newer["id"], older["id"]]
+94
View File
@@ -0,0 +1,94 @@
"""Unit tests: the ApiToken model registers the api_tokens contract
(phase 79, task 01).
Schema-level assertions without a live DB (the
``tests/unit/test_models.py`` doc_drafts precedent): the table name, the
column set + nullability, the UNIQUE ``token_hash`` (two tokens with the
same hash must collide — the unique constraint backing
``ix_api_tokens_token_hash``), and a display-only, non-unique
``label``. The real duplicate-rejection behaviour is pinned against the
dev DB in ``tests/integration/test_migration_0012.py``.
"""
from __future__ import annotations
from sqlalchemy import PrimaryKeyConstraint, String, UniqueConstraint
from sqlalchemy.schema import Table
import app.models # noqa: F401 (import registers all tables on Base.metadata)
from app.db import Base
def _table() -> Table:
return Base.metadata.tables["api_tokens"]
def test_api_tokens_table_registered() -> None:
assert "api_tokens" in Base.metadata.tables, "ApiToken must register api_tokens"
assert _table().name == "api_tokens"
def test_api_tokens_column_contract() -> None:
"""The column set + nullability: ``id`` UUID PK; ``label`` /
``token_hash`` NOT NULL; ``created_at`` NOT NULL with a server
default (now()); ``last_used_at`` / ``revoked_at`` NULL until the
service (tasks 02/03) sets them."""
tok = _table()
assert set(tok.c.keys()) == {
"id", "label", "token_hash", "created_at", "last_used_at", "revoked_at",
}
assert tok.c["id"].primary_key is True, "api_tokens.id must be the PK"
assert tok.c["id"].nullable is False, "api_tokens.id must be NOT NULL"
assert tok.c["label"].nullable is False, "label must be NOT NULL"
label_type = tok.c["label"].type
assert isinstance(label_type, String), "label must be String(120)"
assert label_type.length == 120, "label must be String(120)"
assert tok.c["token_hash"].nullable is False, "token_hash must be NOT NULL"
hash_type = tok.c["token_hash"].type
assert isinstance(hash_type, String), "token_hash must be String(64)"
assert hash_type.length == 64, (
"token_hash must be String(64) — a sha256 hex digest"
)
assert tok.c["created_at"].nullable is False, "created_at must be NOT NULL"
assert tok.c["created_at"].server_default is not None, (
"created_at needs a server default (now())"
)
for name in ("last_used_at", "revoked_at"):
assert tok.c[name].nullable is True, (
f"{name} must be NULL until first use / revocation"
)
def test_api_tokens_token_hash_is_unique() -> None:
"""Two tokens with the same hash must collide: a UNIQUE constraint
covers exactly ``token_hash`` (the backing constraint of the
``ix_api_tokens_token_hash`` unique index — the stored credential
is the lookup key)."""
tok = _table()
uq = [
c
for c in tok.constraints
if isinstance(c, UniqueConstraint)
and not isinstance(c, PrimaryKeyConstraint)
and {col.name for col in c.columns} == {"token_hash"}
]
assert uq, "api_tokens must be unique on (token_hash) — the stored credential"
def test_api_tokens_label_is_not_unique() -> None:
"""``label`` is the hand-out name — display-only: the column itself
is not unique and no unique constraint may cover it (two tokens can
share a label, e.g. two "alice" tokens issued at different times)."""
tok = _table()
assert not tok.c["label"].unique, "label must not be unique"
covering = [
c
for c in tok.constraints
if isinstance(c, UniqueConstraint)
and any(col.name == "label" for col in c.columns)
]
assert not covering, "no unique constraint may cover api_tokens.label"
+136 -3
View File
@@ -1,11 +1,17 @@
"""Unit tests: single-admin auth (phase 16; A10 revised).
"""Unit tests: auth (phase 16 single-admin; phase 79 token users).
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.
phase-79 ``require_user`` matrix (admin pass, live token pass, revoked /
missing-row 401 + session keys popped, anonymous 401, admin+user
coexistence), the three-role whoami payload shape, and the
sign_in/sign_out session semantics.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import pytest
from fastapi import HTTPException
from starlette.middleware.sessions import Session
@@ -18,9 +24,11 @@ from app.core.auth import (
check_password,
ensure_admin_configured,
require_admin,
require_user,
sign_in,
sign_out,
)
from app.models import ApiToken
from app.schemas import WhoamiResponse
@@ -122,7 +130,117 @@ def test_require_admin_403s_anonymous(session: dict) -> None:
assert exc.value.detail == "admin only"
# ---------- whoami payload shape ----------
# ---------- require_user (phase 79: admin OR live token, else 401) ----------
class _FakeTokenResult:
"""``execute()`` result for the fake db: ``.scalars().first()``
yields the one fixed row (or ``None``)."""
def __init__(self, row: ApiToken | None) -> None:
self._row = row
def scalars(self) -> _FakeTokenResult:
return self
def first(self) -> ApiToken | None:
return self._row
class _FakeTokenDb:
"""Session stand-in for ``require_user``'s PK lookup: counts the
queries it is given and always returns the one fixed row — enough to
pin the matrix without a database (the admin path must NOT query at
all)."""
def __init__(self, row: ApiToken | None) -> None:
self._row = row
self.queries = 0
def execute(self, _stmt: object) -> _FakeTokenResult:
self.queries += 1
return _FakeTokenResult(self._row)
def _token_row(**kwargs: object) -> ApiToken:
base: dict[str, object] = {
"id": uuid.uuid4(),
"label": "alice",
"token_hash": "0" * 64,
"created_at": datetime.now(UTC),
}
base.update(kwargs)
return ApiToken(**base) # pyright: ignore[reportCallIssue]
def test_require_user_admin_session_passes_without_any_db_lookup() -> None:
"""An admin ALWAYS passes — token state irrelevant, no row fetched
(the admin+user coexistence case: admin wins outright)."""
db = _FakeTokenDb(None) # would be a dead row if it were ever consulted
require_user(
_request_with_session(
admin=True, user=True, user_token_id=str(uuid.uuid4())
),
db, # pyright: ignore[reportArgumentType]
) # no exception
assert db.queries == 0
def test_require_user_active_token_session_passes() -> None:
row = _token_row()
db = _FakeTokenDb(row)
require_user(
_request_with_session(user=True, user_token_id=str(row.id)),
db, # pyright: ignore[reportArgumentType]
) # no exception
assert db.queries == 1 # the live PK lookup ran
@pytest.mark.parametrize(
("row", "token_id"),
[
(None, str(uuid.uuid4())), # the row is gone (deleted out-of-band)
(_token_row(revoked_at=datetime.now(UTC)), str(uuid.uuid4())), # revoked
(None, "not-a-uuid"), # corrupt session — no valid row id at all
],
ids=["missing-row", "revoked", "corrupt-token-id"],
)
def test_require_user_dead_token_session_401s_and_pops_both_keys(
row: ApiToken | None, token_id: str
) -> None:
"""Row missing / revoked / unresolvable → 401 ``authentication
required`` AND the dead session is dropped NOW (both user keys
popped, so the next whoami is anonymous)."""
request = _request_with_session(user=True, user_token_id=token_id)
with pytest.raises(HTTPException) as exc:
require_user(request, _FakeTokenDb(row)) # pyright: ignore[reportArgumentType]
assert exc.value.status_code == 401
assert exc.value.detail == "authentication required"
assert "user" not in request.session
assert "user_token_id" not in request.session
def test_require_user_anonymous_401s() -> None:
with pytest.raises(HTTPException) as exc:
require_user(
_request_with_session(), _FakeTokenDb(None) # pyright: ignore[reportArgumentType]
)
assert exc.value.status_code == 401
assert exc.value.detail == "authentication required"
def test_require_user_user_key_without_token_id_401s_and_pops() -> None:
"""A ``user`` key with no ``user_token_id`` at all is a dead session
too — same 401, both keys dropped."""
request = _request_with_session(user=True)
with pytest.raises(HTTPException) as exc:
require_user(request, _FakeTokenDb(None)) # pyright: ignore[reportArgumentType]
assert exc.value.status_code == 401
assert exc.value.detail == "authentication required"
assert "user" not in request.session
# ---------- whoami payload shape (three roles, phase 79) ----------
def test_whoami_anonymous_payload() -> None:
@@ -136,6 +254,21 @@ def test_whoami_admin_payload() -> None:
assert body == WhoamiResponse(authenticated=True, role="admin")
def test_whoami_token_user_payload() -> None:
body = whoami(_request_with_session(user=True, user_token_id=str(uuid.uuid4())))
assert body == WhoamiResponse(authenticated=True, role="user")
def test_whoami_admin_wins_when_both_roles_are_set() -> None:
"""Coexistence is deliberate: a browser holding BOTH an admin and a
token session reports admin (the UI keys off role, the admin surface
stays open)."""
body = whoami(
_request_with_session(admin=True, user=True, user_token_id=str(uuid.uuid4()))
)
assert body == WhoamiResponse(authenticated=True, role="admin")
# ---------- sign_in / sign_out session semantics ----------
-271
View File
@@ -1,271 +0,0 @@
"""Unit: the phase-25 still-background contract — layer plumbing and the
phase-08 anchors (source pins).
Phase 22 (owner report 2026-08-24) made the phase-08 background
perceptible: 60% grid-line alpha, a widened mask, a 60s one-cell grid
drift, and a 14s whole-layer glow breathe. The owner then reported
(2026-08-25, chat): the background "jitters down and to the right every
second and it slowly blinks brighter and darker. It should be smooth,
fluxuating, dimming and brightening, but not moving. Different bright
spots should slowly fade in and out." — the phase-22 design intent
(grid drift + whole-layer breathe) is superseded.
The new design (styles.css, pure CSS, zero JS, no `filter` — A11):
- grid (body::before): a STATIC texture — the drift animation and its
keyframes are deleted (the 0.73px/s sub-pixel drift rasterizes as a
once-per-second down-right jitter);
- three independent soft glow spots — body::after (26s), html::before
(34s, -12s delay), html::after (42s, -23s delay) — each on its own
SLOW opacity-only fade (the whole-layer breathe keyframes are
deleted), so the total light fluxuates smoothly and irregularly;
LCM(26, 34, 42) = 4641s, so the composite pattern never repeats
within a viewing session. The 2026-08-28 rebrand recolored the
phase-08 indigo/cyan spots to the warm dark-red theme palette
(rose / orange / red) and the grid lines to the warm line tone —
structure (sizes, positions, alphas, periods) unchanged.
This file keeps the generic layer-plumbing pins (the no-occlusion
contract, fixed / z-index -1 / pointer-events none — now across all
four layers) and the phase-08 no-blur/no-JS anchor. The full new
contract (no animation on the grid, opacity-only keyframes, the three
spot gradients, reduced motion across all four layers) is pinned in
tests/unit/test_background_no_motion.py; browser behavior is E2E-covered
by tests/e2e/test_background_no_motion.py (task 02).
Story: .agents/user_stories/background-no-motion.md (supersedes
.agents/user_stories/background-animation.md).
"""
from __future__ import annotations
import re
from pathlib import Path
STYLES_CSS = (
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
)
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _css_no_comments() -> str:
"""styles.css with /* … */ comments stripped — for functional anchor
checks (filter/blur) that must not trip on explanatory comments."""
return re.sub(r"/\*[\s\S]*?\*/", "", _css())
def _rule_block(css: str, selector: str) -> str:
"""Body of the first `selector { ... }` rule (top-level, no nesting)."""
rule = re.search(
r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css
)
assert rule, f"styles.css must define a {selector} rule"
return rule.group(1)
def _grid_rule(css: str) -> str:
return _rule_block(css, "body::before")
def _glow_rule(css: str) -> str:
return _rule_block(css, "body::after")
def _bg_keyframes(css: str) -> dict[str, str]:
"""Name → body for every @keyframes bg-* rule (balanced braces —
works for the one-line blocks and a multi-line reformat alike)."""
out: dict[str, str] = {}
for m in re.finditer(r"@keyframes (bg-[A-Za-z0-9-]+)\s*\{", css):
start, depth, i = m.end(), 1, m.end()
while i < len(css) and depth:
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
i += 1
out[m.group(1)] = css[start:i - 1]
return out
# --------------------------------------------------------------------------
# Layer plumbing — the no-occlusion contract (phase 08) must survive
# --------------------------------------------------------------------------
def test_both_layers_are_fixed_zminus1_noninteractive() -> None:
"""All four background layers stay behind the content and can never
intercept input: fixed, full-viewport, z-index -1, pointer-events
none (phase 25: html::before / html::after join body::before /
body::after as background layers — UI Structure Check: layers behind
content, no 360px overflow, since they are fixed; inset: 0)."""
for name, block in (
("body::before", _grid_rule(_css())),
("body::after", _glow_rule(_css())),
("html::before", _rule_block(_css(), "html::before")),
("html::after", _rule_block(_css(), "html::after")),
):
assert "position: fixed" in block, f"{name} must stay position:fixed"
assert "inset: 0" in block, f"{name} must stay full-viewport (inset: 0)"
assert "z-index: -1" in block, f"{name} must stay z-index:-1"
assert "pointer-events: none" in block, f"{name} must stay click-through"
assert "content: \"\"" in block, f"{name} must keep its pseudo content"
def test_html_owns_bg_and_body_stays_transparent() -> None:
"""The no-occlusion contract: the visible page background lives on
<html>; <body> must remain transparent and non-stacking, or the
z-index:-1 layers (including the phase-25 html pseudo-layers, which
paint above the canvas and below body's content as the root stacking
context) are painted over (the phase-08 recipe)."""
html_block = _rule_block(_css(), "html")
assert "background: var(--bg)" in html_block, (
"html must keep background: var(--bg) (the page canvas)"
)
body_block = _rule_block(_css(), "body")
assert "background: transparent" in body_block, (
"body must keep background: transparent so the layers show"
)
# body must not gain a z-index/transform/opacity that would turn it
# into a stacking context trapping the negative-z-index layers.
for prop in ("z-index", "transform", "opacity", "filter"):
assert prop + ":" not in body_block, (
f"body must not create a stacking context (found {prop})"
)
# --------------------------------------------------------------------------
# Grid layer — phase 25: a static texture (the drift is gone)
# --------------------------------------------------------------------------
def test_grid_is_static_no_drift() -> None:
"""body::before must carry NO animation — the phase-22 60s one-cell
drift (0.73px/s down-right) rasterized as a once-per-second jitter;
the owner wants no movement (2026-08-25). Its keyframes are deleted
too."""
block = _grid_rule(_css())
assert "animation" not in block, (
"body::before must not animate (the no-movement contract)"
)
assert "bg-grid-drift" not in _css(), (
"@keyframes bg-grid-drift must be deleted"
)
def test_grid_cells_and_line_contrast() -> None:
"""44px cells with 1px lines at the phase-22 fixed 60% line alpha,
in the rebrand warm line tone (2026-08-28; the phase-22 indigo
value rgb(38 48 74 / 0.6) was recolored with the dark-red theme)
— the static texture keeps the values that made the grid readable
(see tests/unit/test_background_no_motion.py for the phase-25
story)."""
block = _grid_rule(_css())
assert "background-size: 44px 44px" in block
line = "linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)"
assert line in block, "grid must keep horizontal 1px lines at 60% line alpha"
assert (
"linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)"
in block
), "grid must keep vertical 1px lines at 60% line alpha"
assert "0.35" not in block, "the too-faint 35% line alpha must not return"
def test_grid_mask_widened_and_prefixed() -> None:
"""The phase-22 mask: 140%×110% ellipse, fully visible to 40% of the
radius, faded out by 90% — the grid must read across most of the
viewport (phase-08's 120%×90%/25%/78% masked it to the top ~25%).
The -webkit- and standard mask-image must stay in lockstep."""
block = _grid_rule(_css())
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
assert f"-webkit-mask-image: {mask};" in block
assert f"mask-image: {mask};" in block
# --------------------------------------------------------------------------
# Glow layers — phase 25: three spots, each on its own slow opacity fade
# --------------------------------------------------------------------------
def test_three_spots_run_own_slow_opacity_fades() -> None:
"""The whole-layer breathe is replaced by three independent
opacity-only fades on distinct slow periods with negative delays (out
of phase): body::after 26s, html::before 34s -12s, html::after 42s
-23s. The old breathe keyframes are deleted."""
assert "animation: bg-glow-a 26s ease-in-out infinite" in _glow_rule(_css())
assert (
"animation: bg-glow-b 34s ease-in-out -12s infinite"
in _rule_block(_css(), "html::before")
)
assert (
"animation: bg-glow-c 42s ease-in-out -23s infinite"
in _rule_block(_css(), "html::after")
)
assert "bg-glow-breathe" not in _css(), (
"@keyframes bg-glow-breathe must be deleted"
)
def test_glow_keyframes_are_opacity_only() -> None:
"""The no-movement contract: every bg-* keyframe block animates ONLY
opacity (no transform/scale, no background-position)."""
keyframes = _bg_keyframes(_css())
assert set(keyframes) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"}, (
"exactly three bg-glow-* keyframe blocks must exist"
)
for name, body in keyframes.items():
props = set(re.findall(r"([A-Za-z-]+)\s*:", body))
assert props == {"opacity"}, (
f"{name} must animate only opacity, found {sorted(props)}"
)
def test_glow_spots_use_the_rebrand_warm_palette() -> None:
"""The three spots keep their radii and positions from the phase-25
layout (rose top-left on body::after, orange bottom-right on
html::before, red bottom-left 52rem at 14% 86% on html::after) but
wear the 2026-08-28 rebrand palette (warm dark-red theme; the
phase-08 indigo/cyan values are gone). All spots fade to
transparent at 62%."""
assert (
"radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), "
"transparent 62%)" in _glow_rule(_css())
)
assert (
"radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), "
"transparent 62%)" in _rule_block(_css(), "html::before")
)
assert (
"radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), "
"transparent 62%)" in _rule_block(_css(), "html::after")
)
# --------------------------------------------------------------------------
# Phase-08 anchor: pure CSS, zero JS, no blur
# --------------------------------------------------------------------------
def test_no_blur_no_js_in_background_layers() -> None:
"""The phase-08 performance anchor: no `filter` (or any filter) on
any layer, and the motion is CSS-only — the three glow layers carry
an `animation:` shorthand (the grid is deliberately still in phase
25), and nothing in styles.css references a script."""
for name, block in (
("body::before", _grid_rule(_css())),
("body::after", _glow_rule(_css())),
("html::before", _rule_block(_css(), "html::before")),
("html::after", _rule_block(_css(), "html::after")),
):
assert "filter" not in block, f"{name} must not use any filter"
for name, block in (
("body::after", _glow_rule(_css())),
("html::before", _rule_block(_css(), "html::before")),
("html::after", _rule_block(_css(), "html::after")),
):
assert "animation:" in block, f"{name} must be CSS-animated"
assert "blur" not in _css_no_comments(), (
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
)
+128 -195
View File
@@ -1,99 +1,117 @@
"""Unit: the phase-25 still-background contract (source pins).
"""Unit: the phase-78 static-background contract (source pins).
Owner report (2026-08-25, chat): the animated background "jitters down
and to the right every second and it slowly blinks brighter and darker.
It should be smooth, fluxuating, dimming and brightening, but not
moving. Different bright spots should slowly fade in and out."
Owner direction (TODO.md L4, recorded per AGENTS.md rule 3): "Remove the
animated css background, it's too resource intensive" — the three
opacity-fading glow spots (``body::after`` / ``html::before`` /
``html::after``), their glow keyframes, and the
``prefers-reduced-motion`` rule whose only job was stilling those layers
are deleted from ``styles.css``. The 44px grid texture on
``body::before`` STAYS — it is static (zero animation cost).
The diagnosis (`.agents/reports/25_background_no_motion/`) found both
root causes in the phase-22 design:
- "jitters down and to the right" = the grid's 44px/60s drift
(0.73px/s, diagonally down-right) — a 1px grid line translated
sub-pixel by sub-pixel rasterizes with per-frame stepping;
- "slowly blinks" = the whole-layer 14s opacity 0.85↔1 +
scale(1)↔scale(1.05) pulse — one synchronized pulse reads as a blink.
Supersedes the phase-25 fading-glow contract (superseded chain
08 → 25 → 78); the phase-25 unit source-pin suite
(``tests/unit/test_background_animation.py``) is deleted with its
premise (three fading glows on named keyframe cycles).
The fix (styles.css, pure CSS, zero JS, no `filter` — A11): the grid is
a STATIC texture (no animation, no bg-grid-drift keyframes), and three
independent soft glow spots (body::after, html::before, html::after)
each run their own SLOW opacity-only fade (26/34/42s, ease-in-out,
negative delays → out of phase; LCM 4641s → the composite pattern
effectively never repeats within a viewing session), so the total light
fluxuates smoothly and irregularly — no blink, no jitter, no movement.
Story: .agents/user_stories/background-no-motion.md. Browser behavior
(no motion, visible fades, no occlusion, no overflow) is E2E-covered by
tests/e2e/test_background_no_motion.py (task 02).
Story: n/a (TODO-derived). Browser behavior (no background animation in
a real viewport, the grid still painted, no occlusion, no overflow) is
E2E-covered by ``tests/e2e/test_background_no_motion.py`` (task 02).
"""
from __future__ import annotations
import re
from pathlib import Path
from tests.unit.test_background_animation import _css, _css_no_comments, _rule_block
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
# (layer, keyframes name, duration shorthand, single-spot gradient)
# The 2026-08-28 rebrand recolored the phase-08 indigo/cyan spots to the
# warm dark-red theme palette (rose / orange / red) — structure
# (radius, position, alpha, period, delay) is the phase-25 design.
GLOW_SPOTS = (
("body::after", "bg-glow-a", "animation: bg-glow-a 26s ease-in-out infinite",
"radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), transparent 62%)"),
("html::before", "bg-glow-b", "animation: bg-glow-b 34s ease-in-out -12s infinite",
"radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), transparent 62%)"),
("html::after", "bg-glow-c", "animation: bg-glow-c 42s ease-in-out -23s infinite",
"radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), transparent 62%)"),
STYLES_CSS = (
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
)
GRID_LAYER = "body::before" # the static 44px grid — STAYS
# The phase-25 glow layers — all three deleted in phase 78 (they were
# the ONLY animated part of the background).
GLOW_LAYERS = ("body::after", "html::before", "html::after")
def _bg_keyframes(css: str) -> dict[str, str]:
"""Name → body for every @keyframes bg-* rule (balanced braces —
works for the one-line blocks and a multi-line reformat alike)."""
out: dict[str, str] = {}
for m in re.finditer(r"@keyframes (bg-[A-Za-z0-9-]+)\s*\{", css):
start, depth, i = m.end(), 1, m.end()
while i < len(css) and depth:
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
i += 1
out[m.group(1)] = css[start:i - 1]
return out
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _css_no_comments() -> str:
"""styles.css with /* … */ comments stripped — for functional rule
checks that must not trip on explanatory prose."""
return re.sub(r"/\*[\s\S]*?\*/", "", _css())
def _find_rule(css: str, selector: str) -> re.Match[str] | None:
"""The first top-level ``selector { ... }`` rule, or None (the
deleted glow layers must be ABSENT, so this may not assert)."""
return re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css)
def _rule_block(css: str, selector: str) -> str:
"""Body of the first ``selector { ... }`` rule (top-level, no
nesting)."""
rule = _find_rule(css, selector)
assert rule, f"styles.css must define a {selector} rule"
return rule.group(1)
# --------------------------------------------------------------------------
# No movement — the grid is a static texture, and no bg-* keyframe may
# animate anything but opacity
# The animated part is gone — no glow layers, no glow keyframes
# --------------------------------------------------------------------------
def test_grid_has_no_animation() -> None:
"""body::before must carry NO animation declaration — the phase-22
0.73px/s drift rasterized as a once-per-second down-right jitter; the
owner wants no movement (2026-08-25)."""
block = _rule_block(_css(), "body::before")
assert "animation" not in block, (
"body::before must not animate (the no-movement contract)"
def test_no_bg_glow_keyframes_remain() -> None:
"""The three glow @keyframes blocks are deleted — the names must not
appear anywhere in the file (no declaration, no keyframe block, no
stale comment). Pinned by the ``bg-`` prefix: no token carrying the
background keyframe namespace may survive (the prefix is a plain
literal, so this pin itself carries none of the deleted names)."""
assert "bg-" not in _css(), (
"no token in the background keyframe namespace (bg-*) may remain "
"in styles.css — the glow keyframes and their declarations are deleted"
)
assert re.search(r"@keyframes bg-", _css_no_comments()) is None, (
"no background @keyframes may remain in styles.css"
)
def test_drift_and_breathe_keyframes_are_deleted() -> None:
"""@keyframes bg-grid-drift and @keyframes bg-glow-breathe are gone —
the names must not appear anywhere in the file (no declaration, no
keyframe block, no stale comment)."""
css = _css()
assert "bg-grid-drift" not in css, "bg-grid-drift must be deleted"
assert "bg-glow-breathe" not in css, "bg-glow-breathe must be deleted"
def test_glow_layers_are_deleted() -> None:
"""body::after / html::before / html::after no longer exist as CSS
rules — the layers (their background-images, their animations, their
fixed/z-index:-1 boxes) are gone from the page entirely."""
rules = _css_no_comments()
for sel in GLOW_LAYERS:
assert _find_rule(rules, sel) is None, (
f"{sel} must be deleted (the phase-78 static contract)"
)
def test_grid_keeps_its_static_texture() -> None:
"""The owner rejected the grid's MOTION, not the grid: 44px cells,
1px lines at the fixed 60% line alpha (rebrand warm tone,
2026-08-28), and the widened radial mask (both the -webkit- and
standard mask properties) stay."""
block = _rule_block(_css(), "body::before")
def test_no_background_layer_declares_animation() -> None:
"""None of the four background pseudo-element selectors carries an
``animation:`` declaration — the three glow selectors are ABSENT,
and the surviving grid layer is animation-free."""
rules = _css_no_comments()
for sel in (GRID_LAYER, *GLOW_LAYERS):
rule = _find_rule(rules, sel)
if rule is None:
continue # deleted layer — nothing to animate
assert "animation" not in rule.group(0), (
f"{sel} must not animate (static background contract)"
)
# --------------------------------------------------------------------------
# The static grid STAYS — byte-identical texture, no animation
# --------------------------------------------------------------------------
def test_grid_layer_is_static_and_unchanged() -> None:
"""The owner removed the animated part, not the grid: body::before
keeps 44px cells, 1px lines at the fixed 60% line alpha (warm
rebrand tone), and the widened radial mask (both the -webkit- and
standard mask properties) — and carries NO animation."""
block = _rule_block(_css(), GRID_LAYER)
assert "background-size: 44px 44px" in block
assert (
"linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
@@ -104,121 +122,33 @@ def test_grid_keeps_its_static_texture() -> None:
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
assert f"-webkit-mask-image: {mask};" in block
assert f"mask-image: {mask};" in block
assert "animation" not in block, "the grid must stay static (no animation)"
def test_exactly_three_bg_glow_keyframes_exist() -> None:
"""Exactly three bg-* keyframe blocks: bg-glow-a/b/c (the old
bg-grid-drift and bg-glow-breathe are deleted)."""
assert set(_bg_keyframes(_css())) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"}
def test_bg_keyframes_animate_opacity_only() -> None:
"""The no-movement contract: across ALL frames of ALL bg-* keyframes
the set of declared properties is exactly {opacity} — no transform,
scale, background-position, nothing else may appear."""
props: set[str] = set()
for _name, body in _bg_keyframes(_css()).items():
props |= set(re.findall(r"([A-Za-z-]+)\s*:", body))
assert props == {"opacity"}, (
f"bg-* keyframes must animate only opacity, found {sorted(props)}"
)
def test_glow_layers_declare_no_transform_or_position_animation() -> None:
"""The three glow layers themselves must not declare transform or
background-position either (the no-movement contract applies to the
layers, not just their keyframes)."""
for sel, _name, _anim, _grad in GLOW_SPOTS:
block = _rule_block(_css(), sel)
assert "transform" not in block, f"{sel} must not declare transform"
assert "background-position" not in block, (
f"{sel} must not declare background-position"
)
# --------------------------------------------------------------------------
# Three distinct bright spots, each on its own slow opacity-only fade
# --------------------------------------------------------------------------
def test_each_spot_runs_its_own_slow_opacity_fade() -> None:
"""body::after (rose, 26s), html::before (orange, 34s, -12s delay),
html::after (red, 42s, -23s delay) — the rebrand warm palette on
the phase-25 layout; each glow layer's background-image is EXACTLY
the single radial gradient from the spec (color, radius, position,
62% transparent stop)."""
for sel, _name, animation, gradient in GLOW_SPOTS:
block = _rule_block(_css(), sel)
assert animation in block, f"{sel} must run {animation}"
assert f"background-image: {gradient};" in block, (
f"{sel} must carry exactly one radial gradient: {gradient}"
)
def test_glow_durations_are_distinct_and_slow() -> None:
"""The three cycles are out of phase (distinct durations) and each is
slow (>= 20s); LCM(26, 34, 42) = 4641s, so the composite pattern
effectively never repeats within a viewing session."""
durations: list[float] = []
for sel, name, _anim, _grad in GLOW_SPOTS:
block = _rule_block(_css(), sel)
m = re.search(rf"animation: {name}\s+([\d.]+)s", block)
assert m, f"{sel} must run its {name} fade"
durations.append(float(m.group(1)))
assert len(set(durations)) == len(durations), (
"the three spot cycles must be out of phase (distinct durations)"
)
assert all(d >= 20 for d in durations), (
f"each spot fade must be slow (>= 20s), got {durations}"
)
def test_glow_keyframes_low_and_high_opacities() -> None:
"""Each cycle: 0%/100% at its own low opacity (0.25 / 0.20 / 0.15),
50% at 1 — smooth fade in and out, never a hard cut."""
keyframes = _bg_keyframes(_css())
lows = {"bg-glow-a": 0.25, "bg-glow-b": 0.20, "bg-glow-c": 0.15}
for name, low in lows.items():
body = keyframes[name]
m = re.search(r"0%,\s*100%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body)
assert m and float(m.group(1)) == low, (
f"{name} must start/end at opacity {low}"
)
m = re.search(r"50%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body)
assert m and float(m.group(1)) == 1.0, (f"{name} must peak at opacity 1")
# --------------------------------------------------------------------------
# Layer plumbing — the no-occlusion contract across all four layers
# --------------------------------------------------------------------------
def test_all_four_layers_are_fixed_zminus1_noninteractive() -> None:
"""All four background pseudo-layers stay behind the content and can
never intercept input: fixed, full-viewport, z-index -1,
pointer-events none, with pseudo content (UI Structure Check: layers
behind content, no 360px overflow — the layers are fixed; inset: 0)."""
for sel in ALL_LAYERS:
block = _rule_block(_css(), sel)
assert "position: fixed" in block, f"{sel} must stay position:fixed"
assert "inset: 0" in block, f"{sel} must stay full-viewport (inset: 0)"
assert "z-index: -1" in block, f"{sel} must stay z-index:-1"
assert "pointer-events: none" in block, f"{sel} must stay click-through"
assert 'content: ""' in block, f"{sel} must keep its pseudo content"
def test_grid_layer_plumbing() -> None:
"""The surviving grid layer stays behind the content and can never
intercept input: fixed, full-viewport, z-index -1, pointer-events
none, with pseudo content (UI Structure Check: the fixed; inset: 0
layer adds no width — no 360px overflow)."""
block = _rule_block(_css(), GRID_LAYER)
assert "position: fixed" in block
assert "inset: 0" in block
assert "z-index: -1" in block
assert "pointer-events: none" in block
assert 'content: ""' in block
def test_html_owns_canvas_and_body_stays_transparent() -> None:
"""The no-occlusion contract: <html> keeps the var(--bg) canvas;
<body> stays transparent and non-stacking — or the z-index:-1 layers
(including the new html::before / html::after spots) would be painted
over."""
"""The no-occlusion contract survives the deletion: <html> keeps the
var(--bg) canvas; <body> stays transparent and non-stacking — or the
z-index:-1 grid layer would be painted over."""
html_block = _rule_block(_css(), "html")
assert "background: var(--bg)" in html_block, (
"html must keep background: var(--bg) (the page canvas)"
)
body_block = _rule_block(_css(), "body")
assert "background: transparent" in body_block, (
"body must keep background: transparent so the layers show"
"body must keep background: transparent so the grid shows"
)
for prop in ("z-index", "transform", "opacity", "filter"):
assert prop + ":" not in body_block, (
@@ -227,31 +157,34 @@ def test_html_owns_canvas_and_body_stays_transparent() -> None:
# --------------------------------------------------------------------------
# Reduced motion + phase-08 anchors (no filter, no blur, zero JS)
# Reduced motion + phase-08 anchors
# --------------------------------------------------------------------------
def test_reduced_motion_stills_all_four_layers() -> None:
"""prefers-reduced-motion: reduce must still ALL FOUR layers together
(body::before, body::after, html::before, html::after) with
animation: none — the typing/spinner/thinking blocks are untouched."""
def test_background_reduced_motion_block_is_deleted() -> None:
"""The prefers-reduced-motion rule whose only job was stilling the
background layers (body::before, body::after, html::before,
html::after → animation: none) is deleted WITH the layers. The
unrelated reduced-motion blocks (typing dots, spinner, toasts, nav
slide, …) stay untouched."""
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}", _css()
)
assert any(
all(sel in b for sel in ALL_LAYERS) and "animation: none" in b
for b in blocks
), "a reduced-motion block must still all four background layers"
assert blocks, "the unrelated reduced-motion blocks must survive"
for i, block in enumerate(blocks):
for sel in (GRID_LAYER, *GLOW_LAYERS):
assert sel not in block, (
f"reduced-motion block {i} still references background layer {sel}"
)
def test_no_filter_in_any_layer_and_no_blur_anywhere() -> None:
"""The phase-08 performance anchor: no `filter` in any background
layer block, and no `blur` anywhere in styles.css (comments
stripped)."""
for sel in ALL_LAYERS:
assert "filter" not in _rule_block(_css(), sel), (
f"{sel} must not use any filter"
)
def test_no_filter_no_blur() -> None:
"""The phase-08 performance anchor survives the deletion: no
``filter`` in the grid rule and no ``blur`` anywhere in styles.css
(comments stripped)."""
assert "filter" not in _rule_block(_css(), GRID_LAYER), (
"the grid layer must not use any filter"
)
assert "blur" not in _css_no_comments(), (
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
)
+1
View File
@@ -211,6 +211,7 @@ def test_html_pages_include_history() -> None:
"/tuning.html",
"/git-sources.html",
"/history.html",
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
"/shared.html", # phase 51: the shared page's static path
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
):
+19 -1
View File
@@ -22,6 +22,7 @@ query_log row; structured ``error`` frame, not logged as cancelled).
from __future__ import annotations
import asyncio
import base64
import gc
import json
import logging
@@ -32,9 +33,10 @@ from types import SimpleNamespace
from typing import Any
import pytest
from itsdangerous import TimestampSigner
from app.api import chat as chat_api
from app.config import Settings
from app.config import Settings, get_settings
from app.main import app as fastapi_app
from app.models import Document, KbOverview, QueryLog
from app.rag.llm import LLMClient
@@ -195,6 +197,21 @@ def _install_llm(monkeypatch: pytest.MonkeyPatch, llm: LLMClient) -> None:
# ---------- the ASGI driver (client disconnect at the ASGI boundary) ----------
def _admin_cookie_header() -> tuple[bytes, bytes]:
"""A valid signed ``bor_session`` cookie carrying the admin session.
Phase 79 (task 03): ``POST /api/chat`` is user-gated, and the raw
ASGI scope below carries no browser — so it presents the same signed
cookie ``SessionMiddleware`` would have emitted after
``POST /api/login`` (the admin session short-circuits
``require_user``; the anonymous 401 contract is pinned in
``test_auth_api.py``)."""
settings = get_settings()
data = base64.b64encode(json.dumps({"admin": True}).encode("utf-8"))
signed = TimestampSigner(settings.session_secret).sign(data)
return b"cookie", f"{settings.session_cookie}={signed.decode('ascii')}".encode("ascii")
def _scope() -> dict[str, Any]:
return {
"type": "http",
@@ -209,6 +226,7 @@ def _scope() -> dict[str, Any]:
"headers": [
(b"host", b"testserver"),
(b"content-type", b"application/json"),
_admin_cookie_header(), # phase 79: the signed-in admin
],
"client": ("testclient", 50000),
"server": ("testserver", 80),
+12
View File
@@ -24,6 +24,7 @@ from app.rag.agent import AGENT_TOOLS
from app.rag.llm import StreamPiece
from app.rag.retriever import RetrievedChunk, weak_hit_titles
from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions
from tests.conftest import ADMIN_PASSWORD
if TYPE_CHECKING:
from app.rag.scaffolding import ScaffoldingFilter
@@ -492,6 +493,17 @@ class _FakeSession:
return None
@pytest.fixture(autouse=True)
def _admin_signed_in(client: TestClient) -> None:
"""Phase 79 (task 03): ``POST /api/chat`` is user-gated — the
endpoint-level tests run as the signed-in ADMIN, so the shared
``client`` logs in once per test. The admin session short-circuits
``require_user`` before any DB touch, so the fake-session wiring in
``gate_env`` is untouched."""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
@pytest.fixture()
def gate_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[_FakeSession, _CannedLLM]]:
"""``POST /api/chat`` with retriever, session, and LLM all faked."""
+4
View File
@@ -165,6 +165,10 @@ def test_new_chat_clears_key_and_ui() -> None:
assert "clearStoredConversation()" in body
assert 'querySelectorAll(".msg")' in body
assert "emptyState.hidden = false" in body
assert "loadSuggestions()" in body, (
"phase 80: the empty state came back — the onboarding chips refetch "
"so the row reflects the CURRENT last-3 state, not the boot fetch"
)
assert "setUiState(UI_STATE.idle)" in body
assert "sendStatus.textContent" in body, "confirmation via the live region"
assert "UI_STATE.thinking" in body and "UI_STATE.streaming" in body, (
+9 -2
View File
@@ -34,6 +34,7 @@ from app.api.docs import doc_format
from app.db import get_db
from app.main import create_app
from app.models import Document
from tests.conftest import ADMIN_PASSWORD
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
@@ -96,10 +97,16 @@ class _FakeSession:
def _client_with_row(row: object) -> TestClient:
"""Fresh app whose ``get_db`` dependency is a stub returning ``row``
(``None`` → no matching document row)."""
(``None`` → no matching document row). Phase 79: the endpoint is
user-gated, so the client signs in as the admin first — these tests
pin the CONTENT mapping (200/404/422), not the auth contract (which
``test_auth_api.py`` pins)."""
app = create_app()
app.dependency_overrides[get_db] = lambda: _FakeSession(row)
return TestClient(app)
client = TestClient(app)
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
def test_content_unknown_pair_maps_to_404() -> None:
+513 -3
View File
@@ -36,6 +36,45 @@ active stamps) are gone with the four folded view documents, so
Chat link), and header.js carries NO ``is-active`` write: the whoami
auth gate + the mobile hamburger are its only nav responsibilities,
and the router is the SINGLE runtime writer of the active state.
Phase 77 task 01 (the re-show refresh hook): a user-initiated re-show
of an ALREADY-MOUNTED view dispatches ``bor:view-refresh`` on the
view's section — gated on the pre-mount ``wasMounted`` capture, so the
first show (the mount) and boot never fire it (the mount's own load is
the first fetch); a re-click of the active view's own nav link
dispatches the event instead of a bare return (no ``pushState`` — the
URL is already that view's path); the History view listens (armed only
in the admin branch, after the whoami gate — anonymous never fetches).
Phase 77 task 02 (the other data views join the refresh): RAG
(``sources.js``), Sources (``git-sources.js``) and Tuning
(``tuning.js``) each listen for ``bor:view-refresh`` on their root and
re-run their existing load (armed only in the admin branch, after the
whoami gate — the same gate guard as History). ``sources.js``'s
``loadDocs`` clears the tbody's rows at the TOP (before the fetch —
the History pattern), so a refresh from a populated list into an empty
result leaves no ghost rows. The Chat view (``app.js``) does NOT
listen — the negative pin: the in-flight SSE stream and the local
conversation must survive every switch (the phase-76 contract), so
the exclusion is a contract, not an oversight.
Phase 77 task 03 (the explicit History refresh control, TODO.md L3):
the History page-head becomes a flex row (scoped to ``#view-history``
— the other four views' page-heads are untouched) carrying the
``#history-refresh`` button (``aria-label="Refresh saved chats"``,
the aria-hidden house refresh glyph + the visible "Refresh" label —
the phase-46 auth-link convention) OUTSIDE the table wrap (reachable
while the empty state shows). history.js binds it in the admin branch
only (the anonymous branch hides it — no dead control beside the
gate); the click disables the button (no double-fire in flight),
reruns the re-entrant ``loadChats()`` and re-enables on success AND
failure (the finally). The outcome lands in ``#history-status``:
``Saved chats refreshed.`` on success (a 0-row fetch is a success) —
and the failure lines now live INSIDE ``loadChats`` (the house copy:
"is the app reachable?" / "try again."), so every caller of a failed
load sees it (the §7.4 never-stale contract). styles.css reuses the
``.new-chat-btn`` visual language (brand pill, ≥44px, hover,
:disabled) and goes icon-only below 640px.
"""
from __future__ import annotations
@@ -64,8 +103,9 @@ def _html() -> str:
def test_view_map_covers_the_shell_paths() -> None:
"""The VIEW map is pathname → view name: the shell's own two URLs
("/" and "/index.html") are the chat view, plus one entry per
folded view (tasks 01–03: tuning, rag, git-sources, history —
all four non-chat navbar views are in)."""
folded view (tasks 01–03: tuning, rag, git-sources, history;
phase 79 task 06: tokens — all five non-chat navbar views are
in)."""
js = _js()
view_start = js.find("const VIEW = {")
assert view_start != -1, "the VIEW map must exist"
@@ -80,8 +120,11 @@ def test_view_map_covers_the_shell_paths() -> None:
assert '"/history.html": "history"' in view_body, (
"task 03 folds the History view into the shell"
)
assert '"/tokens.html": "tokens"' in view_body, (
"phase 79 task 06 folds the Tokens view into the shell"
)
# The view names are the #view-<name> section slugs in index.html.
for name in ("chat", "tuning", "history"):
for name in ("chat", "tuning", "history", "tokens"):
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
@@ -172,6 +215,9 @@ def test_only_non_chat_views_have_lazy_modules() -> None:
assert 'history: () => import("./history.js")' in mods_body, (
"the History view module is lazy-imported on first show"
)
assert 'tokens: () => import("./tokens.js")' in mods_body, (
"the Tokens view module is lazy-imported on first show"
)
assert '"chat"' not in mods_body, "the chat view has no lazy module"
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
@@ -227,6 +273,8 @@ def test_router_writes_active_state_title_and_meta() -> None:
assert "Manage the global tuning notes that steer every Brain of Reese answer." in js
assert 'history: "Saved chats · Brain of Reese"' in js
assert "Saved chats — every conversation is saved automatically, one click back." in js
assert 'tokens: "Access tokens · Brain of Reese"' in js
assert "Generate and revoke the API tokens that let people use the app." in js
# The brand composition (phase 39's window.BOR_BRAND, read at
# write time — never a hardcoded stamp).
assert 'window.BOR_BRAND || "Brain of Reese"' in js
@@ -291,6 +339,15 @@ def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
tuning_link = tuning_match.group(0)
assert "hidden" in tuning_link, "#nav-tuning ships hidden (admin-only)"
assert "is-active" not in tuning_link, "no static active stamp on the Tuning link"
# The Tokens nav link (phase 79 task 06) ships hidden (admin-only)
# and UNstamped too — the router is the single writer of the active
# state, and a token user (role "user") must never see the link
# (header.js reveals it for admin only).
tokens_match = re.search(r'<a[^>]*id="nav-tokens"[^>]*>', html)
assert tokens_match, "the shell must carry the #nav-tokens nav link"
tokens_link = tokens_match.group(0)
assert "hidden" in tokens_link, "#nav-tokens ships hidden (admin-only)"
assert "is-active" not in tokens_link, "no static active stamp on the Tokens link"
# ---------- phase 76 task 04: the header is shell-owned ----------
@@ -352,3 +409,456 @@ def test_boot_order_is_brand_app_router() -> None:
assert 'type="module"' in router_tag, "router.js is an ES module"
# No CDN: every asset reference is local (AGENTS.md rule 6).
assert 'src="http' not in html and 'href="http' not in html
# ---------- phase 77 task 01: the re-show refresh hook ----------
def test_reshow_dispatches_view_refresh_gated_on_pre_mount_capture() -> None:
"""Phase 77: a user-initiated re-show of an already-mounted view
dispatches the ``bor:view-refresh`` CustomEvent on the view's
section. The dispatch site is INSIDE the ``if (wasMounted)`` guard,
and the ``wasMounted`` capture runs BEFORE the mount-once set
(``mounted[name] = true``) — so the first show (the mount) and boot
never dispatch: the mount's own load is the first fetch. Event
order: the view is visible and the head/nav state is written
BEFORE the refresh fires, and the focus/scroll tail runs after."""
js = _js()
assert '"bor:view-refresh"' in js, "the refresh event literal must exist"
fn = js.find("async function switchTo")
assert fn != -1, "switchTo must exist"
body = js[fn : js.find("\n}", fn)]
capture = body.find("const wasMounted = mounted[name]")
mount_set = body.find("mounted[name] = true")
assert 0 <= capture < mount_set, (
"the wasMounted capture must precede the mount-once set "
"(first show is exempt from the refresh)"
)
gate = body.find("if (wasMounted)")
dispatch = body.find('root.dispatchEvent(new CustomEvent("bor:view-refresh"))')
assert 0 <= gate < dispatch < gate + 120, (
"the dispatch must sit inside the wasMounted guard"
)
show_loop = body.find("Object.entries(viewEls)")
title_write = body.find("document.title = titleFor(name)")
current_set = body.find("current = name")
focus = body.find("root.focus(")
assert show_loop < title_write < current_set < gate < dispatch < focus, (
"visible → head/nav state → refresh dispatched → focus/scroll tail"
)
def test_active_view_reclick_dispatches_refresh_not_bare_return() -> None:
"""Phase 77: a re-click of the ACTIVE view's own nav link is a
re-fetch, not a no-op — the ``name === current`` branch dispatches
the refresh event on that view's section and returns. It must NOT
pushState (the URL is already this view's path) and must NOT
re-run the switch (no re-mount)."""
js = _js()
fn = js.find('nav.addEventListener("click"')
assert fn != -1, "the delegated click handler on the nav must exist"
body = js[fn : js.find("\n });", fn)]
branch = body.find("if (name === current)")
assert branch != -1, "the active re-click branch must exist"
branch_end = body.find("}", branch)
branch_body = body[branch : branch_end + 1]
assert 'new CustomEvent("bor:view-refresh")' in branch_body, (
"the re-click branch must dispatch the refresh event (not a bare return)"
)
assert "history.pushState" not in branch_body, (
"the re-click must NOT pushState — the URL is already this view's path"
)
assert "switchTo" not in branch_body, "the re-click must NOT re-run the switch"
assert "return" in branch_body, "the re-click still returns early (menu closes)"
def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None:
"""Phase 77: the History view re-fetches on a user-initiated
re-show — history.js registers a ``bor:view-refresh`` listener on
the view's root that re-runs the (now re-entrant) ``loadChats()``.
The listener is armed only AFTER the whoami gate passes: anonymous
shows the gate and never fetches (the phase-50 contract the story
E2E pins), and the ``started`` flag means the listener can only
re-run a load the mount already made."""
history_js = (ASSETS / "history.js").read_text(encoding="utf-8")
assert 'addEventListener("bor:view-refresh"' in history_js, (
"history.js must listen for the refresh event on the view root"
)
gate = history_js.find("if (!(await fetchIsAdmin()))")
listener = history_js.find('addEventListener("bor:view-refresh"')
assert 0 <= gate < listener, (
"the listener is armed only in the ADMIN branch (after the gate)"
)
assert re.search(r"if \(started\)\s+loadChats\(\)", history_js), (
"the listener is gated on the first load (started)"
)
# Re-entrancy: a re-load drops the data rows (except the hidden
# empty-state row) before fetching — the list is replaced, not
# duplicated.
load = history_js.find("async function loadChats()")
assert load != -1, "loadChats must exist"
load_body = history_js[load : history_js.find("\n }", load)]
assert "tr !== emptyRow" in load_body and "tr.remove()" in load_body, (
"loadChats must remove the data rows (the empty row stays) first"
)
clear_i = load_body.find("tr !== emptyRow")
fetch_i = load_body.find('fetch("/api/chats")')
assert 0 <= clear_i < fetch_i, "the row clearing precedes the fetch"
# ---------- phase 77 task 02: RAG / Sources / Tuning re-fetch; chat stays out ----------
def _asset(name: str) -> str:
path = ASSETS / name
assert path.is_file(), f"missing {path}"
return path.read_text(encoding="utf-8")
def _pin_refresh_listener(js: str, gate: str, listener_call: str, name: str) -> None:
"""Shared shape of the task-02 pin: the view module listens for
``bor:view-refresh`` on its own root, the listener re-runs the
view's existing load, and the listener is armed ONLY in the ADMIN
branch — after the whoami gate (anonymous never fetches)."""
listener = js.find('addEventListener("bor:view-refresh"')
assert listener != -1, f"{name} must listen for the refresh event on the view root"
gate_i = js.find(gate)
assert 0 <= gate_i < listener, (
f"{name}: the listener must be armed in the ADMIN branch (after {gate!r})"
)
assert listener_call in js[listener : listener + 120], (
f"{name}: the listener must re-run the view's load ({listener_call!r})"
)
def test_rag_view_refetches_on_reshow() -> None:
"""Phase 77 task 02: the RAG (knowledge base) view re-fetches on a
user-initiated re-show — sources.js listens and re-runs
``loadDocs()``. ``loadDocs`` is now re-entrant: the tbody's rows
are cleared at the TOP, before the fetch (the History pattern from
task 01), so a refresh from a populated list into an empty result
replaces the list instead of leaving ghost rows."""
js = _asset("sources.js")
_pin_refresh_listener(
js, "const admin = await fetchIsAdmin();", "() => loadDocs()", "sources.js"
)
load = js.find("async function loadDocs()")
assert load != -1, "loadDocs must exist"
body = js[load : js.find("\n }", load)]
clear_i = body.find("tbody.replaceChildren()")
fetch_i = body.find('fetch("/api/docs")')
assert 0 <= clear_i < fetch_i, (
"the row clearing must precede the fetch (a populated → empty refresh "
"must not leave ghost rows)"
)
def test_git_sources_view_refetches_on_reshow() -> None:
"""Phase 77 task 02: the Sources (git-sources) view re-fetches on a
user-initiated re-show — git-sources.js listens and re-runs
``loadSources()``. A re-call resets ALL THREE list states: the
populated render (renderSources replaces the tbody + re-syncs the
empty state) and the load error (``hideLoadError()`` runs on the
success path BEFORE rendering, so an error followed by a
successful refresh clears it)."""
js = _asset("git-sources.js")
_pin_refresh_listener(
js, "const admin = await fetchIsAdmin();", "() => loadSources()", "git-sources.js"
)
load = js.find("async function loadSources()")
assert load != -1, "loadSources must exist"
body = js[load : js.find("\n }", load)]
hide_i = body.find("hideLoadError()")
render_i = body.find("renderSources(")
assert 0 <= hide_i < render_i, (
"the success path must clear the load error before rendering "
"(an error followed by a successful refresh clears the error)"
)
render = js.find("function renderSources(")
assert render != -1, "renderSources must exist"
render_body = js[render : js.find("\n }", render)]
assert "tbody.replaceChildren()" in render_body, (
"a re-render replaces the list (the populated state resets)"
)
def test_tuning_view_refetches_on_reshow() -> None:
"""Phase 77 task 02: the Tuning view re-fetches on a user-initiated
re-show — tuning.js listens and re-runs ``loadNotes()``. A re-call
replaces the list (renderNotes clears it first); a FAILED refresh
keeps the last rendered list — loadNotes's documented contract
(progressive enhancement, never a blanked panel), unchanged by the
listener (it just calls the function)."""
js = _asset("tuning.js")
_pin_refresh_listener(
js, "if (await fetchIsAdmin())", "() => loadNotes()", "tuning.js"
)
render = js.find("function renderNotes(")
assert render != -1, "renderNotes must exist"
render_body = js[render : js.find("\n }", render)]
assert 'tuneList.textContent = ""' in render_body, (
"a re-render clears the list first (the re-call replaces it)"
)
def test_chat_view_does_not_listen_for_view_refresh() -> None:
"""Negative pin: app.js (the chat view) must NOT listen for
``bor:view-refresh`` — the in-flight SSE stream and the local
conversation survive EVERY switch (the phase-76 contract the
stream E2E pins). The exclusion is a contract, not an oversight;
the deliberate-exclusion comment lives at the chat view's
module-scope state in app.js."""
js = _asset("app.js")
assert 'addEventListener("bor:view-refresh"' not in js, (
"the chat view must NOT listen for the refresh event — its "
"in-flight stream and local conversation must survive every "
"switch (phase 76)"
)
assert "bor:view-refresh" in js, (
"the exclusion is documented at the chat view's module-scope state"
)
# ---------- phase 77 task 03: the History refresh button ----------
def _history_view(html: str) -> str:
"""The shell's History view section (the test_history_page pattern):
from the #view-history open tag to the container main's close
(the view is the shell's LAST view section)."""
start = html.find('<section class="view" id="view-history"')
assert start != -1, "the #view-history section must be in the shell"
end = html.find("</main>", start)
assert end != -1, "the container main must close after the view"
return html[start:end]
def test_history_refresh_button_markup_lives_in_the_page_head() -> None:
"""Phase 77 task 03 (TODO.md L3): the History page-head carries the
explicit refresh control — #history-refresh, a ``type="button"``
``.history-refresh`` with the accessible name
``aria-label="Refresh saved chats"``, the house inline-SVG refresh
glyph (aria-hidden) and the visible "Refresh" label (the phase-46
auth-link convention: label visible ≥640px, icon-only below — the
aria-label keeps the name in both). It sits INSIDE the view's
.page-head and BEFORE the table wrap (outside it — the button must
stay reachable while the empty state is showing)."""
view = _history_view(_html())
btn_i = view.find('id="history-refresh"')
assert btn_i != -1, "the #history-refresh button must exist"
tag_start = view.rfind("<button", 0, btn_i)
tag_end = view.find(">", btn_i)
tag = view[tag_start:tag_end]
assert 'type="button"' in tag, "a plain button (no form submit)"
assert 'class="history-refresh"' in tag
assert 'aria-label="Refresh saved chats"' in tag, ("the accessible name")
tail = view[tag_end:tag_end + 600]
assert 'aria-hidden="true"' in tail, "the refresh glyph must be aria-hidden"
assert '<span class="history-refresh-label">Refresh</span>' in tail, (
"the visible Refresh label (icon-only below 640px, label above)"
)
head_i = view.find('class="page-head"')
wrap_i = view.find('id="history-table-wrap"')
assert -1 < head_i < btn_i < wrap_i, (
"the button sits in the page-head, before (OUTSIDE) the table wrap"
)
def test_history_refresh_button_binding_admin_only_with_outcome_lines() -> None:
"""Phase 77 task 03: history.js binds #history-refresh in the ADMIN
branch only — the anonymous branch HIDES the button (the gate is
what anonymous sees; no dead control beside the sign-in gate) and
still fetches nothing. The click handler disables the button
BEFORE the fetch (no double-fire while in flight) and delegates to
the re-entrant load; the re-enable sits in a ``finally`` (success
AND failure — a click can never leave the button stuck disabled).
The success line is ``Saved chats refreshed.``; the failure lines
live INSIDE ``loadChats`` itself — the house copy (network:
"is the app reachable?"; non-2xx: "try again.") — so every caller
of a failed load (the mount's first load, a re-show, the button)
sees the outcome in #history-status."""
js = _asset("history.js")
gate = js.find("if (!(await fetchIsAdmin()))")
assert gate != -1
branch = js[gate:js.find("return;", gate)]
assert "refreshBtn.hidden = true" in branch, (
"the anonymous branch hides the button (no dead control)"
)
admin_after = js[js.find("return;", gate):]
bind = admin_after.find('refreshBtn.addEventListener("click"')
assert bind != -1, "the refresh binding must exist in the admin branch"
handler = admin_after[bind:admin_after.find(");", bind)]
assert "refreshBtn.disabled = true" in handler, (
"the click disables the button before the fetch (no double-fire)"
)
# refreshChats is defined alongside loadChats (before the gate) —
# its BODY is pinned on the whole file, its BINDING on the admin
# branch above (the hoisted function is only reachable from the
# admin-branch binding: the anonymous branch never references it).
fn = js.find("async function refreshChats()")
assert fn != -1, "refreshChats must exist"
fn_body = js[fn:js.find("\n }", fn)]
assert "loadChats()" in fn_body, "the button re-runs the (re-entrant) load"
assert "finally" in fn_body and "refreshBtn.disabled = false" in fn_body, (
"the button re-enables on success AND failure (the finally)"
)
assert '"Saved chats refreshed."' in fn_body, "the exact success line"
load = js.find("async function loadChats()")
load_body = js[load:js.find("\n }", load)]
assert "Couldn't load saved chats — is the app reachable?" in load_body, (
"the network-error line lives in loadChats (every caller sees it)"
)
assert "Couldn't load saved chats — try again." in load_body, (
"the non-2xx line lives in loadChats (every caller sees it)"
)
def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
"""Phase 77 task 03 (styles.css): .history-refresh reuses the
.new-chat-btn visual language — the solid brand pill (--bg text on
--brand, 5.2:1 ≥ WCAG 4.5:1), the ≥44px target, the lightened
hover fill, the dimmed :disabled (the in-flight state), the glyph
hidden on desktop (the label carries the pill) — and the global
:focus-visible ring applies (no button-scoped focus override).
The page-head flex row is SCOPED to #view-history (the other four
views' page-heads are untouched). Below 640px the pill goes
icon-only (the phase-46 auth-link convention — the aria-label
keeps the accessible name)."""
css = _asset("styles.css")
block = re.search(r"\.history-refresh \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .history-refresh"
body = block.group(1)
assert "background: var(--brand)" in body, "the .new-chat-btn brand fill"
assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)"
assert "min-height: 44px" in body, "the comfortable touch target"
assert "border-radius: 999px" in body and "border: 0" in body, "the pill"
assert ".history-refresh:hover { background: #f55a72; color: var(--bg); }" in css
assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, (
"the in-flight disabled state is dimmed (the house language)"
)
assert ".history-refresh svg { width: 16px; height: 16px; display: none; }" in css, (
"desktop: the label carries the pill (the glyph is hidden)"
)
row = re.search(r"#view-history \.page-head \{([\s\S]*?)\n\}", css)
assert row and "display: flex" in row.group(1), (
"the page-head flex row is scoped to the History view"
)
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the 640px media query must exist"
mbody = mobile.group(1)
assert ".history-refresh-label { display: none; }" in mbody, (
"icon-only below 640px (the phase-46 convention)"
)
assert ".history-refresh svg { display: block; }" in mbody, (
"the glyph is the whole control below 640px"
)
# ---------- phase 79 task 06: the Tokens view (generate · list · revoke) ----------
def test_tokens_view_module_contract() -> None:
"""Phase 79 task 06: tokens.js follows the phase-76 view-module
contract — ``export async function mount(root)`` is the entry, the
whoami gate (``fetchIsAdmin``) runs in mount and the anonymous
branch shows the gate + hides the table + RETURNS with NO
/api/tokens request (the router 403s anonymous), the
``bor:view-refresh`` listener is armed ONLY in the ADMIN branch
(after the gate) and re-runs the re-entrant ``loadTokens()``
(gated on the ``started`` flag), and ``loadTokens`` hides the
once-block and clears the data rows BEFORE the fetch — the
plaintext is never re-shown and the list is replaced, not
duplicated. Every cell is textContent: the file never touches
innerHTML (XSS-safe by construction)."""
js = _asset("tokens.js")
assert "export async function mount(root)" in js, (
"mount(root) must be the module's entry (the phase-76 fold)"
)
assert 'import { fetchIsAdmin } from "./header.js";' in js, (
"the view imports ONLY the shared cached whoami promise"
)
mount_i = js.find("export async function mount(root)")
gate_i = js.find("if (!(await fetchIsAdmin()))")
assert 0 <= mount_i < gate_i, "the whoami gate must run in mount"
# The anonymous branch: gate in, table out, then a bare return —
# and NO fetch call anywhere inside it.
branch = js[gate_i:js.find("return;", gate_i)]
assert "fetch(" not in branch, (
"the anonymous branch must not fetch anything"
)
assert "tableWrap.hidden = true" in branch
assert "gateEl.hidden = false" in branch
# The re-show refresh: armed in the ADMIN branch only (after the
# gate), gated on the first load (started), re-running loadTokens.
listener = js.find('addEventListener("bor:view-refresh"')
assert 0 <= gate_i < listener, (
"the refresh listener is armed only in the ADMIN branch (after the gate)"
)
assert re.search(r"if \(started\)\s+loadTokens\(\)", js), (
"the listener is gated on the first load (started)"
)
# loadTokens: re-entrant — the once-block hides and the data rows
# (except the hidden empty-state row) are dropped BEFORE the fetch.
load = js.find("async function loadTokens()")
assert load != -1, "loadTokens must exist"
load_body = js[load:js.find("\n }", load)]
hide_i = load_body.find("onceBlock.hidden = true")
clear_i = load_body.find("tr !== emptyRow")
fetch_i = load_body.find('fetch("/api/tokens")')
assert 0 <= hide_i < clear_i < fetch_i, (
"once-block hide + row clearing must precede the fetch "
"(a re-render never re-shows the plaintext; the list is replaced)"
)
assert "innerHTML" not in js, (
"every cell is textContent — no innerHTML anywhere (XSS-safe)"
)
def test_tokens_view_scaffold_in_the_shell() -> None:
"""Phase 79 task 06: the shell carries the #view-tokens section —
hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) —
with the page-head (h1 \"Access tokens\"), the #tokens-gate (the
#history-gate pattern, ship-hidden, its Sign in returning to the
Tokens view), the role=\"status\" live region, the create row
(label input + Generate — ship-hidden, anonymous-safe), the
#token-once block (ship-hidden — only a 201 reveals it), and the
full-width table (AGENTS.md rule 5) with the visually-hidden
Actions header + the hidden #tokens-empty-row."""
html = _html()
view = html.find('<section class="view" id="view-tokens"')
assert view != -1, "the #view-tokens section must be in the shell"
tag_end = html.find(">", view)
tag = html[view:tag_end]
assert "hidden" in tag and "inert" in tag, (
"the folded view ships hidden AND inert"
)
assert 'tabindex="-1"' in tag, "the target view is focusable"
main_end = html.find("</main>", view)
assert view < main_end, "the view section lives inside the single main"
body = html[view:main_end]
assert "<h1>Access tokens</h1>" in body
gate = re.search(r'<section[^>]*id="tokens-gate"[^>]*>', body)
assert gate and "hidden" in gate.group(0), "#tokens-gate must ship hidden"
assert 'href="/login.html?next=/tokens.html"' in body, (
"the gate's Sign in returns to the Tokens view (no-JS fallback)"
)
assert re.search(r'<span[^>]*id="tokens-status"[^>]*role="status"[^>]*>', body)
create = re.search(r'<div[^>]*id="token-create"[^>]*>', body)
assert create and "hidden" in create.group(0), (
"the create row ships hidden (anonymous-safe)"
)
assert re.search(r'<input[^>]*id="token-label"[^>]*>', body)
assert re.search(r'<button[^>]*id="token-generate"[^>]*>', body)
once = re.search(r'<div[^>]*id="token-once"[^>]*>', body)
assert once and "hidden" in once.group(0), (
"the once-block ships hidden (only a 201 reveals it)"
)
assert re.search(r'<input[^>]*id="token-once-value"[^>]*readonly[^>]*>', body)
assert re.search(r'<button[^>]*id="token-once-copy"[^>]*>', body)
wrap = re.search(r'<div[^>]*id="tokens-table-wrap"[^>]*>', body)
assert wrap and 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
assert 'id="tokens-tbody"' in body
assert re.search(r'<tr[^>]*id="tokens-empty-row"[^>]*hidden>', body)
# The Actions column header is visually-hidden (the row buttons
# carry their own aria-labels — the history-table convention).
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
+6 -3
View File
@@ -292,12 +292,15 @@ def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
boot_start = js.find("(async () => {")
assert boot_start != -1, "the boot IIFE must exist"
boot = js[boot_start:]
# Phase 79 (task 05): the token gate settles FIRST — a cached
# token's silent re-auth lands before the first whoami fires.
gate_i = boot.find("await mountGate(")
init_i = boot.find("await initSharedHeader();")
admin_i = boot.find("isAdmin = await fetchIsAdmin();")
admin_i = boot.find('who.role === "admin";')
saved_i = boot.find("await restoreSavedChatFromUrl();")
local_i = boot.find("restoreConversation();")
assert -1 < init_i < admin_i < saved_i < local_i, (
"boot order: header init → whoami → ?chat= load → local fallback"
assert -1 < gate_i < init_i < admin_i < saved_i < local_i, (
"boot order: token gate → header init → whoami → ?chat= load → local fallback"
)
assert "shareBtn.hidden" not in boot, ("no Share-reveal line left in boot (phase 55 task 03)")
assert "if (!openedSaved) restoreConversation();" in boot, (
+64 -17
View File
@@ -44,29 +44,69 @@ def _script_srcs(path: Path) -> list[str]:
# ---------- header.js: the module itself ----------
def test_header_module_exports_the_three_functions() -> None:
"""header.js must export the three functions every page script
imports (fetchIsAdmin / initSharedHeader / clearChatStorage)."""
def test_header_module_exports_the_header_functions() -> None:
"""header.js must export the functions every page script imports
(fetchWhoami — the phase-79 canonical call — fetchIsAdmin, its
phase-16/19 backward-compatible delegation, initSharedHeader,
clearChatStorage) plus resetWhoami (the phase-79 cache
invalidation the token gate uses after a mid-page auth)."""
js = _text(HEADER_JS)
assert "export function fetchWhoami" in js
assert "export function fetchIsAdmin" in js
assert "export function resetWhoami" in js
assert "export async function initSharedHeader" in js
assert "export function clearChatStorage" in js
def test_whoami_fetch_is_cached_in_a_module_level_promise() -> None:
"""The whoami fetch is cached in the module-level `adminPromise`
marker — first call stores the promise, later calls return it, so a
page makes exactly ONE /api/whoami request per load no matter how
many consumers await it. Anonymous-safe: a failure resolves to
false."""
"""The whoami fetch is cached in the module-level `whoamiPromise`
marker (phase 79, task 05: it stores the FULL response —
{ authenticated, role } — not just the admin flag) — first call
stores the promise, later calls return it, so a page makes exactly
ONE /api/whoami request per load no matter how many consumers
await it. Anonymous-safe: non-2xx / network failure / malformed
body all resolve to { authenticated: false, role: "anonymous" }.
The string `fetch("/api/whoami")` appears in this file EXACTLY
ONCE — the single-request contract (the rest of the frontend goes
through fetchWhoami/fetchIsAdmin)."""
js = _text(HEADER_JS)
assert re.search(r"let\s+adminPromise\s*=\s*null", js), (
"module-level adminPromise marker missing"
assert re.search(r"let\s+whoamiPromise\s*=\s*null", js), (
"module-level whoamiPromise marker missing"
)
assert 'fetch("/api/whoami")' in js
assert "if (!adminPromise)" in js, "fetchIsAdmin must reuse the stored promise"
assert "return adminPromise" in js
assert ".catch(() => false)" in js, "network failure must resolve to anonymous"
assert js.count('fetch("/api/whoami")') == 1, (
"the SINGLE /api/whoami call site lives in header.js exactly once"
)
assert "if (!whoamiPromise)" in js, "fetchWhoami must reuse the stored promise"
assert "return whoamiPromise" in js
assert 'role: "anonymous"' in js, "the anonymous fallback carries the role"
assert ".catch(() => ANONYMOUS_WHOAMI)" in js, (
"network failure must resolve to the anonymous role"
)
def test_fetch_is_admin_delegates_to_fetch_whoami() -> None:
"""Phase 79 (task 05): fetchIsAdmin() is a thin delegation —
fetchWhoami().then(w => w.role === "admin"): SAME single request,
all phase-16/19 callers keep working, and a token user (role
"user") reads FALSE here (the admin-only surfaces key off
role === "admin", never off `authenticated`)."""
js = _text(HEADER_JS)
fn = js.find("export function fetchIsAdmin")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "fetchWhoami().then((w) => w.role === \"admin\")" in body
def test_reset_whoami_clears_the_module_cache() -> None:
"""Phase 79 (task 05): the token gate changes the session MID-PAGE
(silent re-auth / interactive login) — resetWhoami() drops the
cached promise so the NEXT fetchWhoami() is a fresh post-auth
request (a boot-fired pre-auth whoami would still say anonymous)."""
js = _text(HEADER_JS)
fn = js.find("export function resetWhoami")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "whoamiPromise = null" in body
def test_init_shared_header_toggles_only_elements_that_exist() -> None:
@@ -77,7 +117,11 @@ def test_init_shared_header_toggles_only_elements_that_exist() -> None:
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "await fetchIsAdmin()" in body
# Phase 79 (task 05): the header boots on the FULL whoami — the
# auth pair keys off the authenticated role (admin OR token user),
# the admin-only surfaces off role === "admin".
assert "await fetchWhoami()" in body
assert 'whoami.role === "admin"' in body
for selector in ("#nav-sources", "#nav-git-sources", "#nav-tuning"):
assert f'querySelector("{selector}")' in body
# Sign in: both the bar copy AND the mobile dropdown copy (phase 46)
@@ -410,7 +454,10 @@ def test_app_js_delegates_the_shared_controls_to_header_module() -> None:
(`./header.js`) so esbuild can bundle it into the image."""
js = _text(APP_JS)
assert 'from "./header.js"' in js
assert "fetchIsAdmin" in js and "initSharedHeader" in js
# Phase 79 (task 05): app.js reads the FULL whoami (the same cached
# promise) — the auth pair off `authenticated`, the admin-only
# surfaces off role === "admin".
assert "fetchWhoami" in js and "initSharedHeader" in js
assert "signOutBtn.addEventListener" not in js, (
"the sign-out binding moved to header.js"
)
@@ -419,7 +466,7 @@ def test_app_js_delegates_the_shared_controls_to_header_module() -> None:
)
assert "loadAuthState" not in js, "loadAuthState was deleted in phase 19"
assert "function applyAuthState" in js, "chat-page tuning gate stays"
assert "isAdmin = await fetchIsAdmin();" in js
assert 'who.role === "admin"' in js and "who.authenticated" in js
init_idx = js.find("await initSharedHeader();")
restore_idx = js.find("restoreConversation();")
assert -1 < init_idx < restore_idx, (
+369
View File
@@ -0,0 +1,369 @@
"""Unit: the in-app token gate (phase 79, task 05).
Source-level house pattern (read the JS sources, no browser): pins the
gate module's wiring — the ``bor.token`` localStorage key, the
SILENT-RE-AUTH-BEFORE-WHOAMI order, the failed-re-auth key drop, the
cache-invalidation choice — the header's full-whoami plumbing
(``fetchWhoami`` exported, ``fetchIsAdmin`` delegating, the SINGLE
``fetch("/api/whoami")`` call site, the sign-out binding dropping the
cached token), and the shell/viewer HTML wiring (the gate ships hidden
+ inert, the form/input/error ids, the admin link, the boot order
token-gate.js AFTER router.js). The browser flows (gate → unlock →
cached reload → revoked drop → sign-out) are E2E-pinned by
``tests/e2e/test_api_tokens.py`` (phase 79, task 07).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
APP_JS = ASSETS / "app.js"
DOCUMENT_JS = ASSETS / "document.js"
TOKEN_GATE_JS = ASSETS / "token-gate.js"
INDEX_HTML = FRONTEND / "index.html"
DOCUMENT_HTML = FRONTEND / "document.html"
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
# ---------- token-gate.js: the module itself ----------
def test_token_gate_module_exists_and_exports_mount_gate() -> None:
"""token-gate.js is an ES module exposing mountGate(lockRoot,
onAuthed) — the reusable mount point (the shell passes #main, the
viewer passes its content wrapper)."""
js = _text(TOKEN_GATE_JS)
assert "export async function mountGate" in js
# Relative import for the single-evaluation design (esbuild inlines
# it into the page bundles; the Containerfile parity pin covers the
# image build).
assert 'from "./header.js"' in js
assert '"/assets/header.js"' not in js
def test_token_gate_uses_the_bor_token_localstorage_key() -> None:
"""The owner's sentence: "cache that token in browser storage". The
cached key is the LITERAL bor.token — read at mount (silent
re-auth), written on a successful login, dropped on a failed
re-auth and on sign out (the header binding). Every localStorage
access is try/catch (the fail-silence storage contract)."""
js = _text(TOKEN_GATE_JS)
assert '"bor.token"' in js, "the bor.token localStorage key literal"
# The three accesses (read at mount, write on login, drop on a
# failed re-auth) all go through the key constant — try/catch each
# (the fail-silence storage contract: private mode degrades to
# "re-enter the token each visit", never to a broken gate).
assert "localStorage.getItem(TOKEN_KEY)" in js
assert "localStorage.setItem(TOKEN_KEY" in js
assert "localStorage.removeItem(TOKEN_KEY)" in js
assert js.count("try {") >= 3
assert js.count("catch") >= 3
def test_silent_reauth_happens_before_the_whoami_check() -> None:
"""Source order: the cached token is re-sent to POST /api/token-auth
BEFORE the whoami role check — the re-auth (re)sets the session
cookie before any whoami settles, so the role check sees the
post-auth role (no stale anonymous for a returning token user)."""
js = _text(TOKEN_GATE_JS)
reauth = js.find("(1) SILENT RE-AUTH — before the whoami check")
role_check = js.find("(2) ROLE CHECK — header.js's fetchWhoami()")
assert -1 < reauth < role_check, "the silent re-auth must precede the whoami check"
assert 'fetch("/api/token-auth"' in js
# The re-auth block reads the cached token and posts it, all before
# the role check's fetchWhoami.
cached_read = js.find("readCachedToken()")
assert -1 < cached_read < role_check
# The role check goes through header.js's SHARED cached promise
# (one /api/whoami per page load in dev).
assert "await fetchWhoami()" in js
def test_failed_silent_reauth_drops_the_cached_key() -> None:
"""A failed silent re-auth (revoked / unknown / network) removes
the key — the token may have been revoked — before the mount falls
through to the role check. The remove call sits in the (1) block,
so a dead cached token can never linger in localStorage."""
js = _text(TOKEN_GATE_JS)
reauth = js.find("(1) SILENT RE-AUTH — before the whoami check")
role_check = js.find("(2) ROLE CHECK — header.js's fetchWhoami()")
assert -1 < reauth < role_check
block = js[reauth:role_check]
assert "removeToken()" in block, "the failure path must drop the key"
# removeToken itself hits the real localStorage.removeItem (inside
# its own try/catch).
fn = js.find("const removeToken")
body = js[fn : js.find("\n};", fn)]
assert "localStorage.removeItem(TOKEN_KEY)" in body
def test_gate_ships_hidden_and_revealed_as_an_inert_pair() -> None:
"""The gate ships hidden + inert (the phase-16 ship-hidden pattern
— an authenticated boot never shows it for a frame) and the JS
always toggles hidden AND inert together (the WCAG inert-pair
contract): revealing drops BOTH, hiding re-adds BOTH."""
js = _text(TOKEN_GATE_JS)
fn = js.find("export async function mountGate")
assert fn != -1
body = js[fn:]
# Reveal: drop hidden AND inert.
assert "gate.hidden = false" in body
assert "gate.inert = false" in body
# Hide: re-add hidden AND inert.
assert "gate.hidden = true" in body
assert "gate.inert = true" in body
# The lock root is locked (inert) when the gate shows and unlocked
# when auth settles — the locked app never receives focus.
assert "lockRoot.inert = true" in body
assert "lockRoot.inert = false" in body
def test_gate_submit_caches_then_invalidates_the_whoami_cache() -> None:
"""Form submit: 204 → cache the token, THEN invalidate the module
whoami cache (resetWhoami) and re-fetch through fetchWhoami — the
documented choice (a direct re-fetch would leave header.js's
boot-fired anonymous cache stale for the header re-boot). 401 →
the role=alert error line, the input cleared + re-focused."""
js = _text(TOKEN_GATE_JS)
fn = js.find("form.addEventListener(\"submit\"")
assert fn != -1
block = js[fn:]
store = block.find("storeToken(token)")
reset = block.find("resetWhoami()")
refetch = block.find("await fetchWhoami()")
assert -1 < store < reset < refetch, (
"cache → invalidate → re-fetch: the order the contract pins"
)
# The error path: 401 → showError() — the role=alert line revealed,
# the input cleared + re-focused (defined once at mount, called from
# the failure branch).
assert "showError()" in block
fn_show = js.find("const showError")
show = js[fn_show : js.find("\n };", fn_show)]
assert "error.hidden = false" in show
assert "input.value = \"\"" in show
assert "input.focus()" in show
def test_gate_finds_its_markup_by_class() -> None:
"""The gate markup differs only in ids across the two pages
(#auth-gate / #doc-auth-gate) — the module finds it by the shared
.auth-gate CLASS (the ONE section on the page), and the token
input by name (the form field, not the id)."""
js = _text(TOKEN_GATE_JS)
assert 'querySelector(".auth-gate")' in js
assert 'input[name="token"]' in js
# ---------- header.js: the full-whoami plumbing ----------
def test_header_fetch_whoami_is_the_single_call_site() -> None:
"""The string fetch("/api/whoami") appears in header.js EXACTLY
ONCE (the single-request contract — the file's comments also
mention whoami, so the pin is on the fetch call, not the word);
fetchWhoami is exported and fetchIsAdmin delegates to it (a token
user reads false from fetchIsAdmin — the admin surfaces key off
role === "admin")."""
js = _text(HEADER_JS)
assert 'fetch("/api/whoami")' in js
assert js.count('fetch("/api/whoami")') == 1, (
"no second whoami call site may enter header.js"
)
assert "export function fetchWhoami" in js
fn = js.find("export function fetchIsAdmin")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "fetchWhoami()" in body, "fetchIsAdmin must delegate to fetchWhoami"
assert 'w.role === "admin"' in body
def test_sign_out_binding_drops_the_cached_token_before_reload() -> None:
"""The sign-out binding (header.js, module-owned) removes
localStorage["bor.token"] — try/catch, the fail-silence storage
contract — AFTER the logout POST and BEFORE the reload: one
logout clears the server session AND the cached token, so a
signing-out token user meets the gate again on the next load."""
js = _text(HEADER_JS)
logout = js.find('fetch("/api/logout", { method: "POST" })')
drop = js.find('localStorage.removeItem("bor.token")')
reload = js.find("window.location.reload()")
assert -1 < logout < drop < reload, (
"sign out: logout → drop bor.token → reload (the order the contract pins)"
)
def test_init_shared_header_keys_the_pair_off_authenticated() -> None:
"""Phase 79: initSharedHeader's auth PAIR (Sign in / Sign out) keys
off the authenticated role — a token user (role "user") gets
sign-in hidden + sign-out visible; the admin-ONLY surfaces (nav
links, steering refresh) still key off role === "admin" (a user
gets the anonymous branch: links hidden, the panel REMOVED,
/api/steering never fetched). Admin/anonymous stays
byte-identical to phase 16/19."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert 'link.hidden = signedIn' in body
assert "btn.hidden = !signedIn" in body
assert "navSources.hidden = !admin" in body
assert "steeringPanel?.remove();" in body
# ---------- index.html: the shell gate + boot order ----------
def _script_srcs(html: str) -> list[str]:
return re.findall(r'<script[^>]*src="([^"]+)"', html)
def test_shell_loads_token_gate_after_router() -> None:
"""index.html loads token-gate.js as a module AFTER app.js and
router.js (boot order: brand.js classic → app.js module →
router.js module → token-gate.js module) — the Containerfile
bundles it (the parity pin in
tests/integration/test_containerfile_assets.py covers the image).
No page loads it BEFORE app.js (the gate's boot call is awaited
by app.js's boot IIFE)."""
html = _text(INDEX_HTML)
srcs = _script_srcs(html)
tag = [s for s in srcs if "token-gate.js" in s]
assert tag, "the shell must load the token-gate module"
order = [
srcs.index("assets/brand.js"),
srcs.index("/assets/app.js"),
srcs.index("/assets/router.js"),
srcs.index(tag[0]),
]
assert order == sorted(order), (
f"boot order brand.js → app.js → router.js → token-gate.js broken: {srcs}"
)
m = re.search(r'<script[^>]*src="[^"]*token-gate\.js"[^>]*>', html)
assert m and 'type="module"' in m.group(0), "token-gate.js is an ES module"
def test_shell_gate_markup_ships_hidden_inert() -> None:
"""The shell's gate (body-level, AFTER #main) ships hidden + inert
with the full contract: the #auth-gate section labelled by its h2
("Enter your access token"), the sub line, the labelled form with
the mono token input (autocomplete off — a token must never be
offered by the password manager) and the Sign in submit, the
role=alert error line (hidden, the owner-locked copy), and the
"Sign in as admin" link (the header's ?next= convention, the
no-JS fallback)."""
html = _text(INDEX_HTML)
# The section: body-level, after #main (before the footer).
tag = re.search(r'<section[^>]*id="auth-gate"[^>]*>', html)
assert tag, "the shell must carry the #auth-gate section"
assert "hidden" in tag.group(0) and "inert" in tag.group(0), (
"the gate ships hidden + inert (the ship-hidden pattern)"
)
assert 'class="auth-gate"' in tag.group(0)
assert 'aria-labelledby="auth-gate-title"' in tag.group(0)
main_end = html.find("</main>")
assert main_end < html.find('id="auth-gate"'), "the gate sits AFTER #main"
# The content (the #sources-gate visual language).
assert '<h2 id="auth-gate-title">Enter your access token</h2>' in html
assert "Shared chats stay open" in html
form = re.search(r'<form[^>]*id="auth-gate-form"[^>]*>', html)
assert form, "the gate form"
assert '<label class="visually-hidden" for="auth-gate-input">Access token</label>' in html
inp = re.search(r'<input[^>]*id="auth-gate-input"[^>]*>', html)
assert inp, "the token input"
for attr in (
'name="token"',
'type="text"',
'autocomplete="off"',
'autocapitalize="none"',
'spellcheck="false"',
"required",
):
assert attr in inp.group(0), f"the token input must carry {attr}"
assert 'type="submit"' in html and "Sign in" in html
err = re.search(r'<p[^>]*class="auth-gate-error"[^>]*id="auth-gate-error"[^>]*>', html)
assert err, "the error line"
assert 'role="alert"' in err.group(0) and "hidden" in err.group(0)
assert "That token isn" in html, "the owner-locked error copy"
assert 'href="/login.html?next=/"' in html, "the admin link (?next= convention)"
def test_shell_boots_the_gate_from_app_js() -> None:
"""app.js awaits mountGate(#main, no-op) at boot — BEFORE its
initSharedHeader — so a silent re-auth lands before the first
whoami fires (the header sees the post-auth role deterministically).
In the shell, onAuthed needs no view work: the lazy views mount on
first show exactly as today (mount-once, hide-forever untouched)."""
js = _text(APP_JS)
assert 'from "./token-gate.js"' in js
gate_i = js.find('mountGate(document.getElementById("main"), () => {})')
assert gate_i != -1, "the shell's boot call (no-op onAuthed)"
init_i = js.find("await initSharedHeader();", gate_i)
assert -1 < gate_i < init_i, "the gate settles BEFORE the header boots"
# ---------- document.html / document.js: the viewer gate ----------
def test_viewer_gate_markup_reuses_the_shell_copy_renamed() -> None:
"""document.html carries the SAME gate markup as the shell, the ids
renamed (#doc-auth-gate / #doc-auth-gate-form / #doc-auth-gate-input
/ #doc-auth-gate-error / #doc-auth-gate-title) — hidden + inert,
the labelled form + input + role=alert error + admin link."""
html = _text(DOCUMENT_HTML)
tag = re.search(r'<section[^>]*id="doc-auth-gate"[^>]*>', html)
assert tag, "the viewer must carry the #doc-auth-gate section"
assert "hidden" in tag.group(0) and "inert" in tag.group(0)
assert 'class="auth-gate"' in tag.group(0)
assert 'aria-labelledby="doc-auth-gate-title"' in tag.group(0)
assert '<h2 id="doc-auth-gate-title">Enter your access token</h2>' in html
assert re.search(r'<form[^>]*id="doc-auth-gate-form"[^>]*>', html)
assert re.search(
r'<input[^>]*id="doc-auth-gate-input"[^>]*name="token"[^>]*>', html
) or re.search(
r'<input[^>]*name="token"[^>]*id="doc-auth-gate-input"[^>]*>', html
), "the viewer's token input"
assert re.search(r'id="doc-auth-gate-error"[^>]*role="alert"', html) or re.search(
r'role="alert"[^>]*id="doc-auth-gate-error"', html
)
def test_viewer_wires_the_gate_around_the_existing_boot() -> None:
"""document.js wires mountGate(#main, onAuthed) — onAuthed runs the
content load for a SIGNED-IN role only (anonymous never fetches the
gated content; the inline gate is the surface) — and the shared
header boots in the .then AFTER the gate settles, for EVERY role
(the gate locks #main, not the header — the anonymous contract is
byte-identical to the shell). Awaiting the gate first is what makes
the header race-free: the settled whoami is the single request both
the gate and the header reuse (no second whoami, no stale bar)."""
js = _text(DOCUMENT_JS)
assert 'from "./token-gate.js"' in js
gate_i = js.find('mountGate(document.getElementById("main")')
assert gate_i != -1
boot = js[gate_i : gate_i + 400]
assert "load()" in boot, "onAuthed runs the content load (signed-in role only)"
# The header boots AFTER the gate settles (the .then) — not before
# it, not inside onAuthed: one settled whoami for gate + header.
load_i = boot.find("load()")
then_i = boot.find(".then(")
assert -1 < load_i < then_i, ("onAuthed (load) comes before the header .then")
assert "initSharedHeader()" in boot[then_i:], (
"the header must boot on the settled whoami, after the gate"
)
# The bare boot call is gone — the ONLY load(); statement in the
# file is the one inside the gate's onAuthed callback.
assert js.count("load();") == 1, (
"the un-gated load() call must be gone (onAuthed is the only caller)"
)
# The whoami single-request contract survives: no direct whoami
# fetch in the viewer script (header.js's cached promise).
assert 'fetch("/api/whoami")' not in js
+174
View File
@@ -0,0 +1,174 @@
"""Unit: the API-token service (phase 79, task 02).
Covers ``app.core.tokens`` — the create/lookup/revoke service behind the
admin API (task 02's endpoints) and the future token-auth login (task
03):
* ``generate_token`` — the ``bor_`` + 32-hex shape, two calls differ;
* ``hash_token`` — deterministic 64-hex digest of the FULL string (a
stripped prefix can never collide);
* ``create_token`` / ``find_active_by_token`` — the round-trip (the
plaintext resolves to its active row) and the GENERIC-MISS contract:
revoked, unknown, empty, short and wrong-prefix candidates all return
the same ``None`` (hash matches no row — there is no "almost" path,
which is what makes the task-03 one-generic-401 safe);
* ``revoke`` — stamps ``revoked_at`` once (idempotent re-call keeps the
original stamp; False only for a missing id);
* ``mark_used`` — bumps ``last_used_at``.
House DB-test pattern (the ``test_sources_meta`` precedent): the service
is a thin session wrapper whose contract (server-default ``created_at``,
the unique hash index) only holds against a real database — runs against
the local compose Postgres, skips with clear instructions when the stack
is not up. The service flushes, never commits: the tests commit, as the
endpoints do.
"""
from __future__ import annotations
import re
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core import tokens as tok
from app.models import ApiToken
TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$")
@pytest.fixture(autouse=True)
def clean_tokens(db: Session) -> Iterator[None]:
"""api_tokens is global state: reset around every test."""
db.execute(text("TRUNCATE api_tokens"))
db.commit()
yield
db.execute(text("TRUNCATE api_tokens"))
db.commit()
def _create_and_commit(db: Session, label: str = "alice") -> tuple[ApiToken, str]:
"""Service create + the endpoint's commit/refresh (the house split)."""
row, plaintext = tok.create_token(db, label)
db.commit()
db.refresh(row)
return row, plaintext
def test_generate_token_shape_and_uniqueness() -> None:
"""``bor_`` + exactly 32 lowercase hex chars (128-bit), no reuse."""
a = tok.generate_token()
b = tok.generate_token()
assert TOKEN_SHAPE.fullmatch(a), a
assert TOKEN_SHAPE.fullmatch(b), b
assert a != b
def test_hash_token_deterministic_64_hex_full_string() -> None:
"""Same token → same digest; 64 hex chars; the FULL string is hashed
(hashing ``bor_X`` ≠ hashing ``X`` — a stripped prefix can never
collide with another token's hash)."""
plain = tok.generate_token()
h1 = tok.hash_token(plain)
h2 = tok.hash_token(plain)
assert h1 == h2
assert re.fullmatch(r"[0-9a-f]{64}", h1), h1
assert tok.hash_token(plain.removeprefix("bor_")) != h1
def test_create_and_find_round_trip(db: Session) -> None:
"""create → the row carries ONLY the hash; the plaintext resolves
back to the same active row (the task-03 login path)."""
row, plaintext = _create_and_commit(db, " alice ")
# The service strips the label; the row only ever carries the hash.
assert row.label == "alice"
assert row.token_hash == tok.hash_token(plaintext)
assert row.token_hash != plaintext
assert row.last_used_at is None
assert row.revoked_at is None
hit = tok.find_active_by_token(db, plaintext)
assert hit is not None
assert hit.id == row.id
assert hit.label == "alice"
@pytest.mark.parametrize(
("candidate", "why"),
[
("", "empty string"),
("bor_ab", "short string"),
("bor_" + "f" * 31, "31 hex chars (one short)"),
(
"zzz_" + "0" * 32,
"wrong prefix",
),
],
ids=["empty", "short", "31-hex", "wrong-prefix"],
)
def test_find_active_by_token_generic_miss(db: Session, candidate: str, why: str) -> None:
"""Every malformed/unknown candidate is the SAME miss — the hash
simply matches no row (no "almost" path, no per-shape error)."""
_, plaintext = _create_and_commit(db) # the table is NOT empty
assert tok.find_active_by_token(db, candidate) is None, why
# …while the stored token still resolves (the miss is specific).
assert tok.find_active_by_token(db, plaintext) is not None
def test_find_active_by_token_unknown_well_formed(db: Session) -> None:
"""A well-formed token that was never stored (or belongs to another
admin) misses — no enumeration surface beyond the unique-index hit."""
unknown = tok.generate_token() # never persisted
assert tok.find_active_by_token(db, unknown) is None
def test_find_active_by_token_revoked_misses(db: Session) -> None:
"""A revoked row never resolves: the hash still matches the row, but
``revoked_at IS NULL`` is part of the contract (A4 — dead is dead,
enforced immediately)."""
row, plaintext = _create_and_commit(db)
assert tok.revoke(db, row.id) is True
db.commit()
assert tok.find_active_by_token(db, plaintext) is None
def test_revoke_stamps_once_and_false_only_for_missing(db: Session) -> None:
"""First revoke stamps ``revoked_at`` (True); the second call is
idempotent (True, the ORIGINAL stamp kept — no re-stamp); False only
when the row does not exist."""
row, _ = _create_and_commit(db)
assert tok.revoke(db, row.id) is True
db.commit()
db.refresh(row)
first_stamp = row.revoked_at
assert first_stamp is not None
# Idempotent: True again, and the first stamp survives.
assert tok.revoke(db, row.id) is True
db.commit()
db.refresh(row)
assert row.revoked_at == first_stamp
# A missing id is the only False.
assert tok.revoke(db, uuid.uuid4()) is False
def test_mark_used_stamps_last_used_at(db: Session) -> None:
"""NULL until first use, then "now" (UTC, tz-aware)."""
row, _ = _create_and_commit(db)
assert row.last_used_at is None
before = datetime.now(UTC)
tok.mark_used(row)
after = datetime.now(UTC)
assert row.last_used_at is not None
assert row.last_used_at.tzinfo is not None
assert before - timedelta(seconds=1) <= row.last_used_at <= after + timedelta(
seconds=1
)
db.commit()