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