Files
brain-of-reese/tests/e2e/test_api_tokens.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
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
2026-09-07 12:39:01 -04:00

677 lines
29 KiB
Python

"""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"}