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

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

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

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

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

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

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

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
This commit is contained in:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+94
View File
@@ -0,0 +1,94 @@
"""Unit tests: the ApiToken model registers the api_tokens contract
(phase 79, task 01).
Schema-level assertions without a live DB (the
``tests/unit/test_models.py`` doc_drafts precedent): the table name, the
column set + nullability, the UNIQUE ``token_hash`` (two tokens with the
same hash must collide — the unique constraint backing
``ix_api_tokens_token_hash``), and a display-only, non-unique
``label``. The real duplicate-rejection behaviour is pinned against the
dev DB in ``tests/integration/test_migration_0012.py``.
"""
from __future__ import annotations
from sqlalchemy import PrimaryKeyConstraint, String, UniqueConstraint
from sqlalchemy.schema import Table
import app.models # noqa: F401 (import registers all tables on Base.metadata)
from app.db import Base
def _table() -> Table:
return Base.metadata.tables["api_tokens"]
def test_api_tokens_table_registered() -> None:
assert "api_tokens" in Base.metadata.tables, "ApiToken must register api_tokens"
assert _table().name == "api_tokens"
def test_api_tokens_column_contract() -> None:
"""The column set + nullability: ``id`` UUID PK; ``label`` /
``token_hash`` NOT NULL; ``created_at`` NOT NULL with a server
default (now()); ``last_used_at`` / ``revoked_at`` NULL until the
service (tasks 02/03) sets them."""
tok = _table()
assert set(tok.c.keys()) == {
"id", "label", "token_hash", "created_at", "last_used_at", "revoked_at",
}
assert tok.c["id"].primary_key is True, "api_tokens.id must be the PK"
assert tok.c["id"].nullable is False, "api_tokens.id must be NOT NULL"
assert tok.c["label"].nullable is False, "label must be NOT NULL"
label_type = tok.c["label"].type
assert isinstance(label_type, String), "label must be String(120)"
assert label_type.length == 120, "label must be String(120)"
assert tok.c["token_hash"].nullable is False, "token_hash must be NOT NULL"
hash_type = tok.c["token_hash"].type
assert isinstance(hash_type, String), "token_hash must be String(64)"
assert hash_type.length == 64, (
"token_hash must be String(64) — a sha256 hex digest"
)
assert tok.c["created_at"].nullable is False, "created_at must be NOT NULL"
assert tok.c["created_at"].server_default is not None, (
"created_at needs a server default (now())"
)
for name in ("last_used_at", "revoked_at"):
assert tok.c[name].nullable is True, (
f"{name} must be NULL until first use / revocation"
)
def test_api_tokens_token_hash_is_unique() -> None:
"""Two tokens with the same hash must collide: a UNIQUE constraint
covers exactly ``token_hash`` (the backing constraint of the
``ix_api_tokens_token_hash`` unique index — the stored credential
is the lookup key)."""
tok = _table()
uq = [
c
for c in tok.constraints
if isinstance(c, UniqueConstraint)
and not isinstance(c, PrimaryKeyConstraint)
and {col.name for col in c.columns} == {"token_hash"}
]
assert uq, "api_tokens must be unique on (token_hash) — the stored credential"
def test_api_tokens_label_is_not_unique() -> None:
"""``label`` is the hand-out name — display-only: the column itself
is not unique and no unique constraint may cover it (two tokens can
share a label, e.g. two "alice" tokens issued at different times)."""
tok = _table()
assert not tok.c["label"].unique, "label must not be unique"
covering = [
c
for c in tok.constraints
if isinstance(c, UniqueConstraint)
and any(col.name == "label" for col in c.columns)
]
assert not covering, "no unique constraint may cover api_tokens.label"
+136 -3
View File
@@ -1,11 +1,17 @@
"""Unit tests: single-admin auth (phase 16; A10 revised).
"""Unit tests: auth (phase 16 single-admin; phase 79 token users).
Covers the config gate (fail-loud, including via ``create_app``), the
constant-time password check, the ``require_admin`` dependency, the
whoami payload shape, and the sign_in/sign_out session semantics.
phase-79 ``require_user`` matrix (admin pass, live token pass, revoked /
missing-row 401 + session keys popped, anonymous 401, admin+user
coexistence), the three-role whoami payload shape, and the
sign_in/sign_out session semantics.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import pytest
from fastapi import HTTPException
from starlette.middleware.sessions import Session
@@ -18,9 +24,11 @@ from app.core.auth import (
check_password,
ensure_admin_configured,
require_admin,
require_user,
sign_in,
sign_out,
)
from app.models import ApiToken
from app.schemas import WhoamiResponse
@@ -122,7 +130,117 @@ def test_require_admin_403s_anonymous(session: dict) -> None:
assert exc.value.detail == "admin only"
# ---------- whoami payload shape ----------
# ---------- require_user (phase 79: admin OR live token, else 401) ----------
class _FakeTokenResult:
"""``execute()`` result for the fake db: ``.scalars().first()``
yields the one fixed row (or ``None``)."""
def __init__(self, row: ApiToken | None) -> None:
self._row = row
def scalars(self) -> _FakeTokenResult:
return self
def first(self) -> ApiToken | None:
return self._row
class _FakeTokenDb:
"""Session stand-in for ``require_user``'s PK lookup: counts the
queries it is given and always returns the one fixed row — enough to
pin the matrix without a database (the admin path must NOT query at
all)."""
def __init__(self, row: ApiToken | None) -> None:
self._row = row
self.queries = 0
def execute(self, _stmt: object) -> _FakeTokenResult:
self.queries += 1
return _FakeTokenResult(self._row)
def _token_row(**kwargs: object) -> ApiToken:
base: dict[str, object] = {
"id": uuid.uuid4(),
"label": "alice",
"token_hash": "0" * 64,
"created_at": datetime.now(UTC),
}
base.update(kwargs)
return ApiToken(**base) # pyright: ignore[reportCallIssue]
def test_require_user_admin_session_passes_without_any_db_lookup() -> None:
"""An admin ALWAYS passes — token state irrelevant, no row fetched
(the admin+user coexistence case: admin wins outright)."""
db = _FakeTokenDb(None) # would be a dead row if it were ever consulted
require_user(
_request_with_session(
admin=True, user=True, user_token_id=str(uuid.uuid4())
),
db, # pyright: ignore[reportArgumentType]
) # no exception
assert db.queries == 0
def test_require_user_active_token_session_passes() -> None:
row = _token_row()
db = _FakeTokenDb(row)
require_user(
_request_with_session(user=True, user_token_id=str(row.id)),
db, # pyright: ignore[reportArgumentType]
) # no exception
assert db.queries == 1 # the live PK lookup ran
@pytest.mark.parametrize(
("row", "token_id"),
[
(None, str(uuid.uuid4())), # the row is gone (deleted out-of-band)
(_token_row(revoked_at=datetime.now(UTC)), str(uuid.uuid4())), # revoked
(None, "not-a-uuid"), # corrupt session — no valid row id at all
],
ids=["missing-row", "revoked", "corrupt-token-id"],
)
def test_require_user_dead_token_session_401s_and_pops_both_keys(
row: ApiToken | None, token_id: str
) -> None:
"""Row missing / revoked / unresolvable → 401 ``authentication
required`` AND the dead session is dropped NOW (both user keys
popped, so the next whoami is anonymous)."""
request = _request_with_session(user=True, user_token_id=token_id)
with pytest.raises(HTTPException) as exc:
require_user(request, _FakeTokenDb(row)) # pyright: ignore[reportArgumentType]
assert exc.value.status_code == 401
assert exc.value.detail == "authentication required"
assert "user" not in request.session
assert "user_token_id" not in request.session
def test_require_user_anonymous_401s() -> None:
with pytest.raises(HTTPException) as exc:
require_user(
_request_with_session(), _FakeTokenDb(None) # pyright: ignore[reportArgumentType]
)
assert exc.value.status_code == 401
assert exc.value.detail == "authentication required"
def test_require_user_user_key_without_token_id_401s_and_pops() -> None:
"""A ``user`` key with no ``user_token_id`` at all is a dead session
too — same 401, both keys dropped."""
request = _request_with_session(user=True)
with pytest.raises(HTTPException) as exc:
require_user(request, _FakeTokenDb(None)) # pyright: ignore[reportArgumentType]
assert exc.value.status_code == 401
assert exc.value.detail == "authentication required"
assert "user" not in request.session
# ---------- whoami payload shape (three roles, phase 79) ----------
def test_whoami_anonymous_payload() -> None:
@@ -136,6 +254,21 @@ def test_whoami_admin_payload() -> None:
assert body == WhoamiResponse(authenticated=True, role="admin")
def test_whoami_token_user_payload() -> None:
body = whoami(_request_with_session(user=True, user_token_id=str(uuid.uuid4())))
assert body == WhoamiResponse(authenticated=True, role="user")
def test_whoami_admin_wins_when_both_roles_are_set() -> None:
"""Coexistence is deliberate: a browser holding BOTH an admin and a
token session reports admin (the UI keys off role, the admin surface
stays open)."""
body = whoami(
_request_with_session(admin=True, user=True, user_token_id=str(uuid.uuid4()))
)
assert body == WhoamiResponse(authenticated=True, role="admin")
# ---------- sign_in / sign_out session semantics ----------
-271
View File
@@ -1,271 +0,0 @@
"""Unit: the phase-25 still-background contract — layer plumbing and the
phase-08 anchors (source pins).
Phase 22 (owner report 2026-08-24) made the phase-08 background
perceptible: 60% grid-line alpha, a widened mask, a 60s one-cell grid
drift, and a 14s whole-layer glow breathe. The owner then reported
(2026-08-25, chat): the background "jitters down and to the right every
second and it slowly blinks brighter and darker. It should be smooth,
fluxuating, dimming and brightening, but not moving. Different bright
spots should slowly fade in and out." — the phase-22 design intent
(grid drift + whole-layer breathe) is superseded.
The new design (styles.css, pure CSS, zero JS, no `filter` — A11):
- grid (body::before): a STATIC texture — the drift animation and its
keyframes are deleted (the 0.73px/s sub-pixel drift rasterizes as a
once-per-second down-right jitter);
- three independent soft glow spots — body::after (26s), html::before
(34s, -12s delay), html::after (42s, -23s delay) — each on its own
SLOW opacity-only fade (the whole-layer breathe keyframes are
deleted), so the total light fluxuates smoothly and irregularly;
LCM(26, 34, 42) = 4641s, so the composite pattern never repeats
within a viewing session. The 2026-08-28 rebrand recolored the
phase-08 indigo/cyan spots to the warm dark-red theme palette
(rose / orange / red) and the grid lines to the warm line tone —
structure (sizes, positions, alphas, periods) unchanged.
This file keeps the generic layer-plumbing pins (the no-occlusion
contract, fixed / z-index -1 / pointer-events none — now across all
four layers) and the phase-08 no-blur/no-JS anchor. The full new
contract (no animation on the grid, opacity-only keyframes, the three
spot gradients, reduced motion across all four layers) is pinned in
tests/unit/test_background_no_motion.py; browser behavior is E2E-covered
by tests/e2e/test_background_no_motion.py (task 02).
Story: .agents/user_stories/background-no-motion.md (supersedes
.agents/user_stories/background-animation.md).
"""
from __future__ import annotations
import re
from pathlib import Path
STYLES_CSS = (
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
)
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _css_no_comments() -> str:
"""styles.css with /* … */ comments stripped — for functional anchor
checks (filter/blur) that must not trip on explanatory comments."""
return re.sub(r"/\*[\s\S]*?\*/", "", _css())
def _rule_block(css: str, selector: str) -> str:
"""Body of the first `selector { ... }` rule (top-level, no nesting)."""
rule = re.search(
r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css
)
assert rule, f"styles.css must define a {selector} rule"
return rule.group(1)
def _grid_rule(css: str) -> str:
return _rule_block(css, "body::before")
def _glow_rule(css: str) -> str:
return _rule_block(css, "body::after")
def _bg_keyframes(css: str) -> dict[str, str]:
"""Name → body for every @keyframes bg-* rule (balanced braces —
works for the one-line blocks and a multi-line reformat alike)."""
out: dict[str, str] = {}
for m in re.finditer(r"@keyframes (bg-[A-Za-z0-9-]+)\s*\{", css):
start, depth, i = m.end(), 1, m.end()
while i < len(css) and depth:
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
i += 1
out[m.group(1)] = css[start:i - 1]
return out
# --------------------------------------------------------------------------
# Layer plumbing — the no-occlusion contract (phase 08) must survive
# --------------------------------------------------------------------------
def test_both_layers_are_fixed_zminus1_noninteractive() -> None:
"""All four background layers stay behind the content and can never
intercept input: fixed, full-viewport, z-index -1, pointer-events
none (phase 25: html::before / html::after join body::before /
body::after as background layers — UI Structure Check: layers behind
content, no 360px overflow, since they are fixed; inset: 0)."""
for name, block in (
("body::before", _grid_rule(_css())),
("body::after", _glow_rule(_css())),
("html::before", _rule_block(_css(), "html::before")),
("html::after", _rule_block(_css(), "html::after")),
):
assert "position: fixed" in block, f"{name} must stay position:fixed"
assert "inset: 0" in block, f"{name} must stay full-viewport (inset: 0)"
assert "z-index: -1" in block, f"{name} must stay z-index:-1"
assert "pointer-events: none" in block, f"{name} must stay click-through"
assert "content: \"\"" in block, f"{name} must keep its pseudo content"
def test_html_owns_bg_and_body_stays_transparent() -> None:
"""The no-occlusion contract: the visible page background lives on
<html>; <body> must remain transparent and non-stacking, or the
z-index:-1 layers (including the phase-25 html pseudo-layers, which
paint above the canvas and below body's content as the root stacking
context) are painted over (the phase-08 recipe)."""
html_block = _rule_block(_css(), "html")
assert "background: var(--bg)" in html_block, (
"html must keep background: var(--bg) (the page canvas)"
)
body_block = _rule_block(_css(), "body")
assert "background: transparent" in body_block, (
"body must keep background: transparent so the layers show"
)
# body must not gain a z-index/transform/opacity that would turn it
# into a stacking context trapping the negative-z-index layers.
for prop in ("z-index", "transform", "opacity", "filter"):
assert prop + ":" not in body_block, (
f"body must not create a stacking context (found {prop})"
)
# --------------------------------------------------------------------------
# Grid layer — phase 25: a static texture (the drift is gone)
# --------------------------------------------------------------------------
def test_grid_is_static_no_drift() -> None:
"""body::before must carry NO animation — the phase-22 60s one-cell
drift (0.73px/s down-right) rasterized as a once-per-second jitter;
the owner wants no movement (2026-08-25). Its keyframes are deleted
too."""
block = _grid_rule(_css())
assert "animation" not in block, (
"body::before must not animate (the no-movement contract)"
)
assert "bg-grid-drift" not in _css(), (
"@keyframes bg-grid-drift must be deleted"
)
def test_grid_cells_and_line_contrast() -> None:
"""44px cells with 1px lines at the phase-22 fixed 60% line alpha,
in the rebrand warm line tone (2026-08-28; the phase-22 indigo
value rgb(38 48 74 / 0.6) was recolored with the dark-red theme)
— the static texture keeps the values that made the grid readable
(see tests/unit/test_background_no_motion.py for the phase-25
story)."""
block = _grid_rule(_css())
assert "background-size: 44px 44px" in block
line = "linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)"
assert line in block, "grid must keep horizontal 1px lines at 60% line alpha"
assert (
"linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)"
in block
), "grid must keep vertical 1px lines at 60% line alpha"
assert "0.35" not in block, "the too-faint 35% line alpha must not return"
def test_grid_mask_widened_and_prefixed() -> None:
"""The phase-22 mask: 140%×110% ellipse, fully visible to 40% of the
radius, faded out by 90% — the grid must read across most of the
viewport (phase-08's 120%×90%/25%/78% masked it to the top ~25%).
The -webkit- and standard mask-image must stay in lockstep."""
block = _grid_rule(_css())
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
assert f"-webkit-mask-image: {mask};" in block
assert f"mask-image: {mask};" in block
# --------------------------------------------------------------------------
# Glow layers — phase 25: three spots, each on its own slow opacity fade
# --------------------------------------------------------------------------
def test_three_spots_run_own_slow_opacity_fades() -> None:
"""The whole-layer breathe is replaced by three independent
opacity-only fades on distinct slow periods with negative delays (out
of phase): body::after 26s, html::before 34s -12s, html::after 42s
-23s. The old breathe keyframes are deleted."""
assert "animation: bg-glow-a 26s ease-in-out infinite" in _glow_rule(_css())
assert (
"animation: bg-glow-b 34s ease-in-out -12s infinite"
in _rule_block(_css(), "html::before")
)
assert (
"animation: bg-glow-c 42s ease-in-out -23s infinite"
in _rule_block(_css(), "html::after")
)
assert "bg-glow-breathe" not in _css(), (
"@keyframes bg-glow-breathe must be deleted"
)
def test_glow_keyframes_are_opacity_only() -> None:
"""The no-movement contract: every bg-* keyframe block animates ONLY
opacity (no transform/scale, no background-position)."""
keyframes = _bg_keyframes(_css())
assert set(keyframes) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"}, (
"exactly three bg-glow-* keyframe blocks must exist"
)
for name, body in keyframes.items():
props = set(re.findall(r"([A-Za-z-]+)\s*:", body))
assert props == {"opacity"}, (
f"{name} must animate only opacity, found {sorted(props)}"
)
def test_glow_spots_use_the_rebrand_warm_palette() -> None:
"""The three spots keep their radii and positions from the phase-25
layout (rose top-left on body::after, orange bottom-right on
html::before, red bottom-left 52rem at 14% 86% on html::after) but
wear the 2026-08-28 rebrand palette (warm dark-red theme; the
phase-08 indigo/cyan values are gone). All spots fade to
transparent at 62%."""
assert (
"radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), "
"transparent 62%)" in _glow_rule(_css())
)
assert (
"radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), "
"transparent 62%)" in _rule_block(_css(), "html::before")
)
assert (
"radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), "
"transparent 62%)" in _rule_block(_css(), "html::after")
)
# --------------------------------------------------------------------------
# Phase-08 anchor: pure CSS, zero JS, no blur
# --------------------------------------------------------------------------
def test_no_blur_no_js_in_background_layers() -> None:
"""The phase-08 performance anchor: no `filter` (or any filter) on
any layer, and the motion is CSS-only — the three glow layers carry
an `animation:` shorthand (the grid is deliberately still in phase
25), and nothing in styles.css references a script."""
for name, block in (
("body::before", _grid_rule(_css())),
("body::after", _glow_rule(_css())),
("html::before", _rule_block(_css(), "html::before")),
("html::after", _rule_block(_css(), "html::after")),
):
assert "filter" not in block, f"{name} must not use any filter"
for name, block in (
("body::after", _glow_rule(_css())),
("html::before", _rule_block(_css(), "html::before")),
("html::after", _rule_block(_css(), "html::after")),
):
assert "animation:" in block, f"{name} must be CSS-animated"
assert "blur" not in _css_no_comments(), (
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
)
+128 -195
View File
@@ -1,99 +1,117 @@
"""Unit: the phase-25 still-background contract (source pins).
"""Unit: the phase-78 static-background contract (source pins).
Owner report (2026-08-25, chat): the animated background "jitters down
and to the right every second and it slowly blinks brighter and darker.
It should be smooth, fluxuating, dimming and brightening, but not
moving. Different bright spots should slowly fade in and out."
Owner direction (TODO.md L4, recorded per AGENTS.md rule 3): "Remove the
animated css background, it's too resource intensive" — the three
opacity-fading glow spots (``body::after`` / ``html::before`` /
``html::after``), their glow keyframes, and the
``prefers-reduced-motion`` rule whose only job was stilling those layers
are deleted from ``styles.css``. The 44px grid texture on
``body::before`` STAYS — it is static (zero animation cost).
The diagnosis (`.agents/reports/25_background_no_motion/`) found both
root causes in the phase-22 design:
- "jitters down and to the right" = the grid's 44px/60s drift
(0.73px/s, diagonally down-right) — a 1px grid line translated
sub-pixel by sub-pixel rasterizes with per-frame stepping;
- "slowly blinks" = the whole-layer 14s opacity 0.85↔1 +
scale(1)↔scale(1.05) pulse — one synchronized pulse reads as a blink.
Supersedes the phase-25 fading-glow contract (superseded chain
08 → 25 → 78); the phase-25 unit source-pin suite
(``tests/unit/test_background_animation.py``) is deleted with its
premise (three fading glows on named keyframe cycles).
The fix (styles.css, pure CSS, zero JS, no `filter` — A11): the grid is
a STATIC texture (no animation, no bg-grid-drift keyframes), and three
independent soft glow spots (body::after, html::before, html::after)
each run their own SLOW opacity-only fade (26/34/42s, ease-in-out,
negative delays → out of phase; LCM 4641s → the composite pattern
effectively never repeats within a viewing session), so the total light
fluxuates smoothly and irregularly — no blink, no jitter, no movement.
Story: .agents/user_stories/background-no-motion.md. Browser behavior
(no motion, visible fades, no occlusion, no overflow) is E2E-covered by
tests/e2e/test_background_no_motion.py (task 02).
Story: n/a (TODO-derived). Browser behavior (no background animation in
a real viewport, the grid still painted, no occlusion, no overflow) is
E2E-covered by ``tests/e2e/test_background_no_motion.py`` (task 02).
"""
from __future__ import annotations
import re
from pathlib import Path
from tests.unit.test_background_animation import _css, _css_no_comments, _rule_block
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
# (layer, keyframes name, duration shorthand, single-spot gradient)
# The 2026-08-28 rebrand recolored the phase-08 indigo/cyan spots to the
# warm dark-red theme palette (rose / orange / red) — structure
# (radius, position, alpha, period, delay) is the phase-25 design.
GLOW_SPOTS = (
("body::after", "bg-glow-a", "animation: bg-glow-a 26s ease-in-out infinite",
"radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), transparent 62%)"),
("html::before", "bg-glow-b", "animation: bg-glow-b 34s ease-in-out -12s infinite",
"radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), transparent 62%)"),
("html::after", "bg-glow-c", "animation: bg-glow-c 42s ease-in-out -23s infinite",
"radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), transparent 62%)"),
STYLES_CSS = (
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
)
GRID_LAYER = "body::before" # the static 44px grid — STAYS
# The phase-25 glow layers — all three deleted in phase 78 (they were
# the ONLY animated part of the background).
GLOW_LAYERS = ("body::after", "html::before", "html::after")
def _bg_keyframes(css: str) -> dict[str, str]:
"""Name → body for every @keyframes bg-* rule (balanced braces —
works for the one-line blocks and a multi-line reformat alike)."""
out: dict[str, str] = {}
for m in re.finditer(r"@keyframes (bg-[A-Za-z0-9-]+)\s*\{", css):
start, depth, i = m.end(), 1, m.end()
while i < len(css) and depth:
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
i += 1
out[m.group(1)] = css[start:i - 1]
return out
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _css_no_comments() -> str:
"""styles.css with /* … */ comments stripped — for functional rule
checks that must not trip on explanatory prose."""
return re.sub(r"/\*[\s\S]*?\*/", "", _css())
def _find_rule(css: str, selector: str) -> re.Match[str] | None:
"""The first top-level ``selector { ... }`` rule, or None (the
deleted glow layers must be ABSENT, so this may not assert)."""
return re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css)
def _rule_block(css: str, selector: str) -> str:
"""Body of the first ``selector { ... }`` rule (top-level, no
nesting)."""
rule = _find_rule(css, selector)
assert rule, f"styles.css must define a {selector} rule"
return rule.group(1)
# --------------------------------------------------------------------------
# No movement — the grid is a static texture, and no bg-* keyframe may
# animate anything but opacity
# The animated part is gone — no glow layers, no glow keyframes
# --------------------------------------------------------------------------
def test_grid_has_no_animation() -> None:
"""body::before must carry NO animation declaration — the phase-22
0.73px/s drift rasterized as a once-per-second down-right jitter; the
owner wants no movement (2026-08-25)."""
block = _rule_block(_css(), "body::before")
assert "animation" not in block, (
"body::before must not animate (the no-movement contract)"
def test_no_bg_glow_keyframes_remain() -> None:
"""The three glow @keyframes blocks are deleted — the names must not
appear anywhere in the file (no declaration, no keyframe block, no
stale comment). Pinned by the ``bg-`` prefix: no token carrying the
background keyframe namespace may survive (the prefix is a plain
literal, so this pin itself carries none of the deleted names)."""
assert "bg-" not in _css(), (
"no token in the background keyframe namespace (bg-*) may remain "
"in styles.css — the glow keyframes and their declarations are deleted"
)
assert re.search(r"@keyframes bg-", _css_no_comments()) is None, (
"no background @keyframes may remain in styles.css"
)
def test_drift_and_breathe_keyframes_are_deleted() -> None:
"""@keyframes bg-grid-drift and @keyframes bg-glow-breathe are gone —
the names must not appear anywhere in the file (no declaration, no
keyframe block, no stale comment)."""
css = _css()
assert "bg-grid-drift" not in css, "bg-grid-drift must be deleted"
assert "bg-glow-breathe" not in css, "bg-glow-breathe must be deleted"
def test_glow_layers_are_deleted() -> None:
"""body::after / html::before / html::after no longer exist as CSS
rules — the layers (their background-images, their animations, their
fixed/z-index:-1 boxes) are gone from the page entirely."""
rules = _css_no_comments()
for sel in GLOW_LAYERS:
assert _find_rule(rules, sel) is None, (
f"{sel} must be deleted (the phase-78 static contract)"
)
def test_grid_keeps_its_static_texture() -> None:
"""The owner rejected the grid's MOTION, not the grid: 44px cells,
1px lines at the fixed 60% line alpha (rebrand warm tone,
2026-08-28), and the widened radial mask (both the -webkit- and
standard mask properties) stay."""
block = _rule_block(_css(), "body::before")
def test_no_background_layer_declares_animation() -> None:
"""None of the four background pseudo-element selectors carries an
``animation:`` declaration — the three glow selectors are ABSENT,
and the surviving grid layer is animation-free."""
rules = _css_no_comments()
for sel in (GRID_LAYER, *GLOW_LAYERS):
rule = _find_rule(rules, sel)
if rule is None:
continue # deleted layer — nothing to animate
assert "animation" not in rule.group(0), (
f"{sel} must not animate (static background contract)"
)
# --------------------------------------------------------------------------
# The static grid STAYS — byte-identical texture, no animation
# --------------------------------------------------------------------------
def test_grid_layer_is_static_and_unchanged() -> None:
"""The owner removed the animated part, not the grid: body::before
keeps 44px cells, 1px lines at the fixed 60% line alpha (warm
rebrand tone), and the widened radial mask (both the -webkit- and
standard mask properties) — and carries NO animation."""
block = _rule_block(_css(), GRID_LAYER)
assert "background-size: 44px 44px" in block
assert (
"linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
@@ -104,121 +122,33 @@ def test_grid_keeps_its_static_texture() -> None:
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
assert f"-webkit-mask-image: {mask};" in block
assert f"mask-image: {mask};" in block
assert "animation" not in block, "the grid must stay static (no animation)"
def test_exactly_three_bg_glow_keyframes_exist() -> None:
"""Exactly three bg-* keyframe blocks: bg-glow-a/b/c (the old
bg-grid-drift and bg-glow-breathe are deleted)."""
assert set(_bg_keyframes(_css())) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"}
def test_bg_keyframes_animate_opacity_only() -> None:
"""The no-movement contract: across ALL frames of ALL bg-* keyframes
the set of declared properties is exactly {opacity} — no transform,
scale, background-position, nothing else may appear."""
props: set[str] = set()
for _name, body in _bg_keyframes(_css()).items():
props |= set(re.findall(r"([A-Za-z-]+)\s*:", body))
assert props == {"opacity"}, (
f"bg-* keyframes must animate only opacity, found {sorted(props)}"
)
def test_glow_layers_declare_no_transform_or_position_animation() -> None:
"""The three glow layers themselves must not declare transform or
background-position either (the no-movement contract applies to the
layers, not just their keyframes)."""
for sel, _name, _anim, _grad in GLOW_SPOTS:
block = _rule_block(_css(), sel)
assert "transform" not in block, f"{sel} must not declare transform"
assert "background-position" not in block, (
f"{sel} must not declare background-position"
)
# --------------------------------------------------------------------------
# Three distinct bright spots, each on its own slow opacity-only fade
# --------------------------------------------------------------------------
def test_each_spot_runs_its_own_slow_opacity_fade() -> None:
"""body::after (rose, 26s), html::before (orange, 34s, -12s delay),
html::after (red, 42s, -23s delay) — the rebrand warm palette on
the phase-25 layout; each glow layer's background-image is EXACTLY
the single radial gradient from the spec (color, radius, position,
62% transparent stop)."""
for sel, _name, animation, gradient in GLOW_SPOTS:
block = _rule_block(_css(), sel)
assert animation in block, f"{sel} must run {animation}"
assert f"background-image: {gradient};" in block, (
f"{sel} must carry exactly one radial gradient: {gradient}"
)
def test_glow_durations_are_distinct_and_slow() -> None:
"""The three cycles are out of phase (distinct durations) and each is
slow (>= 20s); LCM(26, 34, 42) = 4641s, so the composite pattern
effectively never repeats within a viewing session."""
durations: list[float] = []
for sel, name, _anim, _grad in GLOW_SPOTS:
block = _rule_block(_css(), sel)
m = re.search(rf"animation: {name}\s+([\d.]+)s", block)
assert m, f"{sel} must run its {name} fade"
durations.append(float(m.group(1)))
assert len(set(durations)) == len(durations), (
"the three spot cycles must be out of phase (distinct durations)"
)
assert all(d >= 20 for d in durations), (
f"each spot fade must be slow (>= 20s), got {durations}"
)
def test_glow_keyframes_low_and_high_opacities() -> None:
"""Each cycle: 0%/100% at its own low opacity (0.25 / 0.20 / 0.15),
50% at 1 — smooth fade in and out, never a hard cut."""
keyframes = _bg_keyframes(_css())
lows = {"bg-glow-a": 0.25, "bg-glow-b": 0.20, "bg-glow-c": 0.15}
for name, low in lows.items():
body = keyframes[name]
m = re.search(r"0%,\s*100%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body)
assert m and float(m.group(1)) == low, (
f"{name} must start/end at opacity {low}"
)
m = re.search(r"50%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body)
assert m and float(m.group(1)) == 1.0, (f"{name} must peak at opacity 1")
# --------------------------------------------------------------------------
# Layer plumbing — the no-occlusion contract across all four layers
# --------------------------------------------------------------------------
def test_all_four_layers_are_fixed_zminus1_noninteractive() -> None:
"""All four background pseudo-layers stay behind the content and can
never intercept input: fixed, full-viewport, z-index -1,
pointer-events none, with pseudo content (UI Structure Check: layers
behind content, no 360px overflow — the layers are fixed; inset: 0)."""
for sel in ALL_LAYERS:
block = _rule_block(_css(), sel)
assert "position: fixed" in block, f"{sel} must stay position:fixed"
assert "inset: 0" in block, f"{sel} must stay full-viewport (inset: 0)"
assert "z-index: -1" in block, f"{sel} must stay z-index:-1"
assert "pointer-events: none" in block, f"{sel} must stay click-through"
assert 'content: ""' in block, f"{sel} must keep its pseudo content"
def test_grid_layer_plumbing() -> None:
"""The surviving grid layer stays behind the content and can never
intercept input: fixed, full-viewport, z-index -1, pointer-events
none, with pseudo content (UI Structure Check: the fixed; inset: 0
layer adds no width — no 360px overflow)."""
block = _rule_block(_css(), GRID_LAYER)
assert "position: fixed" in block
assert "inset: 0" in block
assert "z-index: -1" in block
assert "pointer-events: none" in block
assert 'content: ""' in block
def test_html_owns_canvas_and_body_stays_transparent() -> None:
"""The no-occlusion contract: <html> keeps the var(--bg) canvas;
<body> stays transparent and non-stacking — or the z-index:-1 layers
(including the new html::before / html::after spots) would be painted
over."""
"""The no-occlusion contract survives the deletion: <html> keeps the
var(--bg) canvas; <body> stays transparent and non-stacking — or the
z-index:-1 grid layer would be painted over."""
html_block = _rule_block(_css(), "html")
assert "background: var(--bg)" in html_block, (
"html must keep background: var(--bg) (the page canvas)"
)
body_block = _rule_block(_css(), "body")
assert "background: transparent" in body_block, (
"body must keep background: transparent so the layers show"
"body must keep background: transparent so the grid shows"
)
for prop in ("z-index", "transform", "opacity", "filter"):
assert prop + ":" not in body_block, (
@@ -227,31 +157,34 @@ def test_html_owns_canvas_and_body_stays_transparent() -> None:
# --------------------------------------------------------------------------
# Reduced motion + phase-08 anchors (no filter, no blur, zero JS)
# Reduced motion + phase-08 anchors
# --------------------------------------------------------------------------
def test_reduced_motion_stills_all_four_layers() -> None:
"""prefers-reduced-motion: reduce must still ALL FOUR layers together
(body::before, body::after, html::before, html::after) with
animation: none — the typing/spinner/thinking blocks are untouched."""
def test_background_reduced_motion_block_is_deleted() -> None:
"""The prefers-reduced-motion rule whose only job was stilling the
background layers (body::before, body::after, html::before,
html::after → animation: none) is deleted WITH the layers. The
unrelated reduced-motion blocks (typing dots, spinner, toasts, nav
slide, …) stay untouched."""
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}", _css()
)
assert any(
all(sel in b for sel in ALL_LAYERS) and "animation: none" in b
for b in blocks
), "a reduced-motion block must still all four background layers"
assert blocks, "the unrelated reduced-motion blocks must survive"
for i, block in enumerate(blocks):
for sel in (GRID_LAYER, *GLOW_LAYERS):
assert sel not in block, (
f"reduced-motion block {i} still references background layer {sel}"
)
def test_no_filter_in_any_layer_and_no_blur_anywhere() -> None:
"""The phase-08 performance anchor: no `filter` in any background
layer block, and no `blur` anywhere in styles.css (comments
stripped)."""
for sel in ALL_LAYERS:
assert "filter" not in _rule_block(_css(), sel), (
f"{sel} must not use any filter"
)
def test_no_filter_no_blur() -> None:
"""The phase-08 performance anchor survives the deletion: no
``filter`` in the grid rule and no ``blur`` anywhere in styles.css
(comments stripped)."""
assert "filter" not in _rule_block(_css(), GRID_LAYER), (
"the grid layer must not use any filter"
)
assert "blur" not in _css_no_comments(), (
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
)
+1
View File
@@ -211,6 +211,7 @@ def test_html_pages_include_history() -> None:
"/tuning.html",
"/git-sources.html",
"/history.html",
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
"/shared.html", # phase 51: the shared page's static path
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
):
+19 -1
View File
@@ -22,6 +22,7 @@ query_log row; structured ``error`` frame, not logged as cancelled).
from __future__ import annotations
import asyncio
import base64
import gc
import json
import logging
@@ -32,9 +33,10 @@ from types import SimpleNamespace
from typing import Any
import pytest
from itsdangerous import TimestampSigner
from app.api import chat as chat_api
from app.config import Settings
from app.config import Settings, get_settings
from app.main import app as fastapi_app
from app.models import Document, KbOverview, QueryLog
from app.rag.llm import LLMClient
@@ -195,6 +197,21 @@ def _install_llm(monkeypatch: pytest.MonkeyPatch, llm: LLMClient) -> None:
# ---------- the ASGI driver (client disconnect at the ASGI boundary) ----------
def _admin_cookie_header() -> tuple[bytes, bytes]:
"""A valid signed ``bor_session`` cookie carrying the admin session.
Phase 79 (task 03): ``POST /api/chat`` is user-gated, and the raw
ASGI scope below carries no browser — so it presents the same signed
cookie ``SessionMiddleware`` would have emitted after
``POST /api/login`` (the admin session short-circuits
``require_user``; the anonymous 401 contract is pinned in
``test_auth_api.py``)."""
settings = get_settings()
data = base64.b64encode(json.dumps({"admin": True}).encode("utf-8"))
signed = TimestampSigner(settings.session_secret).sign(data)
return b"cookie", f"{settings.session_cookie}={signed.decode('ascii')}".encode("ascii")
def _scope() -> dict[str, Any]:
return {
"type": "http",
@@ -209,6 +226,7 @@ def _scope() -> dict[str, Any]:
"headers": [
(b"host", b"testserver"),
(b"content-type", b"application/json"),
_admin_cookie_header(), # phase 79: the signed-in admin
],
"client": ("testclient", 50000),
"server": ("testserver", 80),
+12
View File
@@ -24,6 +24,7 @@ from app.rag.agent import AGENT_TOOLS
from app.rag.llm import StreamPiece
from app.rag.retriever import RetrievedChunk, weak_hit_titles
from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions
from tests.conftest import ADMIN_PASSWORD
if TYPE_CHECKING:
from app.rag.scaffolding import ScaffoldingFilter
@@ -492,6 +493,17 @@ class _FakeSession:
return None
@pytest.fixture(autouse=True)
def _admin_signed_in(client: TestClient) -> None:
"""Phase 79 (task 03): ``POST /api/chat`` is user-gated — the
endpoint-level tests run as the signed-in ADMIN, so the shared
``client`` logs in once per test. The admin session short-circuits
``require_user`` before any DB touch, so the fake-session wiring in
``gate_env`` is untouched."""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
@pytest.fixture()
def gate_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[_FakeSession, _CannedLLM]]:
"""``POST /api/chat`` with retriever, session, and LLM all faked."""
+4
View File
@@ -165,6 +165,10 @@ def test_new_chat_clears_key_and_ui() -> None:
assert "clearStoredConversation()" in body
assert 'querySelectorAll(".msg")' in body
assert "emptyState.hidden = false" in body
assert "loadSuggestions()" in body, (
"phase 80: the empty state came back — the onboarding chips refetch "
"so the row reflects the CURRENT last-3 state, not the boot fetch"
)
assert "setUiState(UI_STATE.idle)" in body
assert "sendStatus.textContent" in body, "confirmation via the live region"
assert "UI_STATE.thinking" in body and "UI_STATE.streaming" in body, (
+9 -2
View File
@@ -34,6 +34,7 @@ from app.api.docs import doc_format
from app.db import get_db
from app.main import create_app
from app.models import Document
from tests.conftest import ADMIN_PASSWORD
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
@@ -96,10 +97,16 @@ class _FakeSession:
def _client_with_row(row: object) -> TestClient:
"""Fresh app whose ``get_db`` dependency is a stub returning ``row``
(``None`` → no matching document row)."""
(``None`` → no matching document row). Phase 79: the endpoint is
user-gated, so the client signs in as the admin first — these tests
pin the CONTENT mapping (200/404/422), not the auth contract (which
``test_auth_api.py`` pins)."""
app = create_app()
app.dependency_overrides[get_db] = lambda: _FakeSession(row)
return TestClient(app)
client = TestClient(app)
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
return client
def test_content_unknown_pair_maps_to_404() -> None:
+513 -3
View File
@@ -36,6 +36,45 @@ active stamps) are gone with the four folded view documents, so
Chat link), and header.js carries NO ``is-active`` write: the whoami
auth gate + the mobile hamburger are its only nav responsibilities,
and the router is the SINGLE runtime writer of the active state.
Phase 77 task 01 (the re-show refresh hook): a user-initiated re-show
of an ALREADY-MOUNTED view dispatches ``bor:view-refresh`` on the
view's section — gated on the pre-mount ``wasMounted`` capture, so the
first show (the mount) and boot never fire it (the mount's own load is
the first fetch); a re-click of the active view's own nav link
dispatches the event instead of a bare return (no ``pushState`` — the
URL is already that view's path); the History view listens (armed only
in the admin branch, after the whoami gate — anonymous never fetches).
Phase 77 task 02 (the other data views join the refresh): RAG
(``sources.js``), Sources (``git-sources.js``) and Tuning
(``tuning.js``) each listen for ``bor:view-refresh`` on their root and
re-run their existing load (armed only in the admin branch, after the
whoami gate — the same gate guard as History). ``sources.js``'s
``loadDocs`` clears the tbody's rows at the TOP (before the fetch —
the History pattern), so a refresh from a populated list into an empty
result leaves no ghost rows. The Chat view (``app.js``) does NOT
listen — the negative pin: the in-flight SSE stream and the local
conversation must survive every switch (the phase-76 contract), so
the exclusion is a contract, not an oversight.
Phase 77 task 03 (the explicit History refresh control, TODO.md L3):
the History page-head becomes a flex row (scoped to ``#view-history``
— the other four views' page-heads are untouched) carrying the
``#history-refresh`` button (``aria-label="Refresh saved chats"``,
the aria-hidden house refresh glyph + the visible "Refresh" label —
the phase-46 auth-link convention) OUTSIDE the table wrap (reachable
while the empty state shows). history.js binds it in the admin branch
only (the anonymous branch hides it — no dead control beside the
gate); the click disables the button (no double-fire in flight),
reruns the re-entrant ``loadChats()`` and re-enables on success AND
failure (the finally). The outcome lands in ``#history-status``:
``Saved chats refreshed.`` on success (a 0-row fetch is a success) —
and the failure lines now live INSIDE ``loadChats`` (the house copy:
"is the app reachable?" / "try again."), so every caller of a failed
load sees it (the §7.4 never-stale contract). styles.css reuses the
``.new-chat-btn`` visual language (brand pill, ≥44px, hover,
:disabled) and goes icon-only below 640px.
"""
from __future__ import annotations
@@ -64,8 +103,9 @@ def _html() -> str:
def test_view_map_covers_the_shell_paths() -> None:
"""The VIEW map is pathname → view name: the shell's own two URLs
("/" and "/index.html") are the chat view, plus one entry per
folded view (tasks 01–03: tuning, rag, git-sources, history —
all four non-chat navbar views are in)."""
folded view (tasks 01–03: tuning, rag, git-sources, history;
phase 79 task 06: tokens — all five non-chat navbar views are
in)."""
js = _js()
view_start = js.find("const VIEW = {")
assert view_start != -1, "the VIEW map must exist"
@@ -80,8 +120,11 @@ def test_view_map_covers_the_shell_paths() -> None:
assert '"/history.html": "history"' in view_body, (
"task 03 folds the History view into the shell"
)
assert '"/tokens.html": "tokens"' in view_body, (
"phase 79 task 06 folds the Tokens view into the shell"
)
# The view names are the #view-<name> section slugs in index.html.
for name in ("chat", "tuning", "history"):
for name in ("chat", "tuning", "history", "tokens"):
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
@@ -172,6 +215,9 @@ def test_only_non_chat_views_have_lazy_modules() -> None:
assert 'history: () => import("./history.js")' in mods_body, (
"the History view module is lazy-imported on first show"
)
assert 'tokens: () => import("./tokens.js")' in mods_body, (
"the Tokens view module is lazy-imported on first show"
)
assert '"chat"' not in mods_body, "the chat view has no lazy module"
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
@@ -227,6 +273,8 @@ def test_router_writes_active_state_title_and_meta() -> None:
assert "Manage the global tuning notes that steer every Brain of Reese answer." in js
assert 'history: "Saved chats · Brain of Reese"' in js
assert "Saved chats — every conversation is saved automatically, one click back." in js
assert 'tokens: "Access tokens · Brain of Reese"' in js
assert "Generate and revoke the API tokens that let people use the app." in js
# The brand composition (phase 39's window.BOR_BRAND, read at
# write time — never a hardcoded stamp).
assert 'window.BOR_BRAND || "Brain of Reese"' in js
@@ -291,6 +339,15 @@ def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
tuning_link = tuning_match.group(0)
assert "hidden" in tuning_link, "#nav-tuning ships hidden (admin-only)"
assert "is-active" not in tuning_link, "no static active stamp on the Tuning link"
# The Tokens nav link (phase 79 task 06) ships hidden (admin-only)
# and UNstamped too — the router is the single writer of the active
# state, and a token user (role "user") must never see the link
# (header.js reveals it for admin only).
tokens_match = re.search(r'<a[^>]*id="nav-tokens"[^>]*>', html)
assert tokens_match, "the shell must carry the #nav-tokens nav link"
tokens_link = tokens_match.group(0)
assert "hidden" in tokens_link, "#nav-tokens ships hidden (admin-only)"
assert "is-active" not in tokens_link, "no static active stamp on the Tokens link"
# ---------- phase 76 task 04: the header is shell-owned ----------
@@ -352,3 +409,456 @@ def test_boot_order_is_brand_app_router() -> None:
assert 'type="module"' in router_tag, "router.js is an ES module"
# No CDN: every asset reference is local (AGENTS.md rule 6).
assert 'src="http' not in html and 'href="http' not in html
# ---------- phase 77 task 01: the re-show refresh hook ----------
def test_reshow_dispatches_view_refresh_gated_on_pre_mount_capture() -> None:
"""Phase 77: a user-initiated re-show of an already-mounted view
dispatches the ``bor:view-refresh`` CustomEvent on the view's
section. The dispatch site is INSIDE the ``if (wasMounted)`` guard,
and the ``wasMounted`` capture runs BEFORE the mount-once set
(``mounted[name] = true``) — so the first show (the mount) and boot
never dispatch: the mount's own load is the first fetch. Event
order: the view is visible and the head/nav state is written
BEFORE the refresh fires, and the focus/scroll tail runs after."""
js = _js()
assert '"bor:view-refresh"' in js, "the refresh event literal must exist"
fn = js.find("async function switchTo")
assert fn != -1, "switchTo must exist"
body = js[fn : js.find("\n}", fn)]
capture = body.find("const wasMounted = mounted[name]")
mount_set = body.find("mounted[name] = true")
assert 0 <= capture < mount_set, (
"the wasMounted capture must precede the mount-once set "
"(first show is exempt from the refresh)"
)
gate = body.find("if (wasMounted)")
dispatch = body.find('root.dispatchEvent(new CustomEvent("bor:view-refresh"))')
assert 0 <= gate < dispatch < gate + 120, (
"the dispatch must sit inside the wasMounted guard"
)
show_loop = body.find("Object.entries(viewEls)")
title_write = body.find("document.title = titleFor(name)")
current_set = body.find("current = name")
focus = body.find("root.focus(")
assert show_loop < title_write < current_set < gate < dispatch < focus, (
"visible → head/nav state → refresh dispatched → focus/scroll tail"
)
def test_active_view_reclick_dispatches_refresh_not_bare_return() -> None:
"""Phase 77: a re-click of the ACTIVE view's own nav link is a
re-fetch, not a no-op — the ``name === current`` branch dispatches
the refresh event on that view's section and returns. It must NOT
pushState (the URL is already this view's path) and must NOT
re-run the switch (no re-mount)."""
js = _js()
fn = js.find('nav.addEventListener("click"')
assert fn != -1, "the delegated click handler on the nav must exist"
body = js[fn : js.find("\n });", fn)]
branch = body.find("if (name === current)")
assert branch != -1, "the active re-click branch must exist"
branch_end = body.find("}", branch)
branch_body = body[branch : branch_end + 1]
assert 'new CustomEvent("bor:view-refresh")' in branch_body, (
"the re-click branch must dispatch the refresh event (not a bare return)"
)
assert "history.pushState" not in branch_body, (
"the re-click must NOT pushState — the URL is already this view's path"
)
assert "switchTo" not in branch_body, "the re-click must NOT re-run the switch"
assert "return" in branch_body, "the re-click still returns early (menu closes)"
def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None:
"""Phase 77: the History view re-fetches on a user-initiated
re-show — history.js registers a ``bor:view-refresh`` listener on
the view's root that re-runs the (now re-entrant) ``loadChats()``.
The listener is armed only AFTER the whoami gate passes: anonymous
shows the gate and never fetches (the phase-50 contract the story
E2E pins), and the ``started`` flag means the listener can only
re-run a load the mount already made."""
history_js = (ASSETS / "history.js").read_text(encoding="utf-8")
assert 'addEventListener("bor:view-refresh"' in history_js, (
"history.js must listen for the refresh event on the view root"
)
gate = history_js.find("if (!(await fetchIsAdmin()))")
listener = history_js.find('addEventListener("bor:view-refresh"')
assert 0 <= gate < listener, (
"the listener is armed only in the ADMIN branch (after the gate)"
)
assert re.search(r"if \(started\)\s+loadChats\(\)", history_js), (
"the listener is gated on the first load (started)"
)
# Re-entrancy: a re-load drops the data rows (except the hidden
# empty-state row) before fetching — the list is replaced, not
# duplicated.
load = history_js.find("async function loadChats()")
assert load != -1, "loadChats must exist"
load_body = history_js[load : history_js.find("\n }", load)]
assert "tr !== emptyRow" in load_body and "tr.remove()" in load_body, (
"loadChats must remove the data rows (the empty row stays) first"
)
clear_i = load_body.find("tr !== emptyRow")
fetch_i = load_body.find('fetch("/api/chats")')
assert 0 <= clear_i < fetch_i, "the row clearing precedes the fetch"
# ---------- phase 77 task 02: RAG / Sources / Tuning re-fetch; chat stays out ----------
def _asset(name: str) -> str:
path = ASSETS / name
assert path.is_file(), f"missing {path}"
return path.read_text(encoding="utf-8")
def _pin_refresh_listener(js: str, gate: str, listener_call: str, name: str) -> None:
"""Shared shape of the task-02 pin: the view module listens for
``bor:view-refresh`` on its own root, the listener re-runs the
view's existing load, and the listener is armed ONLY in the ADMIN
branch — after the whoami gate (anonymous never fetches)."""
listener = js.find('addEventListener("bor:view-refresh"')
assert listener != -1, f"{name} must listen for the refresh event on the view root"
gate_i = js.find(gate)
assert 0 <= gate_i < listener, (
f"{name}: the listener must be armed in the ADMIN branch (after {gate!r})"
)
assert listener_call in js[listener : listener + 120], (
f"{name}: the listener must re-run the view's load ({listener_call!r})"
)
def test_rag_view_refetches_on_reshow() -> None:
"""Phase 77 task 02: the RAG (knowledge base) view re-fetches on a
user-initiated re-show — sources.js listens and re-runs
``loadDocs()``. ``loadDocs`` is now re-entrant: the tbody's rows
are cleared at the TOP, before the fetch (the History pattern from
task 01), so a refresh from a populated list into an empty result
replaces the list instead of leaving ghost rows."""
js = _asset("sources.js")
_pin_refresh_listener(
js, "const admin = await fetchIsAdmin();", "() => loadDocs()", "sources.js"
)
load = js.find("async function loadDocs()")
assert load != -1, "loadDocs must exist"
body = js[load : js.find("\n }", load)]
clear_i = body.find("tbody.replaceChildren()")
fetch_i = body.find('fetch("/api/docs")')
assert 0 <= clear_i < fetch_i, (
"the row clearing must precede the fetch (a populated → empty refresh "
"must not leave ghost rows)"
)
def test_git_sources_view_refetches_on_reshow() -> None:
"""Phase 77 task 02: the Sources (git-sources) view re-fetches on a
user-initiated re-show — git-sources.js listens and re-runs
``loadSources()``. A re-call resets ALL THREE list states: the
populated render (renderSources replaces the tbody + re-syncs the
empty state) and the load error (``hideLoadError()`` runs on the
success path BEFORE rendering, so an error followed by a
successful refresh clears it)."""
js = _asset("git-sources.js")
_pin_refresh_listener(
js, "const admin = await fetchIsAdmin();", "() => loadSources()", "git-sources.js"
)
load = js.find("async function loadSources()")
assert load != -1, "loadSources must exist"
body = js[load : js.find("\n }", load)]
hide_i = body.find("hideLoadError()")
render_i = body.find("renderSources(")
assert 0 <= hide_i < render_i, (
"the success path must clear the load error before rendering "
"(an error followed by a successful refresh clears the error)"
)
render = js.find("function renderSources(")
assert render != -1, "renderSources must exist"
render_body = js[render : js.find("\n }", render)]
assert "tbody.replaceChildren()" in render_body, (
"a re-render replaces the list (the populated state resets)"
)
def test_tuning_view_refetches_on_reshow() -> None:
"""Phase 77 task 02: the Tuning view re-fetches on a user-initiated
re-show — tuning.js listens and re-runs ``loadNotes()``. A re-call
replaces the list (renderNotes clears it first); a FAILED refresh
keeps the last rendered list — loadNotes's documented contract
(progressive enhancement, never a blanked panel), unchanged by the
listener (it just calls the function)."""
js = _asset("tuning.js")
_pin_refresh_listener(
js, "if (await fetchIsAdmin())", "() => loadNotes()", "tuning.js"
)
render = js.find("function renderNotes(")
assert render != -1, "renderNotes must exist"
render_body = js[render : js.find("\n }", render)]
assert 'tuneList.textContent = ""' in render_body, (
"a re-render clears the list first (the re-call replaces it)"
)
def test_chat_view_does_not_listen_for_view_refresh() -> None:
"""Negative pin: app.js (the chat view) must NOT listen for
``bor:view-refresh`` — the in-flight SSE stream and the local
conversation survive EVERY switch (the phase-76 contract the
stream E2E pins). The exclusion is a contract, not an oversight;
the deliberate-exclusion comment lives at the chat view's
module-scope state in app.js."""
js = _asset("app.js")
assert 'addEventListener("bor:view-refresh"' not in js, (
"the chat view must NOT listen for the refresh event — its "
"in-flight stream and local conversation must survive every "
"switch (phase 76)"
)
assert "bor:view-refresh" in js, (
"the exclusion is documented at the chat view's module-scope state"
)
# ---------- phase 77 task 03: the History refresh button ----------
def _history_view(html: str) -> str:
"""The shell's History view section (the test_history_page pattern):
from the #view-history open tag to the container main's close
(the view is the shell's LAST view section)."""
start = html.find('<section class="view" id="view-history"')
assert start != -1, "the #view-history section must be in the shell"
end = html.find("</main>", start)
assert end != -1, "the container main must close after the view"
return html[start:end]
def test_history_refresh_button_markup_lives_in_the_page_head() -> None:
"""Phase 77 task 03 (TODO.md L3): the History page-head carries the
explicit refresh control — #history-refresh, a ``type="button"``
``.history-refresh`` with the accessible name
``aria-label="Refresh saved chats"``, the house inline-SVG refresh
glyph (aria-hidden) and the visible "Refresh" label (the phase-46
auth-link convention: label visible ≥640px, icon-only below — the
aria-label keeps the name in both). It sits INSIDE the view's
.page-head and BEFORE the table wrap (outside it — the button must
stay reachable while the empty state is showing)."""
view = _history_view(_html())
btn_i = view.find('id="history-refresh"')
assert btn_i != -1, "the #history-refresh button must exist"
tag_start = view.rfind("<button", 0, btn_i)
tag_end = view.find(">", btn_i)
tag = view[tag_start:tag_end]
assert 'type="button"' in tag, "a plain button (no form submit)"
assert 'class="history-refresh"' in tag
assert 'aria-label="Refresh saved chats"' in tag, ("the accessible name")
tail = view[tag_end:tag_end + 600]
assert 'aria-hidden="true"' in tail, "the refresh glyph must be aria-hidden"
assert '<span class="history-refresh-label">Refresh</span>' in tail, (
"the visible Refresh label (icon-only below 640px, label above)"
)
head_i = view.find('class="page-head"')
wrap_i = view.find('id="history-table-wrap"')
assert -1 < head_i < btn_i < wrap_i, (
"the button sits in the page-head, before (OUTSIDE) the table wrap"
)
def test_history_refresh_button_binding_admin_only_with_outcome_lines() -> None:
"""Phase 77 task 03: history.js binds #history-refresh in the ADMIN
branch only — the anonymous branch HIDES the button (the gate is
what anonymous sees; no dead control beside the sign-in gate) and
still fetches nothing. The click handler disables the button
BEFORE the fetch (no double-fire while in flight) and delegates to
the re-entrant load; the re-enable sits in a ``finally`` (success
AND failure — a click can never leave the button stuck disabled).
The success line is ``Saved chats refreshed.``; the failure lines
live INSIDE ``loadChats`` itself — the house copy (network:
"is the app reachable?"; non-2xx: "try again.") — so every caller
of a failed load (the mount's first load, a re-show, the button)
sees the outcome in #history-status."""
js = _asset("history.js")
gate = js.find("if (!(await fetchIsAdmin()))")
assert gate != -1
branch = js[gate:js.find("return;", gate)]
assert "refreshBtn.hidden = true" in branch, (
"the anonymous branch hides the button (no dead control)"
)
admin_after = js[js.find("return;", gate):]
bind = admin_after.find('refreshBtn.addEventListener("click"')
assert bind != -1, "the refresh binding must exist in the admin branch"
handler = admin_after[bind:admin_after.find(");", bind)]
assert "refreshBtn.disabled = true" in handler, (
"the click disables the button before the fetch (no double-fire)"
)
# refreshChats is defined alongside loadChats (before the gate) —
# its BODY is pinned on the whole file, its BINDING on the admin
# branch above (the hoisted function is only reachable from the
# admin-branch binding: the anonymous branch never references it).
fn = js.find("async function refreshChats()")
assert fn != -1, "refreshChats must exist"
fn_body = js[fn:js.find("\n }", fn)]
assert "loadChats()" in fn_body, "the button re-runs the (re-entrant) load"
assert "finally" in fn_body and "refreshBtn.disabled = false" in fn_body, (
"the button re-enables on success AND failure (the finally)"
)
assert '"Saved chats refreshed."' in fn_body, "the exact success line"
load = js.find("async function loadChats()")
load_body = js[load:js.find("\n }", load)]
assert "Couldn't load saved chats — is the app reachable?" in load_body, (
"the network-error line lives in loadChats (every caller sees it)"
)
assert "Couldn't load saved chats — try again." in load_body, (
"the non-2xx line lives in loadChats (every caller sees it)"
)
def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
"""Phase 77 task 03 (styles.css): .history-refresh reuses the
.new-chat-btn visual language — the solid brand pill (--bg text on
--brand, 5.2:1 ≥ WCAG 4.5:1), the ≥44px target, the lightened
hover fill, the dimmed :disabled (the in-flight state), the glyph
hidden on desktop (the label carries the pill) — and the global
:focus-visible ring applies (no button-scoped focus override).
The page-head flex row is SCOPED to #view-history (the other four
views' page-heads are untouched). Below 640px the pill goes
icon-only (the phase-46 auth-link convention — the aria-label
keeps the accessible name)."""
css = _asset("styles.css")
block = re.search(r"\.history-refresh \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .history-refresh"
body = block.group(1)
assert "background: var(--brand)" in body, "the .new-chat-btn brand fill"
assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)"
assert "min-height: 44px" in body, "the comfortable touch target"
assert "border-radius: 999px" in body and "border: 0" in body, "the pill"
assert ".history-refresh:hover { background: #f55a72; color: var(--bg); }" in css
assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, (
"the in-flight disabled state is dimmed (the house language)"
)
assert ".history-refresh svg { width: 16px; height: 16px; display: none; }" in css, (
"desktop: the label carries the pill (the glyph is hidden)"
)
row = re.search(r"#view-history \.page-head \{([\s\S]*?)\n\}", css)
assert row and "display: flex" in row.group(1), (
"the page-head flex row is scoped to the History view"
)
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the 640px media query must exist"
mbody = mobile.group(1)
assert ".history-refresh-label { display: none; }" in mbody, (
"icon-only below 640px (the phase-46 convention)"
)
assert ".history-refresh svg { display: block; }" in mbody, (
"the glyph is the whole control below 640px"
)
# ---------- phase 79 task 06: the Tokens view (generate · list · revoke) ----------
def test_tokens_view_module_contract() -> None:
"""Phase 79 task 06: tokens.js follows the phase-76 view-module
contract — ``export async function mount(root)`` is the entry, the
whoami gate (``fetchIsAdmin``) runs in mount and the anonymous
branch shows the gate + hides the table + RETURNS with NO
/api/tokens request (the router 403s anonymous), the
``bor:view-refresh`` listener is armed ONLY in the ADMIN branch
(after the gate) and re-runs the re-entrant ``loadTokens()``
(gated on the ``started`` flag), and ``loadTokens`` hides the
once-block and clears the data rows BEFORE the fetch — the
plaintext is never re-shown and the list is replaced, not
duplicated. Every cell is textContent: the file never touches
innerHTML (XSS-safe by construction)."""
js = _asset("tokens.js")
assert "export async function mount(root)" in js, (
"mount(root) must be the module's entry (the phase-76 fold)"
)
assert 'import { fetchIsAdmin } from "./header.js";' in js, (
"the view imports ONLY the shared cached whoami promise"
)
mount_i = js.find("export async function mount(root)")
gate_i = js.find("if (!(await fetchIsAdmin()))")
assert 0 <= mount_i < gate_i, "the whoami gate must run in mount"
# The anonymous branch: gate in, table out, then a bare return —
# and NO fetch call anywhere inside it.
branch = js[gate_i:js.find("return;", gate_i)]
assert "fetch(" not in branch, (
"the anonymous branch must not fetch anything"
)
assert "tableWrap.hidden = true" in branch
assert "gateEl.hidden = false" in branch
# The re-show refresh: armed in the ADMIN branch only (after the
# gate), gated on the first load (started), re-running loadTokens.
listener = js.find('addEventListener("bor:view-refresh"')
assert 0 <= gate_i < listener, (
"the refresh listener is armed only in the ADMIN branch (after the gate)"
)
assert re.search(r"if \(started\)\s+loadTokens\(\)", js), (
"the listener is gated on the first load (started)"
)
# loadTokens: re-entrant — the once-block hides and the data rows
# (except the hidden empty-state row) are dropped BEFORE the fetch.
load = js.find("async function loadTokens()")
assert load != -1, "loadTokens must exist"
load_body = js[load:js.find("\n }", load)]
hide_i = load_body.find("onceBlock.hidden = true")
clear_i = load_body.find("tr !== emptyRow")
fetch_i = load_body.find('fetch("/api/tokens")')
assert 0 <= hide_i < clear_i < fetch_i, (
"once-block hide + row clearing must precede the fetch "
"(a re-render never re-shows the plaintext; the list is replaced)"
)
assert "innerHTML" not in js, (
"every cell is textContent — no innerHTML anywhere (XSS-safe)"
)
def test_tokens_view_scaffold_in_the_shell() -> None:
"""Phase 79 task 06: the shell carries the #view-tokens section —
hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) —
with the page-head (h1 \"Access tokens\"), the #tokens-gate (the
#history-gate pattern, ship-hidden, its Sign in returning to the
Tokens view), the role=\"status\" live region, the create row
(label input + Generate — ship-hidden, anonymous-safe), the
#token-once block (ship-hidden — only a 201 reveals it), and the
full-width table (AGENTS.md rule 5) with the visually-hidden
Actions header + the hidden #tokens-empty-row."""
html = _html()
view = html.find('<section class="view" id="view-tokens"')
assert view != -1, "the #view-tokens section must be in the shell"
tag_end = html.find(">", view)
tag = html[view:tag_end]
assert "hidden" in tag and "inert" in tag, (
"the folded view ships hidden AND inert"
)
assert 'tabindex="-1"' in tag, "the target view is focusable"
main_end = html.find("</main>", view)
assert view < main_end, "the view section lives inside the single main"
body = html[view:main_end]
assert "<h1>Access tokens</h1>" in body
gate = re.search(r'<section[^>]*id="tokens-gate"[^>]*>', body)
assert gate and "hidden" in gate.group(0), "#tokens-gate must ship hidden"
assert 'href="/login.html?next=/tokens.html"' in body, (
"the gate's Sign in returns to the Tokens view (no-JS fallback)"
)
assert re.search(r'<span[^>]*id="tokens-status"[^>]*role="status"[^>]*>', body)
create = re.search(r'<div[^>]*id="token-create"[^>]*>', body)
assert create and "hidden" in create.group(0), (
"the create row ships hidden (anonymous-safe)"
)
assert re.search(r'<input[^>]*id="token-label"[^>]*>', body)
assert re.search(r'<button[^>]*id="token-generate"[^>]*>', body)
once = re.search(r'<div[^>]*id="token-once"[^>]*>', body)
assert once and "hidden" in once.group(0), (
"the once-block ships hidden (only a 201 reveals it)"
)
assert re.search(r'<input[^>]*id="token-once-value"[^>]*readonly[^>]*>', body)
assert re.search(r'<button[^>]*id="token-once-copy"[^>]*>', body)
wrap = re.search(r'<div[^>]*id="tokens-table-wrap"[^>]*>', body)
assert wrap and 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
assert 'id="tokens-tbody"' in body
assert re.search(r'<tr[^>]*id="tokens-empty-row"[^>]*hidden>', body)
# The Actions column header is visually-hidden (the row buttons
# carry their own aria-labels — the history-table convention).
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
+6 -3
View File
@@ -292,12 +292,15 @@ def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
boot_start = js.find("(async () => {")
assert boot_start != -1, "the boot IIFE must exist"
boot = js[boot_start:]
# Phase 79 (task 05): the token gate settles FIRST — a cached
# token's silent re-auth lands before the first whoami fires.
gate_i = boot.find("await mountGate(")
init_i = boot.find("await initSharedHeader();")
admin_i = boot.find("isAdmin = await fetchIsAdmin();")
admin_i = boot.find('who.role === "admin";')
saved_i = boot.find("await restoreSavedChatFromUrl();")
local_i = boot.find("restoreConversation();")
assert -1 < init_i < admin_i < saved_i < local_i, (
"boot order: header init → whoami → ?chat= load → local fallback"
assert -1 < gate_i < init_i < admin_i < saved_i < local_i, (
"boot order: token gate → header init → whoami → ?chat= load → local fallback"
)
assert "shareBtn.hidden" not in boot, ("no Share-reveal line left in boot (phase 55 task 03)")
assert "if (!openedSaved) restoreConversation();" in boot, (
+64 -17
View File
@@ -44,29 +44,69 @@ def _script_srcs(path: Path) -> list[str]:
# ---------- header.js: the module itself ----------
def test_header_module_exports_the_three_functions() -> None:
"""header.js must export the three functions every page script
imports (fetchIsAdmin / initSharedHeader / clearChatStorage)."""
def test_header_module_exports_the_header_functions() -> None:
"""header.js must export the functions every page script imports
(fetchWhoami — the phase-79 canonical call — fetchIsAdmin, its
phase-16/19 backward-compatible delegation, initSharedHeader,
clearChatStorage) plus resetWhoami (the phase-79 cache
invalidation the token gate uses after a mid-page auth)."""
js = _text(HEADER_JS)
assert "export function fetchWhoami" in js
assert "export function fetchIsAdmin" in js
assert "export function resetWhoami" in js
assert "export async function initSharedHeader" in js
assert "export function clearChatStorage" in js
def test_whoami_fetch_is_cached_in_a_module_level_promise() -> None:
"""The whoami fetch is cached in the module-level `adminPromise`
marker — first call stores the promise, later calls return it, so a
page makes exactly ONE /api/whoami request per load no matter how
many consumers await it. Anonymous-safe: a failure resolves to
false."""
"""The whoami fetch is cached in the module-level `whoamiPromise`
marker (phase 79, task 05: it stores the FULL response —
{ authenticated, role } — not just the admin flag) — first call
stores the promise, later calls return it, so a page makes exactly
ONE /api/whoami request per load no matter how many consumers
await it. Anonymous-safe: non-2xx / network failure / malformed
body all resolve to { authenticated: false, role: "anonymous" }.
The string `fetch("/api/whoami")` appears in this file EXACTLY
ONCE — the single-request contract (the rest of the frontend goes
through fetchWhoami/fetchIsAdmin)."""
js = _text(HEADER_JS)
assert re.search(r"let\s+adminPromise\s*=\s*null", js), (
"module-level adminPromise marker missing"
assert re.search(r"let\s+whoamiPromise\s*=\s*null", js), (
"module-level whoamiPromise marker missing"
)
assert 'fetch("/api/whoami")' in js
assert "if (!adminPromise)" in js, "fetchIsAdmin must reuse the stored promise"
assert "return adminPromise" in js
assert ".catch(() => false)" in js, "network failure must resolve to anonymous"
assert js.count('fetch("/api/whoami")') == 1, (
"the SINGLE /api/whoami call site lives in header.js exactly once"
)
assert "if (!whoamiPromise)" in js, "fetchWhoami must reuse the stored promise"
assert "return whoamiPromise" in js
assert 'role: "anonymous"' in js, "the anonymous fallback carries the role"
assert ".catch(() => ANONYMOUS_WHOAMI)" in js, (
"network failure must resolve to the anonymous role"
)
def test_fetch_is_admin_delegates_to_fetch_whoami() -> None:
"""Phase 79 (task 05): fetchIsAdmin() is a thin delegation —
fetchWhoami().then(w => w.role === "admin"): SAME single request,
all phase-16/19 callers keep working, and a token user (role
"user") reads FALSE here (the admin-only surfaces key off
role === "admin", never off `authenticated`)."""
js = _text(HEADER_JS)
fn = js.find("export function fetchIsAdmin")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "fetchWhoami().then((w) => w.role === \"admin\")" in body
def test_reset_whoami_clears_the_module_cache() -> None:
"""Phase 79 (task 05): the token gate changes the session MID-PAGE
(silent re-auth / interactive login) — resetWhoami() drops the
cached promise so the NEXT fetchWhoami() is a fresh post-auth
request (a boot-fired pre-auth whoami would still say anonymous)."""
js = _text(HEADER_JS)
fn = js.find("export function resetWhoami")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "whoamiPromise = null" in body
def test_init_shared_header_toggles_only_elements_that_exist() -> None:
@@ -77,7 +117,11 @@ def test_init_shared_header_toggles_only_elements_that_exist() -> None:
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "await fetchIsAdmin()" in body
# Phase 79 (task 05): the header boots on the FULL whoami — the
# auth pair keys off the authenticated role (admin OR token user),
# the admin-only surfaces off role === "admin".
assert "await fetchWhoami()" in body
assert 'whoami.role === "admin"' in body
for selector in ("#nav-sources", "#nav-git-sources", "#nav-tuning"):
assert f'querySelector("{selector}")' in body
# Sign in: both the bar copy AND the mobile dropdown copy (phase 46)
@@ -410,7 +454,10 @@ def test_app_js_delegates_the_shared_controls_to_header_module() -> None:
(`./header.js`) so esbuild can bundle it into the image."""
js = _text(APP_JS)
assert 'from "./header.js"' in js
assert "fetchIsAdmin" in js and "initSharedHeader" in js
# Phase 79 (task 05): app.js reads the FULL whoami (the same cached
# promise) — the auth pair off `authenticated`, the admin-only
# surfaces off role === "admin".
assert "fetchWhoami" in js and "initSharedHeader" in js
assert "signOutBtn.addEventListener" not in js, (
"the sign-out binding moved to header.js"
)
@@ -419,7 +466,7 @@ def test_app_js_delegates_the_shared_controls_to_header_module() -> None:
)
assert "loadAuthState" not in js, "loadAuthState was deleted in phase 19"
assert "function applyAuthState" in js, "chat-page tuning gate stays"
assert "isAdmin = await fetchIsAdmin();" in js
assert 'who.role === "admin"' in js and "who.authenticated" in js
init_idx = js.find("await initSharedHeader();")
restore_idx = js.find("restoreConversation();")
assert -1 < init_idx < restore_idx, (
+369
View File
@@ -0,0 +1,369 @@
"""Unit: the in-app token gate (phase 79, task 05).
Source-level house pattern (read the JS sources, no browser): pins the
gate module's wiring — the ``bor.token`` localStorage key, the
SILENT-RE-AUTH-BEFORE-WHOAMI order, the failed-re-auth key drop, the
cache-invalidation choice — the header's full-whoami plumbing
(``fetchWhoami`` exported, ``fetchIsAdmin`` delegating, the SINGLE
``fetch("/api/whoami")`` call site, the sign-out binding dropping the
cached token), and the shell/viewer HTML wiring (the gate ships hidden
+ inert, the form/input/error ids, the admin link, the boot order
token-gate.js AFTER router.js). The browser flows (gate → unlock →
cached reload → revoked drop → sign-out) are E2E-pinned by
``tests/e2e/test_api_tokens.py`` (phase 79, task 07).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
APP_JS = ASSETS / "app.js"
DOCUMENT_JS = ASSETS / "document.js"
TOKEN_GATE_JS = ASSETS / "token-gate.js"
INDEX_HTML = FRONTEND / "index.html"
DOCUMENT_HTML = FRONTEND / "document.html"
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
# ---------- token-gate.js: the module itself ----------
def test_token_gate_module_exists_and_exports_mount_gate() -> None:
"""token-gate.js is an ES module exposing mountGate(lockRoot,
onAuthed) — the reusable mount point (the shell passes #main, the
viewer passes its content wrapper)."""
js = _text(TOKEN_GATE_JS)
assert "export async function mountGate" in js
# Relative import for the single-evaluation design (esbuild inlines
# it into the page bundles; the Containerfile parity pin covers the
# image build).
assert 'from "./header.js"' in js
assert '"/assets/header.js"' not in js
def test_token_gate_uses_the_bor_token_localstorage_key() -> None:
"""The owner's sentence: "cache that token in browser storage". The
cached key is the LITERAL bor.token — read at mount (silent
re-auth), written on a successful login, dropped on a failed
re-auth and on sign out (the header binding). Every localStorage
access is try/catch (the fail-silence storage contract)."""
js = _text(TOKEN_GATE_JS)
assert '"bor.token"' in js, "the bor.token localStorage key literal"
# The three accesses (read at mount, write on login, drop on a
# failed re-auth) all go through the key constant — try/catch each
# (the fail-silence storage contract: private mode degrades to
# "re-enter the token each visit", never to a broken gate).
assert "localStorage.getItem(TOKEN_KEY)" in js
assert "localStorage.setItem(TOKEN_KEY" in js
assert "localStorage.removeItem(TOKEN_KEY)" in js
assert js.count("try {") >= 3
assert js.count("catch") >= 3
def test_silent_reauth_happens_before_the_whoami_check() -> None:
"""Source order: the cached token is re-sent to POST /api/token-auth
BEFORE the whoami role check — the re-auth (re)sets the session
cookie before any whoami settles, so the role check sees the
post-auth role (no stale anonymous for a returning token user)."""
js = _text(TOKEN_GATE_JS)
reauth = js.find("(1) SILENT RE-AUTH — before the whoami check")
role_check = js.find("(2) ROLE CHECK — header.js's fetchWhoami()")
assert -1 < reauth < role_check, "the silent re-auth must precede the whoami check"
assert 'fetch("/api/token-auth"' in js
# The re-auth block reads the cached token and posts it, all before
# the role check's fetchWhoami.
cached_read = js.find("readCachedToken()")
assert -1 < cached_read < role_check
# The role check goes through header.js's SHARED cached promise
# (one /api/whoami per page load in dev).
assert "await fetchWhoami()" in js
def test_failed_silent_reauth_drops_the_cached_key() -> None:
"""A failed silent re-auth (revoked / unknown / network) removes
the key — the token may have been revoked — before the mount falls
through to the role check. The remove call sits in the (1) block,
so a dead cached token can never linger in localStorage."""
js = _text(TOKEN_GATE_JS)
reauth = js.find("(1) SILENT RE-AUTH — before the whoami check")
role_check = js.find("(2) ROLE CHECK — header.js's fetchWhoami()")
assert -1 < reauth < role_check
block = js[reauth:role_check]
assert "removeToken()" in block, "the failure path must drop the key"
# removeToken itself hits the real localStorage.removeItem (inside
# its own try/catch).
fn = js.find("const removeToken")
body = js[fn : js.find("\n};", fn)]
assert "localStorage.removeItem(TOKEN_KEY)" in body
def test_gate_ships_hidden_and_revealed_as_an_inert_pair() -> None:
"""The gate ships hidden + inert (the phase-16 ship-hidden pattern
— an authenticated boot never shows it for a frame) and the JS
always toggles hidden AND inert together (the WCAG inert-pair
contract): revealing drops BOTH, hiding re-adds BOTH."""
js = _text(TOKEN_GATE_JS)
fn = js.find("export async function mountGate")
assert fn != -1
body = js[fn:]
# Reveal: drop hidden AND inert.
assert "gate.hidden = false" in body
assert "gate.inert = false" in body
# Hide: re-add hidden AND inert.
assert "gate.hidden = true" in body
assert "gate.inert = true" in body
# The lock root is locked (inert) when the gate shows and unlocked
# when auth settles — the locked app never receives focus.
assert "lockRoot.inert = true" in body
assert "lockRoot.inert = false" in body
def test_gate_submit_caches_then_invalidates_the_whoami_cache() -> None:
"""Form submit: 204 → cache the token, THEN invalidate the module
whoami cache (resetWhoami) and re-fetch through fetchWhoami — the
documented choice (a direct re-fetch would leave header.js's
boot-fired anonymous cache stale for the header re-boot). 401 →
the role=alert error line, the input cleared + re-focused."""
js = _text(TOKEN_GATE_JS)
fn = js.find("form.addEventListener(\"submit\"")
assert fn != -1
block = js[fn:]
store = block.find("storeToken(token)")
reset = block.find("resetWhoami()")
refetch = block.find("await fetchWhoami()")
assert -1 < store < reset < refetch, (
"cache → invalidate → re-fetch: the order the contract pins"
)
# The error path: 401 → showError() — the role=alert line revealed,
# the input cleared + re-focused (defined once at mount, called from
# the failure branch).
assert "showError()" in block
fn_show = js.find("const showError")
show = js[fn_show : js.find("\n };", fn_show)]
assert "error.hidden = false" in show
assert "input.value = \"\"" in show
assert "input.focus()" in show
def test_gate_finds_its_markup_by_class() -> None:
"""The gate markup differs only in ids across the two pages
(#auth-gate / #doc-auth-gate) — the module finds it by the shared
.auth-gate CLASS (the ONE section on the page), and the token
input by name (the form field, not the id)."""
js = _text(TOKEN_GATE_JS)
assert 'querySelector(".auth-gate")' in js
assert 'input[name="token"]' in js
# ---------- header.js: the full-whoami plumbing ----------
def test_header_fetch_whoami_is_the_single_call_site() -> None:
"""The string fetch("/api/whoami") appears in header.js EXACTLY
ONCE (the single-request contract — the file's comments also
mention whoami, so the pin is on the fetch call, not the word);
fetchWhoami is exported and fetchIsAdmin delegates to it (a token
user reads false from fetchIsAdmin — the admin surfaces key off
role === "admin")."""
js = _text(HEADER_JS)
assert 'fetch("/api/whoami")' in js
assert js.count('fetch("/api/whoami")') == 1, (
"no second whoami call site may enter header.js"
)
assert "export function fetchWhoami" in js
fn = js.find("export function fetchIsAdmin")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert "fetchWhoami()" in body, "fetchIsAdmin must delegate to fetchWhoami"
assert 'w.role === "admin"' in body
def test_sign_out_binding_drops_the_cached_token_before_reload() -> None:
"""The sign-out binding (header.js, module-owned) removes
localStorage["bor.token"] — try/catch, the fail-silence storage
contract — AFTER the logout POST and BEFORE the reload: one
logout clears the server session AND the cached token, so a
signing-out token user meets the gate again on the next load."""
js = _text(HEADER_JS)
logout = js.find('fetch("/api/logout", { method: "POST" })')
drop = js.find('localStorage.removeItem("bor.token")')
reload = js.find("window.location.reload()")
assert -1 < logout < drop < reload, (
"sign out: logout → drop bor.token → reload (the order the contract pins)"
)
def test_init_shared_header_keys_the_pair_off_authenticated() -> None:
"""Phase 79: initSharedHeader's auth PAIR (Sign in / Sign out) keys
off the authenticated role — a token user (role "user") gets
sign-in hidden + sign-out visible; the admin-ONLY surfaces (nav
links, steering refresh) still key off role === "admin" (a user
gets the anonymous branch: links hidden, the panel REMOVED,
/api/steering never fetched). Admin/anonymous stays
byte-identical to phase 16/19."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert 'link.hidden = signedIn' in body
assert "btn.hidden = !signedIn" in body
assert "navSources.hidden = !admin" in body
assert "steeringPanel?.remove();" in body
# ---------- index.html: the shell gate + boot order ----------
def _script_srcs(html: str) -> list[str]:
return re.findall(r'<script[^>]*src="([^"]+)"', html)
def test_shell_loads_token_gate_after_router() -> None:
"""index.html loads token-gate.js as a module AFTER app.js and
router.js (boot order: brand.js classic → app.js module →
router.js module → token-gate.js module) — the Containerfile
bundles it (the parity pin in
tests/integration/test_containerfile_assets.py covers the image).
No page loads it BEFORE app.js (the gate's boot call is awaited
by app.js's boot IIFE)."""
html = _text(INDEX_HTML)
srcs = _script_srcs(html)
tag = [s for s in srcs if "token-gate.js" in s]
assert tag, "the shell must load the token-gate module"
order = [
srcs.index("assets/brand.js"),
srcs.index("/assets/app.js"),
srcs.index("/assets/router.js"),
srcs.index(tag[0]),
]
assert order == sorted(order), (
f"boot order brand.js → app.js → router.js → token-gate.js broken: {srcs}"
)
m = re.search(r'<script[^>]*src="[^"]*token-gate\.js"[^>]*>', html)
assert m and 'type="module"' in m.group(0), "token-gate.js is an ES module"
def test_shell_gate_markup_ships_hidden_inert() -> None:
"""The shell's gate (body-level, AFTER #main) ships hidden + inert
with the full contract: the #auth-gate section labelled by its h2
("Enter your access token"), the sub line, the labelled form with
the mono token input (autocomplete off — a token must never be
offered by the password manager) and the Sign in submit, the
role=alert error line (hidden, the owner-locked copy), and the
"Sign in as admin" link (the header's ?next= convention, the
no-JS fallback)."""
html = _text(INDEX_HTML)
# The section: body-level, after #main (before the footer).
tag = re.search(r'<section[^>]*id="auth-gate"[^>]*>', html)
assert tag, "the shell must carry the #auth-gate section"
assert "hidden" in tag.group(0) and "inert" in tag.group(0), (
"the gate ships hidden + inert (the ship-hidden pattern)"
)
assert 'class="auth-gate"' in tag.group(0)
assert 'aria-labelledby="auth-gate-title"' in tag.group(0)
main_end = html.find("</main>")
assert main_end < html.find('id="auth-gate"'), "the gate sits AFTER #main"
# The content (the #sources-gate visual language).
assert '<h2 id="auth-gate-title">Enter your access token</h2>' in html
assert "Shared chats stay open" in html
form = re.search(r'<form[^>]*id="auth-gate-form"[^>]*>', html)
assert form, "the gate form"
assert '<label class="visually-hidden" for="auth-gate-input">Access token</label>' in html
inp = re.search(r'<input[^>]*id="auth-gate-input"[^>]*>', html)
assert inp, "the token input"
for attr in (
'name="token"',
'type="text"',
'autocomplete="off"',
'autocapitalize="none"',
'spellcheck="false"',
"required",
):
assert attr in inp.group(0), f"the token input must carry {attr}"
assert 'type="submit"' in html and "Sign in" in html
err = re.search(r'<p[^>]*class="auth-gate-error"[^>]*id="auth-gate-error"[^>]*>', html)
assert err, "the error line"
assert 'role="alert"' in err.group(0) and "hidden" in err.group(0)
assert "That token isn" in html, "the owner-locked error copy"
assert 'href="/login.html?next=/"' in html, "the admin link (?next= convention)"
def test_shell_boots_the_gate_from_app_js() -> None:
"""app.js awaits mountGate(#main, no-op) at boot — BEFORE its
initSharedHeader — so a silent re-auth lands before the first
whoami fires (the header sees the post-auth role deterministically).
In the shell, onAuthed needs no view work: the lazy views mount on
first show exactly as today (mount-once, hide-forever untouched)."""
js = _text(APP_JS)
assert 'from "./token-gate.js"' in js
gate_i = js.find('mountGate(document.getElementById("main"), () => {})')
assert gate_i != -1, "the shell's boot call (no-op onAuthed)"
init_i = js.find("await initSharedHeader();", gate_i)
assert -1 < gate_i < init_i, "the gate settles BEFORE the header boots"
# ---------- document.html / document.js: the viewer gate ----------
def test_viewer_gate_markup_reuses_the_shell_copy_renamed() -> None:
"""document.html carries the SAME gate markup as the shell, the ids
renamed (#doc-auth-gate / #doc-auth-gate-form / #doc-auth-gate-input
/ #doc-auth-gate-error / #doc-auth-gate-title) — hidden + inert,
the labelled form + input + role=alert error + admin link."""
html = _text(DOCUMENT_HTML)
tag = re.search(r'<section[^>]*id="doc-auth-gate"[^>]*>', html)
assert tag, "the viewer must carry the #doc-auth-gate section"
assert "hidden" in tag.group(0) and "inert" in tag.group(0)
assert 'class="auth-gate"' in tag.group(0)
assert 'aria-labelledby="doc-auth-gate-title"' in tag.group(0)
assert '<h2 id="doc-auth-gate-title">Enter your access token</h2>' in html
assert re.search(r'<form[^>]*id="doc-auth-gate-form"[^>]*>', html)
assert re.search(
r'<input[^>]*id="doc-auth-gate-input"[^>]*name="token"[^>]*>', html
) or re.search(
r'<input[^>]*name="token"[^>]*id="doc-auth-gate-input"[^>]*>', html
), "the viewer's token input"
assert re.search(r'id="doc-auth-gate-error"[^>]*role="alert"', html) or re.search(
r'role="alert"[^>]*id="doc-auth-gate-error"', html
)
def test_viewer_wires_the_gate_around_the_existing_boot() -> None:
"""document.js wires mountGate(#main, onAuthed) — onAuthed runs the
content load for a SIGNED-IN role only (anonymous never fetches the
gated content; the inline gate is the surface) — and the shared
header boots in the .then AFTER the gate settles, for EVERY role
(the gate locks #main, not the header — the anonymous contract is
byte-identical to the shell). Awaiting the gate first is what makes
the header race-free: the settled whoami is the single request both
the gate and the header reuse (no second whoami, no stale bar)."""
js = _text(DOCUMENT_JS)
assert 'from "./token-gate.js"' in js
gate_i = js.find('mountGate(document.getElementById("main")')
assert gate_i != -1
boot = js[gate_i : gate_i + 400]
assert "load()" in boot, "onAuthed runs the content load (signed-in role only)"
# The header boots AFTER the gate settles (the .then) — not before
# it, not inside onAuthed: one settled whoami for gate + header.
load_i = boot.find("load()")
then_i = boot.find(".then(")
assert -1 < load_i < then_i, ("onAuthed (load) comes before the header .then")
assert "initSharedHeader()" in boot[then_i:], (
"the header must boot on the settled whoami, after the gate"
)
# The bare boot call is gone — the ONLY load(); statement in the
# file is the one inside the gate's onAuthed callback.
assert js.count("load();") == 1, (
"the un-gated load() call must be gone (onAuthed is the only caller)"
)
# The whoami single-request contract survives: no direct whoami
# fetch in the viewer script (header.js's cached promise).
assert 'fetch("/api/whoami")' not in js
+174
View File
@@ -0,0 +1,174 @@
"""Unit: the API-token service (phase 79, task 02).
Covers ``app.core.tokens`` — the create/lookup/revoke service behind the
admin API (task 02's endpoints) and the future token-auth login (task
03):
* ``generate_token`` — the ``bor_`` + 32-hex shape, two calls differ;
* ``hash_token`` — deterministic 64-hex digest of the FULL string (a
stripped prefix can never collide);
* ``create_token`` / ``find_active_by_token`` — the round-trip (the
plaintext resolves to its active row) and the GENERIC-MISS contract:
revoked, unknown, empty, short and wrong-prefix candidates all return
the same ``None`` (hash matches no row — there is no "almost" path,
which is what makes the task-03 one-generic-401 safe);
* ``revoke`` — stamps ``revoked_at`` once (idempotent re-call keeps the
original stamp; False only for a missing id);
* ``mark_used`` — bumps ``last_used_at``.
House DB-test pattern (the ``test_sources_meta`` precedent): the service
is a thin session wrapper whose contract (server-default ``created_at``,
the unique hash index) only holds against a real database — runs against
the local compose Postgres, skips with clear instructions when the stack
is not up. The service flushes, never commits: the tests commit, as the
endpoints do.
"""
from __future__ import annotations
import re
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core import tokens as tok
from app.models import ApiToken
TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$")
@pytest.fixture(autouse=True)
def clean_tokens(db: Session) -> Iterator[None]:
"""api_tokens is global state: reset around every test."""
db.execute(text("TRUNCATE api_tokens"))
db.commit()
yield
db.execute(text("TRUNCATE api_tokens"))
db.commit()
def _create_and_commit(db: Session, label: str = "alice") -> tuple[ApiToken, str]:
"""Service create + the endpoint's commit/refresh (the house split)."""
row, plaintext = tok.create_token(db, label)
db.commit()
db.refresh(row)
return row, plaintext
def test_generate_token_shape_and_uniqueness() -> None:
"""``bor_`` + exactly 32 lowercase hex chars (128-bit), no reuse."""
a = tok.generate_token()
b = tok.generate_token()
assert TOKEN_SHAPE.fullmatch(a), a
assert TOKEN_SHAPE.fullmatch(b), b
assert a != b
def test_hash_token_deterministic_64_hex_full_string() -> None:
"""Same token → same digest; 64 hex chars; the FULL string is hashed
(hashing ``bor_X`` ≠ hashing ``X`` — a stripped prefix can never
collide with another token's hash)."""
plain = tok.generate_token()
h1 = tok.hash_token(plain)
h2 = tok.hash_token(plain)
assert h1 == h2
assert re.fullmatch(r"[0-9a-f]{64}", h1), h1
assert tok.hash_token(plain.removeprefix("bor_")) != h1
def test_create_and_find_round_trip(db: Session) -> None:
"""create → the row carries ONLY the hash; the plaintext resolves
back to the same active row (the task-03 login path)."""
row, plaintext = _create_and_commit(db, " alice ")
# The service strips the label; the row only ever carries the hash.
assert row.label == "alice"
assert row.token_hash == tok.hash_token(plaintext)
assert row.token_hash != plaintext
assert row.last_used_at is None
assert row.revoked_at is None
hit = tok.find_active_by_token(db, plaintext)
assert hit is not None
assert hit.id == row.id
assert hit.label == "alice"
@pytest.mark.parametrize(
("candidate", "why"),
[
("", "empty string"),
("bor_ab", "short string"),
("bor_" + "f" * 31, "31 hex chars (one short)"),
(
"zzz_" + "0" * 32,
"wrong prefix",
),
],
ids=["empty", "short", "31-hex", "wrong-prefix"],
)
def test_find_active_by_token_generic_miss(db: Session, candidate: str, why: str) -> None:
"""Every malformed/unknown candidate is the SAME miss — the hash
simply matches no row (no "almost" path, no per-shape error)."""
_, plaintext = _create_and_commit(db) # the table is NOT empty
assert tok.find_active_by_token(db, candidate) is None, why
# …while the stored token still resolves (the miss is specific).
assert tok.find_active_by_token(db, plaintext) is not None
def test_find_active_by_token_unknown_well_formed(db: Session) -> None:
"""A well-formed token that was never stored (or belongs to another
admin) misses — no enumeration surface beyond the unique-index hit."""
unknown = tok.generate_token() # never persisted
assert tok.find_active_by_token(db, unknown) is None
def test_find_active_by_token_revoked_misses(db: Session) -> None:
"""A revoked row never resolves: the hash still matches the row, but
``revoked_at IS NULL`` is part of the contract (A4 — dead is dead,
enforced immediately)."""
row, plaintext = _create_and_commit(db)
assert tok.revoke(db, row.id) is True
db.commit()
assert tok.find_active_by_token(db, plaintext) is None
def test_revoke_stamps_once_and_false_only_for_missing(db: Session) -> None:
"""First revoke stamps ``revoked_at`` (True); the second call is
idempotent (True, the ORIGINAL stamp kept — no re-stamp); False only
when the row does not exist."""
row, _ = _create_and_commit(db)
assert tok.revoke(db, row.id) is True
db.commit()
db.refresh(row)
first_stamp = row.revoked_at
assert first_stamp is not None
# Idempotent: True again, and the first stamp survives.
assert tok.revoke(db, row.id) is True
db.commit()
db.refresh(row)
assert row.revoked_at == first_stamp
# A missing id is the only False.
assert tok.revoke(db, uuid.uuid4()) is False
def test_mark_used_stamps_last_used_at(db: Session) -> None:
"""NULL until first use, then "now" (UTC, tz-aware)."""
row, _ = _create_and_commit(db)
assert row.last_used_at is None
before = datetime.now(UTC)
tok.mark_used(row)
after = datetime.now(UTC)
assert row.last_used_at is not None
assert row.last_used_at.tzinfo is not None
assert before - timedelta(seconds=1) <= row.last_used_at <= after + timedelta(
seconds=1
)
db.commit()