feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
@@ -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}/")
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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", (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())"
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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"]]
|
||||
@@ -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
@@ -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 ----------
|
||||
|
||||
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user