phase: 101_tokens_page_overhaul
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 13s

All gates verified green — no defects found, nothing to fix. Final report:

## Phase 101 — final verification pass (all tasks already complete; verified, not re-implemented)

**Verified against the working tree** (task files 01–04 present in `complete/`):
- Active/revoked table split (Status column gone, section hidden while 0 revoked, per-table search with query state surviving re-renders) in `frontend/index.html` + `tokens.js`
- `generateToken()` refuses blank/whitespace names (`Give the token a name first.`, no request); `|| "token"` fallback deleted
- Atomic rotation: `regenerate_token` service + `POST /api/tokens/{id}/regenerate` (201 new-plaintext-once / 404 / 409 / router-wide 403) + `TokenListItem.revoked_at` (D5)
- Regenerate two-step confirm UI + CSS (`.token-regenerate`, neutral hover, no new hue); A4 pins intact

**Test / lint / coverage results:**
- `uv run pytest` → **2065 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed** (isolation, DB up)
- Regression, each in isolation: `test_api_tokens.py` **9 passed**, `test_admin_auth.py` **6 passed**, `test_shared_header.py` **6 passed**, `test_theme_semantic_completion.py` **8 passed** (its revoked-pill pin was correctly re-scoped to the revoked table in this phase)

**Completion criteria:** 1 ✓ split+search (E2E 1–2) · 2 ✓ required name (E2E 3 + source pin) · 3 ✓ rotation end-to-end, old token refused at gate (E2E 4 + API 404/409 pinned) · 4 ✓ A4 holds (list carries no plaintext/hashes) · 5 ✓ suite/coverage/lint green · 6 ✓ E2E + regressions green in isolation · 7 commit left to the harness per executor rules (all changes uncommitted in the working tree)

**Deviations:** none. Next pending phase: `98_sync_summary_visibility`.
This commit is contained in:
2026-09-12 15:16:02 -04:00
parent 58e9d94cff
commit 4dbac1660a
32 changed files with 2321 additions and 150 deletions
+10 -4
View File
@@ -367,12 +367,12 @@ def test_admin_generates_token_in_ui(page: Page, app_url: str, db_ready: None) -
"Token created — copy it now; it won't be shown again."
)
# The table shows the Active row: the em-dash status marker, no
# The table shows the Active row (phase 101 D1: the Status column
# is gone — the four-column active table IS the status): no
# Revoked pill, a Revoke action.
row = page.locator("#tokens-tbody tr", has_text="e2e-alice")
expect(row).to_have_count(1)
expect(row.locator("td.tokens-label-cell")).to_have_text("e2e-alice")
expect(row.locator("td").nth(3)).to_have_text("—") # Active = the plain em-dash
expect(row.locator(".stale-pill")).to_have_count(0)
expect(row.locator("button.token-revoke")).to_have_count(1)
@@ -603,8 +603,14 @@ def test_revocation_closes_the_door(
row.locator("button.token-revoke").click()
expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click()
revoked = page.locator("#tokens-tbody tr", has_text="e2e-revoke")
expect(revoked.locator(".stale-pill")).to_have_text("Revoked", timeout=15_000)
# Phase 101 (D1): the row LEAVES the active table and lands in
# the revoked table below with its Revoked date cell (the
# em-dash / .stale-pill column is gone from both tables — the
# table's position IS the status).
revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-revoke")
expect(revoked).to_have_count(1, timeout=15_000)
expect(revoked.locator(".stale-pill")).to_have_count(0)
expect(revoked.locator("td.tokens-date-cell").last).not_to_be_empty()
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-revoke".')
# Enforcement is IMMEDIATE on the holder's next request (the
+22 -18
View File
@@ -60,9 +60,12 @@ computed-style assertion):
out-of-generation saved chat (the phase-53 seed pattern) renders
the "Stale" pill in History — gray err-ink on gray err-bg with the
gray err-line border, text intact.
5. ``test_revoked_pill_gray_labeled`` — screenshot 6: a token
generated + revoked THROUGH the Tokens UI renders the "Revoked"
pill — the same gray err-family computed colors, text intact.
5. ``test_revoked_table_state_labeled`` — screenshot 6: a token
generated + revoked THROUGH the Tokens UI lands in the SEPARATE
revoked table (phase 101 D1 re-scoped this pin: the "Revoked"
pill is gone — the table's position IS the status) with its
Revoked date cell, text intact (B5: text + position, never color
alone).
6. ``test_local_badge_gray_labeled`` — screenshot 3: a registered
local-directory source renders the "Local" badge on Git sources —
gray ok-ink on gray ok-bg, text intact.
@@ -695,12 +698,14 @@ def test_stale_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> No
# ---------------------------------------------------------------------------
# 5. Tokens: the "Revoked" pill (screenshot 6) — gray err family, text
# intact (generated + revoked THROUGH the UI two-step)
# 5. Tokens: the revoked-table state (screenshot 6) — phase 101 D1
# replaced the "Revoked" pill: the row's very presence in the
# revoked table below + its Revoked date cell (text, never color
# alone — B5; generated + revoked THROUGH the UI two-step)
# ---------------------------------------------------------------------------
def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> None:
def test_revoked_table_state_labeled(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_seed_theme(app_url, _cookies(page))
@@ -714,7 +719,7 @@ def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) ->
page.click("#token-generate")
row = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
expect(row).to_have_count(1, timeout=15_000)
expect(row.locator(".stale-pill")).to_have_count(0) # Active: the em-dash
expect(row.locator(".stale-pill")).to_have_count(0) # Phase 101: no status column
# Revoke through the UI two-step (Revoke → Yes — the inline
# confirm, no native dialog).
@@ -722,17 +727,16 @@ def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) ->
expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click()
# Screenshot 6: the red "Revoked" pill — now gray err-ink on gray
# err-bg with the gray err-line border, text intact.
revoked = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
pill = revoked.locator(".stale-pill")
expect(pill).to_have_count(1)
expect(pill).to_have_text("Revoked", timeout=15_000)
_assert_gray(pill, "color", GRAY["err_ink"], label="revoked pill text")
_assert_gray(pill, "backgroundColor", GRAY["err_bg"], label="revoked pill bg")
_assert_gray(
pill, "borderTopColor", GRAY["err_line"], label="revoked pill border"
)
# Screenshot 6, re-scoped by phase 101 (D1): the "Revoked" pill is
# gone — the table IS the status. The row LEAVES the active table
# and lands in the revoked table below with its Revoked date cell
# (text + the table's position, never color alone — B5).
active = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
expect(active).to_have_count(0)
revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-theme93")
expect(revoked).to_have_count(1, timeout=15_000)
expect(revoked.locator(".stale-pill")).to_have_count(0)
expect(revoked.locator("td.tokens-date-cell").last).not_to_be_empty()
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-theme93".')
+407
View File
@@ -0,0 +1,407 @@
"""Phase 101 E2E (Playwright): the Tokens page overhaul — the owner's
sentence, pinned in a real browser.
Owner request (chat, 2026-09-12): "The tokens page should move revoked
tokens to a separate table below the active ones. Generating a token
should not be possible without giving it a name. Both active and
revoked token tables should be searchable. I should be able to
regenerate active tokens with the click of a button."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_tokens_page.py -v --no-cov
Test → claim mapping (every clause of the owner's sentence is pinned):
1. ``test_revoked_tokens_move_to_their_own_table`` — "move revoked
tokens to a separate table below the active ones": two UI-created
tokens, one revoked through the two-step — the active table keeps
ONLY the live row (its thead is exactly four columns — the Status
column is gone, D1), and the revoked section (visible sub-heading +
the row's Revoked date cell — non-empty, later than the created
date, NO action buttons) appears BELOW it. The section ships hidden
while nothing is revoked (an empty table is noise).
2. ``test_both_tables_are_searchable`` — "both … tables should be
searchable": each table's own search input live-filters its rows by
case-insensitive label substring (no fetch) — the no-match row
reads ``No tokens match "zzz".`` and clears with the input — and
the query SURVIVES a re-show: leave via the Tuning nav, come back,
the re-render re-applies the same filter (the phase-77 contract,
D4).
3. ``test_a_token_cannot_be_generated_without_a_name`` — "generating a
token should not be possible without giving it a name": a blank or
whitespace-only name announces ``Give the token a name first.``,
re-focuses the name input, keeps the once-block hidden, and sends
NO request (the admin's own ``GET /api/tokens`` shows no new row —
D3; the ``|| "token"`` fallback is gone).
4. ``test_regenerate_rotates_the_token`` — "regenerate active tokens
with the click of a button": one Regenerate click opens the house
two-step confirm (focus on Yes); Yes rotates atomically — the
once-block re-appears with a NEW plaintext (``bor_`` + 32 hex, ≠
the original), the live region carries the D2 line, the active
table holds exactly ONE ``e2e-rot`` row (the successor — newer
created date), and the revoked table holds the ORIGINAL (same
label, Revoked date set). The rotation is real: a FRESH context
signs in with the NEW token through the real gate, and the
ORIGINAL token is REFUSED at the gate in another fresh context
(``#auth-gate-error`` role=alert — the rotation killed it
immediately, the phase-79 revocation semantics).
DB isolation: the shared e2e Postgres keeps ``api_tokens`` rows across
suites (``test_api_tokens.py`` is the sibling suite that issues
tokens with ``e2e-`` labels — its autouse cleanup deletes them too).
An autouse fixture deletes the ``e2e-``-labeled rows before each test
(never a TRUNCATE — the shared DB may hold the owner's real tokens).
Every scenario runs in its OWN fresh browser context for token users —
no cached token (localStorage) or session cookie leaks between tests.
"""
from __future__ import annotations
import re
from datetime import datetime
import pytest
from playwright.sync_api import Browser, BrowserContext, Page, expect
from sqlalchemy import text
from app.db import SessionLocal
from e2e.auth_helpers import login, login_with_token
#: The plaintext token's shape (owner-locked A4): prefix + 32 hex.
TOKEN_RE = re.compile(r"bor_[0-9a-f]{32}")
def _cleanup_e2e_tokens() -> None:
"""Delete this suite's issued tokens (deterministic re-runs).
Label-scoped on ``e2e-`` — never a TRUNCATE: the shared e2e DB is
also the dev DB and may hold the owner's real tokens.
"""
with SessionLocal() as db:
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
db.commit()
@pytest.fixture(autouse=True)
def _e2e_tokens_clean(db_ready: None) -> None:
"""Start every test from the same token-empty state (this file and
test_api_tokens.py are the suites that issue tokens)."""
_cleanup_e2e_tokens()
def _go_tokens(page: Page, app_url: str) -> None:
"""Sign in as the admin (the real form login) straight onto the
Tokens view — the create row is revealed for the admin."""
login(page, app_url, next="/tokens.html")
expect(page.locator("#view-tokens")).to_be_visible()
expect(page.locator("#tokens-gate")).to_be_hidden() # admin: no sign-in gate
expect(page.locator("#token-create")).to_be_visible(timeout=15_000)
def _create_via_ui(page: Page, label: str) -> str:
"""Issue one token through the UI create row (name → Generate).
Returns the plaintext from the shown-once block (the A4 contract:
it exists only in the field's value) and pins the new row in the
ACTIVE table.
"""
page.fill("#token-label", label)
page.click("#token-generate")
expect(page.locator("#token-once")).to_be_visible(timeout=15_000)
token = page.input_value("#token-once-value")
assert TOKEN_RE.fullmatch(token), f"bad token shape: {token!r}"
row = page.locator("#tokens-tbody tr", has_text=label)
expect(row).to_have_count(1)
expect(page.locator("#tokens-status")).to_have_text(
"Token created — copy it now; it won't be shown again."
)
return token
def _revoke_via_ui(page: Page, label: str) -> None:
"""Revoke one active row through the house two-step (Revoke →
Yes) and pin the relocation: gone from the active table, present
in the revoked table below."""
row = page.locator("#tokens-tbody tr", has_text=label)
expect(row).to_have_count(1, timeout=15_000)
row.locator("button.token-revoke").click()
expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click()
expect(page.locator("#tokens-tbody tr", has_text=label)).to_have_count(0)
expect(page.locator("#tokens-revoked-tbody tr", has_text=label)).to_have_count(1)
expect(page.locator("#tokens-status")).to_have_text(f'Revoked "{label}".')
def _whoami(page: Page) -> dict:
"""The page's own ``/api/whoami`` read (the context's cookies)."""
return page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
# ---------------------------------------------------------------------------
# 1. Revoked tokens live in their own table below the active ones
# (D1: the table's position IS the status)
# ---------------------------------------------------------------------------
def test_revoked_tokens_move_to_their_own_table(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
_go_tokens(page, app_url)
# The revoked section SHIPS hidden while nothing is revoked
# (an empty table is noise) — and the active table is four
# columns: Label | Created | Last used | Actions (the Status
# column is gone — the all-active table needs no status).
expect(page.locator("#tokens-revoked-heading")).to_be_hidden()
expect(page.locator("#token-search-revoked")).to_be_hidden()
expect(page.locator("#tokens-revoked-wrap")).to_be_hidden()
expect(page.locator("#tokens-table thead th")).to_have_count(4)
expect(page.locator("#tokens-table thead")).not_to_have_text("Status")
_create_via_ui(page, "e2e-act")
_create_via_ui(page, "e2e-rev")
# Revoke e2e-rev through the two-step — the row LEAVES the active
# table and lands in the revoked table below.
row = page.locator("#tokens-tbody tr", has_text="e2e-rev")
expect(row).to_have_count(1, timeout=15_000)
row.locator("button.token-revoke").click()
expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click()
# The active table shows ONLY e2e-act…
expect(page.locator("#tokens-tbody tr", has_text="e2e-act")).to_have_count(1)
expect(page.locator("#tokens-tbody tr", has_text="e2e-rev")).to_have_count(0)
# …and the revoked section is visible BELOW the active table: its
# heading, its own search input, and the row with its Revoked
# date cell (non-empty, strictly after the created date) and NO
# action buttons (a dead token has nothing left to act on).
expect(page.locator("#tokens-revoked-heading")).to_be_visible(timeout=15_000)
expect(page.locator("#tokens-revoked-heading")).to_have_text("Revoked tokens")
expect(page.locator("#token-search-revoked")).to_be_visible()
expect(page.locator("#tokens-revoked-wrap")).to_be_visible()
revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-rev")
expect(revoked).to_have_count(1)
# (tokens-date-cell covers Created / Last used / Revoked — the label
# cell has its own class — so Created is nth(0), Revoked the last.)
created_iso = revoked.locator("td.tokens-date-cell").nth(0).get_attribute("title")
revoked_cell = revoked.locator("td.tokens-date-cell").last
expect(revoked_cell).not_to_be_empty()
revoked_iso = revoked_cell.get_attribute("title")
assert created_iso and revoked_iso, (
f"missing full-ISO hover dates: {created_iso} / {revoked_iso}"
)
assert datetime.fromisoformat(revoked_iso) > datetime.fromisoformat(created_iso), (
"the Revoked date must be strictly after the created date"
)
expect(revoked.locator("button")).to_have_count(0)
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-rev".')
# ---------------------------------------------------------------------------
# 2. Both tables are searchable (D4: client-side, per-table, the
# queries survive re-renders / re-shows)
# ---------------------------------------------------------------------------
def test_both_tables_are_searchable(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
_go_tokens(page, app_url)
_create_via_ui(page, "e2e-a1")
_create_via_ui(page, "e2e-a2")
_create_via_ui(page, "e2e-srchrev")
_revoke_via_ui(page, "e2e-srchrev")
# Both search inputs are revealed (the active one with the create
# row, the revoked one with the revoked section).
expect(page.locator("#token-search-active")).to_be_visible()
expect(page.locator("#token-search-revoked")).to_be_visible()
# Active table: type e2e-a1 → only that row visible…
page.fill("#token-search-active", "e2e-a1")
expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).to_be_visible()
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_be_hidden()
# …type zzz → the no-match row, the distinct-from-empty-state copy…
page.fill("#token-search-active", "zzz")
expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).not_to_be_visible()
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_have_text('No tokens match "zzz".')
# …clear the input → both rows back, no-match hidden.
page.fill("#token-search-active", "")
expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).to_be_visible()
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_be_hidden()
# The same three-beat on the revoked search.
page.fill("#token-search-revoked", "zzz")
expect(page.locator("#tokens-revoked-tbody tr", has_text="e2e-srchrev")).not_to_be_visible()
expect(page.locator("#tokens-revoked-no-match-row")).to_be_visible()
expect(page.locator("#tokens-revoked-no-match-row")).to_have_text('No tokens match "zzz".')
page.fill("#token-search-revoked", "e2e-srchrev")
expect(page.locator("#tokens-revoked-tbody tr", has_text="e2e-srchrev")).to_be_visible()
expect(page.locator("#tokens-revoked-no-match-row")).to_be_hidden()
page.fill("#token-search-revoked", "")
expect(page.locator("#tokens-revoked-tbody tr", has_text="e2e-srchrev")).to_be_visible()
# The query SURVIVES a re-show: type, leave via the Tuning nav,
# come back — the phase-77 refresh re-renders the rows, and the
# filter is still applied (the visible set is unchanged).
page.fill("#token-search-active", "e2e-a1")
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible()
page.click("#nav-tuning")
expect(page.locator("#view-tuning")).to_be_visible()
page.click("#nav-tokens")
expect(page.locator("#view-tokens")).to_be_visible(timeout=15_000)
expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).to_be_visible()
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_be_hidden()
# ---------------------------------------------------------------------------
# 3. A nameless token cannot be generated (D3: the live-region line,
# no request, no row)
# ---------------------------------------------------------------------------
def test_a_token_cannot_be_generated_without_a_name(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_go_tokens(page, app_url)
def _list_count() -> int:
"""The admin's own GET /api/tokens (page.request shares the
context's signed session cookie): the server-side row count."""
r = page.request.get(app_url + "/api/tokens")
assert r.status == 200, r.text
return len(r.json()["tokens"])
# Deterministic start (the autouse cleanup): the active table is
# in its empty state and the server holds no tokens at all.
before = _list_count()
assert before == 0
expect(page.locator("#tokens-empty-row")).to_be_visible()
# BLANK name: the refusal line, the once-block stays hidden, and
# the request NEVER happened (the server-side count is
# unchanged — the old `|| "token"` fallback is gone).
expect(page.locator("#token-label")).to_have_value("")
page.click("#token-generate")
expect(page.locator("#tokens-status")).to_have_text("Give the token a name first.")
expect(page.locator("#token-once")).to_be_hidden()
assert _list_count() == before
expect(page.locator("#tokens-empty-row")).to_be_visible()
# WHITESPACE-only name: the same refusal (trimmed client-side).
page.fill("#token-label", " ")
page.click("#token-generate")
expect(page.locator("#tokens-status")).to_have_text("Give the token a name first.")
expect(page.locator("#token-once")).to_be_hidden()
assert _list_count() == before
expect(page.locator("#tokens-empty-row")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. Regenerate rotates the credential end to end (D2: the atomic
# rotation — the new token signs in, the old one is refused)
# ---------------------------------------------------------------------------
def test_regenerate_rotates_the_token(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_go_tokens(page, app_url)
original = _create_via_ui(page, "e2e-rot")
row = page.locator("#tokens-tbody tr", has_text="e2e-rot")
expect(row).to_have_count(1)
# (Created is the first date cell — the label cell has its own class.)
old_created_iso = row.locator("td.tokens-date-cell").nth(0).get_attribute("title")
assert old_created_iso, "the created cell must carry the full ISO hover date"
# ONE click opens the house two-step confirm (focus on Yes) —
# the rotation is a primary lifecycle action, Regenerate sits
# before Revoke in the Actions cell.
row.locator("button.token-regenerate").first.click()
expect(row.locator(".history-confirm-text")).to_have_text(
"Regenerate? The current token is revoked."
)
yes = row.locator(".history-confirm-yes")
expect(yes).to_be_visible()
expect(yes).to_be_focused()
yes.click()
# Wait for the full 201 sequence (load → once-block reveal →
# announce) on the live-region line: while the request is in
# flight the once-block still holds the ORIGINAL plaintext from
# the create — the D2 line lands LAST, so it is the sync point.
expect(page.locator("#tokens-status")).to_have_text(
"Regenerated \"e2e-rot\" — copy the new token now; it won't be shown again.",
timeout=15_000,
)
# The once-block re-appeared with a NEW plaintext (≠ the original,
# well-formed) — the same shown-once block, the A4 value-only
# contract.
expect(page.locator("#token-once")).to_be_visible()
new_token = page.input_value("#token-once-value")
assert TOKEN_RE.fullmatch(new_token), f"bad new token shape: {new_token!r}"
assert new_token != original, "the rotation must mint a fresh credential"
# The active table holds exactly ONE e2e-rot row — the successor
# (its created date is strictly newer than the original's)…
new_row = page.locator("#tokens-tbody tr", has_text="e2e-rot")
expect(new_row).to_have_count(1)
new_created_iso = new_row.locator("td.tokens-date-cell").nth(0).get_attribute("title")
assert new_created_iso, "the successor's created cell must carry the full ISO hover date"
assert datetime.fromisoformat(new_created_iso) > datetime.fromisoformat(old_created_iso), (
"the successor must be a NEW row (a newer created date)"
)
# …and the REVOKED table now holds the ORIGINAL (same label — the
# hand-out name persists — its created date is the old one, its
# Revoked date is set).
expect(page.locator("#tokens-revoked-heading")).to_be_visible()
rev_row = page.locator("#tokens-revoked-tbody tr", has_text="e2e-rot")
expect(rev_row).to_have_count(1)
assert (
rev_row.locator("td.tokens-date-cell").nth(0).get_attribute("title")
== old_created_iso
), "the revoked row must be the ORIGINAL (same created date)"
rev_iso = rev_row.locator("td.tokens-date-cell").last.get_attribute("title")
assert rev_iso, "the original's Revoked date must be set"
assert datetime.fromisoformat(rev_iso) >= datetime.fromisoformat(old_created_iso)
# A FRESH token-user context signs in with the NEW token through
# the real gate (the gate closes, the app is interactive) — the
# rotation minted a working credential.
ok_ctx: BrowserContext | None = None
try:
ok_ctx = browser.new_context()
ok = ok_ctx.new_page()
ok.set_default_timeout(30_000)
login_with_token(ok, app_url, new_token)
assert _whoami(ok) == {"authenticated": True, "role": "user"}
finally:
if ok_ctx is not None:
ok_ctx.close()
# …and the ORIGINAL token is REFUSED in another fresh context
# (the rotation killed it immediately — the phase-79
# revocation semantics: the role=alert line, the gate stays, the
# visitor is anonymous).
bad_ctx: BrowserContext | None = None
try:
bad_ctx = browser.new_context()
bad = bad_ctx.new_page()
bad.set_default_timeout(30_000)
login_with_token(bad, app_url, original, expect_error=True)
expect(bad.locator("#auth-gate-error")).to_have_attribute("role", "alert")
assert _whoami(bad) == {"authenticated": False, "role": "anonymous"}
finally:
if bad_ctx is not None:
bad_ctx.close()
+161 -9
View File
@@ -4,9 +4,9 @@ The admin surface for issued access tokens, driven through the real app
(TestClient keeps the cookie jar — the house ``test_auth_api`` admin-login
pattern):
* anonymous → 403 ``admin only`` on all three endpoints (router-level
``require_admin``; a token USER, once task 03 lands, is 403 here too —
pinned in task 03's matrix);
* anonymous → 403 ``admin only`` on every route (router-level
``require_admin``; a token USER is 403 on the admin surface too —
pinned in task 03's matrix and, for the regenerate route, here);
* create → 201 with the plaintext ``token`` (the ONE wire moment it
exists, A4) — and ``GET /tokens`` NEVER exposes it: no ``token`` key,
no ``token_hash`` key, and the hash string itself absent from the
@@ -14,9 +14,16 @@ pattern):
* labels are display-only and NOT unique (two tokens, one label);
* blank/over-long labels → 422 (the house ``ValueError`` pattern);
* revoke → 204, idempotent (re-revoke 204, original stamp kept), the
list shows ``revoked: true`` and the row keeps its ``last_used_at``;
unknown id → 404 ``token not found``;
* the list is newest-first (``created_at desc``).
list shows ``revoked: true`` + the ``revoked_at`` wire timestamp and
the row keeps its ``last_used_at``; unknown id → 404 ``token not
found``;
* regenerate (phase 101, D2) → 201 with the NEW plaintext (same label,
one wire moment, A4) — the old row is revoked in the SAME transaction
(``revoked: true`` + non-null ``revoked_at`` in the follow-up list);
already-revoked id → 409 ``token already revoked``; unknown id → 404
``token not found``;
* the list items carry ``revoked_at`` (null active / ISO-8601 revoked —
D5, wire-additive) and are newest-first (``created_at desc``).
Real Postgres (``podman compose up -d db``); no LLM involved — tokens
are plain rows, so the suite is deterministic without a fake.
@@ -36,7 +43,9 @@ from fastapi.testclient import TestClient
from sqlalchemy import select, text, update
from sqlalchemy.orm import Session
from app.main import app as fastapi_app
from app.models import ApiToken
from tests.conftest import ADMIN_PASSWORD
TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$")
#: A fixed "already used" stamp — the row keeps it through revocation
@@ -62,9 +71,21 @@ def _create(admin_client: TestClient, label: str = "alice") -> dict:
return r.json()
def test_anonymous_403_on_all_three(client: TestClient) -> None:
def _admin_client() -> TestClient:
"""A SEPARATE client signed in as the admin — for tests that need an
admin and a token user at the same time (the shared ``client``
fixture is the token holder in those; the ``test_auth_api``
pattern)."""
admin = TestClient(fastapi_app)
r = admin.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
return admin
def test_anonymous_403_on_all_routes(client: TestClient) -> None:
"""Router-level ``require_admin``: every route is 403 for the
unsigned-in caller (one fixed detail — no enumeration)."""
unsigned-in caller (one fixed detail — no enumeration), including
the phase-101 regenerate route."""
r = client.post("/api/tokens", json={"label": "anon"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
@@ -74,6 +95,9 @@ def test_anonymous_403_on_all_three(client: TestClient) -> None:
r = client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.post(f"/api/tokens/{uuid.uuid4()}/regenerate")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
def test_admin_create_returns_plaintext_exactly_once(
@@ -103,11 +127,20 @@ def test_admin_create_returns_plaintext_exactly_once(
items = r.json()["tokens"]
assert len(items) == 1
item = items[0]
assert set(item) == {"id", "label", "created_at", "last_used_at", "revoked"}
# Phase 101, D5: the wire gains ``revoked_at`` — null while active.
assert set(item) == {
"id",
"label",
"created_at",
"last_used_at",
"revoked",
"revoked_at",
}
assert item["id"] == body["id"]
assert item["label"] == "alice"
assert item["last_used_at"] is None # not used yet
assert item["revoked"] is False
assert item["revoked_at"] is None
serialized = r.text
assert body["token"] not in serialized
assert row.token_hash not in serialized
@@ -163,6 +196,11 @@ def test_revoke_204_idempotent_and_preserves_last_used(
).scalar_one().revoked_at
assert original_stamp is not None
# Phase 101, D5: the revoked row carries the revocation timestamp on
# the wire — ISO-8601, the SAME instant as the DB stamp (the
# revoked table renders it).
assert datetime.fromisoformat(item["revoked_at"]) == original_stamp
# Re-revoke: still 204, the original stamp survives (no re-stamp).
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
db.expire_all()
@@ -173,6 +211,120 @@ def test_revoke_204_idempotent_and_preserves_last_used(
) == USED_STAMP
# ---------- phase 101, task 01: the atomic rotation ----------
def test_regenerate_201_rotates_atomically(admin_client: TestClient, db: Session) -> None:
"""201 carries the NEW plaintext (same label, one wire moment, A4);
the old row is revoked in the SAME transaction — the follow-up list
shows it ``revoked: true`` with a non-null ISO ``revoked_at``, while
the new row is active (``revoked_at`` null), same label, with a
newer ``created_at``."""
body = _create(admin_client, "dave")
token_id = uuid.UUID(body["id"])
original = body["token"]
# A deterministic created_at gap (transaction timestamps can be
# coarser than the create gap — the test_list_is_newest_first
# pattern), so "newer" is a strict comparison.
old_created = datetime.now(UTC) - timedelta(hours=1)
db.execute(
update(ApiToken)
.where(ApiToken.id == token_id)
.values(created_at=old_created)
)
db.commit()
r = admin_client.post(f"/api/tokens/{token_id}/regenerate")
assert r.status_code == 201, r.text
rotated = r.json()
# The 201 shape is TokenCreated — the NEW token's one plaintext
# moment (the successor row, same label, a fresh credential).
assert set(rotated) == {"id", "label", "token", "created_at"}
assert rotated["id"] != body["id"]
assert rotated["label"] == "dave"
assert TOKEN_SHAPE.fullmatch(rotated["token"]), rotated["token"]
assert rotated["token"] != original
assert datetime.fromisoformat(rotated["created_at"]) > old_created
# The successor row stores the sha256 of the NEW plaintext only.
row = db.execute(
select(ApiToken).where(ApiToken.id == uuid.UUID(rotated["id"]))
).scalar_one()
assert row.token_hash == hashlib.sha256(rotated["token"].encode("utf-8")).hexdigest()
assert row.revoked_at is None
# The follow-up list: newest first, the old row revoked (D5 wire
# timestamp), the new row active.
items = admin_client.get("/api/tokens").json()["tokens"]
assert [i["id"] for i in items] == [rotated["id"], body["id"]]
by_id = {i["id"]: i for i in items}
old_item, new_item = by_id[body["id"]], by_id[rotated["id"]]
assert old_item["revoked"] is True
assert old_item["revoked_at"] is not None
datetime.fromisoformat(old_item["revoked_at"]) # ISO-8601
assert new_item["revoked"] is False
assert new_item["revoked_at"] is None
assert old_item["label"] == new_item["label"] == "dave"
# A4: the list never carries either plaintext or any hash string.
serialized = admin_client.get("/api/tokens").text
assert original not in serialized
assert rotated["token"] not in serialized
def test_regenerate_unknown_id_404(admin_client: TestClient) -> None:
"""One fixed message for every unknown id (the revoke endpoint's)."""
r = admin_client.post(f"/api/tokens/{uuid.uuid4()}/regenerate")
assert r.status_code == 404
assert r.json() == {"detail": "token not found"}
def test_regenerate_already_revoked_409(admin_client: TestClient, db: Session) -> None:
"""A dead token cannot be rotated: 409 ``token already revoked``,
and the row is untouched — still ONE row, the original stamp, no
successor created."""
body = _create(admin_client, "erin")
token_id = uuid.UUID(body["id"])
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
db.expire_all()
original_stamp = db.execute(
select(ApiToken).where(ApiToken.id == token_id)
).scalar_one().revoked_at
assert original_stamp is not None
r = admin_client.post(f"/api/tokens/{token_id}/regenerate")
assert r.status_code == 409
assert r.json() == {"detail": "token already revoked"}
db.expire_all()
assert db.execute(text("SELECT count(*) FROM api_tokens")).scalar_one() == 1
row = db.execute(select(ApiToken).where(ApiToken.id == token_id)).scalar_one()
assert row.revoked_at == original_stamp
def test_regenerate_403_for_token_user(client: TestClient) -> None:
"""Router-wide gate: a token USER (signed in via /api/token-auth)
cannot rotate a token — 403 ``admin only``, and the rotation did not
happen (the shared ``client`` is the token holder; a separate client
is the admin — the ``test_auth_api`` pattern)."""
admin = _admin_client()
body = _create(admin, "frank")
assert client.post("/api/token-auth", json={"token": body["token"]}).status_code == 204
assert client.get("/api/whoami").json() == {"authenticated": True, "role": "user"}
r = client.post(f"/api/tokens/{body['id']}/regenerate")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
# The token is untouched: one active row, the admin's list agrees.
items = admin.get("/api/tokens").json()["tokens"]
assert len(items) == 1
assert items[0]["id"] == body["id"]
assert items[0]["revoked"] is False
def test_revoke_unknown_id_404(admin_client: TestClient) -> None:
"""One fixed message for every unknown id (no enumeration)."""
r = admin_client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
+446
View File
@@ -902,6 +902,452 @@ def test_tokens_view_scaffold_in_the_shell() -> None:
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
# ---------- phase 101 task 02: the tokens split (active/revoked
# tables, the per-table search, the required name) ----------
def test_tokens_view_split_scaffold_in_the_shell() -> None:
"""Phase 101 task 02 (D1/D3/D4): the #view-tokens skeleton is the
SPLIT — the active table is FOUR columns (the Status ``<th>`` is
gone, the Actions header stays visually-hidden), its empty row is
``colspan=4``, and a NEW ship-hidden #tokens-no-match-row
(``colspan=4``, empty ``<td>`` — the text is JS-filled) ships in
the same tbody; the per-table search inputs (#token-search-active
between the once-block and the active table wrap, #token-search-
revoked in the revoked section) ship hidden with ``type=search``
+ aria-label + the .token-search class; the NEW revoked section
(heading + search + the four-column table with the Revoked ``<th>``
+ the ship-hidden no-match row) ships hidden and sits AFTER the
active table's wrap; and the create row's input is the REQUIRED
name (aria-label "Token name", placeholder
"e.g. alice — required")."""
html = _html()
view = html.find('<section class="view" id="view-tokens"')
assert view != -1, "the #view-tokens section must be in the shell"
main_end = html.find("</main>", view)
body = html[view:main_end]
# The active table: EXACTLY four columns — and no Status header
# anywhere in the view (D1: the table IS the status).
table_i = body.find('<table class="tokens-table" id="tokens-table">')
assert table_i != -1, "the active #tokens-table must exist"
thead = body[table_i:body.find("</thead>", table_i)]
assert thead.count('<th scope="col">') == 4, (
"the active table is four columns (Label | Created | Last used | Actions)"
)
assert '<th scope="col">Status</th>' not in body, (
"the Status column is gone from BOTH tables (D1)"
)
assert '<th scope="col">Revoked</th>' in body, (
"the revoked table carries the Revoked column"
)
# The active tbody: the empty row (colspan=4) + the NEW no-match
# row (ship-hidden, colspan=4, its <td> text JS-filled).
empty = re.search(r'<tr[^>]*id="tokens-empty-row"[^>]*hidden>.*?</tr>', body, re.S)
assert empty and 'colspan="4"' in empty.group(0), (
"the active empty row is colspan=4"
)
no_match = re.search(r'<tr[^>]*id="tokens-no-match-row"[^>]*hidden>', body)
assert no_match, "the active no-match row ships hidden"
no_match_td = re.search(
r'<tr[^>]*id="tokens-no-match-row"[^>]*hidden>\s*<td colspan="4">\s*</td>\s*</tr>',
body,
)
assert no_match_td, "the no-match row is colspan=4 with an EMPTY <td>"
# The per-table search inputs (D4): type=search, aria-labeled, the
# house .token-search class, BOTH ship hidden.
for sid, label in (
("token-search-active", "Search active tokens"),
("token-search-revoked", "Search revoked tokens"),
):
m = re.search(rf'<input[^>]*id="{sid}"[^>]*>', body, re.S)
assert m, f"missing the #{sid} search input"
tag = m.group(0)
assert 'type="search"' in tag, f"#{sid} must be a search input"
assert f'aria-label="{label}"' in tag, f"#{sid} must be labeled"
assert 'class="token-search"' in tag, f"#{sid} must carry the house class"
assert "hidden" in tag, f"#{sid} ships hidden (anonymous-safe)"
# The create row: the name is REQUIRED (D3) — the new aria-label +
# placeholder.
label_in = re.search(r'<input[^>]*id="token-label"[^>]*>', body, re.S)
assert label_in, "the create row's name input must exist"
assert 'aria-label="Token name"' in label_in.group(0)
assert 'placeholder="e.g. alice — required"' in label_in.group(0)
# The active search sits BETWEEN the once-block and the active
# table's wrap.
assert (
body.find('id="token-once"')
< body.find('id="token-search-active"')
< body.find('id="tokens-table-wrap"')
), "the active search is between the once-block and the table wrap"
# The revoked section (D1): heading + search + wrap — ALL ship
# hidden — and it sits BELOW the active table's wrap.
heading = re.search(
r'<h2[^>]*id="tokens-revoked-heading"[^>]*>Revoked tokens</h2>', body
)
assert heading and "hidden" in heading.group(0), (
"the revoked sub-heading ships hidden with its visible text"
)
assert 'class="tokens-revoked-heading"' in heading.group(0)
wrap = re.search(r'<div[^>]*id="tokens-revoked-wrap"[^>]*>', body)
assert wrap and "hidden" in wrap.group(0), ("the revoked wrap ships hidden")
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
assert 'aria-label="Revoked tokens"' in wrap.group(0)
assert 'id="tokens-revoked-tbody"' in body
assert "Revoked tokens — newest first" in body, "the revoked table caption"
assert re.search(
r'<tr[^>]*id="tokens-revoked-no-match-row"[^>]*hidden>', body
), "the revoked no-match row ships hidden"
assert body.find('id="tokens-table-wrap"') < body.find(
'id="tokens-revoked-heading"'
), "the revoked section sits BELOW the active table's wrap"
def test_tokens_js_split_search_and_required_name() -> None:
"""Phase 101 task 02 (D1/D3/D4) in tokens.js: the module state
carries the persistent per-table queries (initialized ""), the
scoped lookups cover the new ids, ``loadTokens`` SPLITS the
fetched list by ``tok.revoked`` (the server order kept per table),
shows the active empty row iff zero ACTIVE rows, shows the revoked
section (heading + search + wrap) iff ≥1 revoked row via the
``setRevokedSectionVisible`` helper, and RE-APPLIES both filters
after every render (a load never loses the queries); ``makeRow``
takes the table (the revoked variant renders the revoked_at date,
no actions); ``applyFilter`` is pure DOM (case-insensitive label
substring over the data rows — the state rows excluded — the
no-match copy quotes the ORIGINAL query in textContent); the input
listeners are armed in the ADMIN branch (after the whoami gate) and
set the module query + applyFilter with NO fetch; and a blank name
is refused client-side — the exact announce line, the re-focus, the
early return BEFORE any fetch, and the deleted "token" fallback."""
js = _asset("tokens.js")
# Module state: the persistent queries, initialized once.
assert re.search(r"let activeQuery = \"\";", js), (
"the active search query is module state (initialized '')"
)
assert re.search(r"let revokedQuery = \"\";", js), (
"the revoked search query is module state (initialized '')"
)
# The new scoped lookups (the phase-76 root-scoping contract).
for sid in (
"tokens-no-match-row",
"token-search-active",
"tokens-revoked-heading",
"token-search-revoked",
"tokens-revoked-wrap",
"tokens-revoked-tbody",
"tokens-revoked-no-match-row",
):
assert f'querySelector("#{sid}")' in js, f"missing the #{sid} lookup"
# makeRow: the table parameter + the revoked variant (the
# revoked_at date cell, no actions).
assert "function makeRow(tok, table)" in js, "makeRow takes the table"
assert 'table === "revoked"' in js, "the revoked variant branches on the table"
assert "revoked_at" in js, "the Revoked cell renders tok.revoked_at"
# loadTokens: the split (per-table appends, server order kept),
# the section helper call, the BOTH filters re-applied after the
# render.
load = js.find("async function loadTokens()")
assert load != -1, "loadTokens must exist"
load_body = js[load:js.find("\n }", load)]
assert 'makeRow(tok, "active")' in load_body, "active rows render into the active table"
assert 'makeRow(tok, "revoked")' in load_body, "revoked rows render into the revoked table"
split_i = load_body.find(".revoked")
assert split_i != -1, "the split keys off tok.revoked (the D5 bool)"
assert "setRevokedSectionVisible(revoked.length)" in load_body, (
"the revoked section shows iff ≥1 revoked row"
)
fetch_i = load_body.find('fetch("/api/tokens")')
active_apply = load_body.find("applyFilter(tbody, noMatchRow, activeQuery)")
revoked_apply = load_body.find(
"applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery)"
)
assert 0 <= fetch_i < active_apply < revoked_apply, (
"BOTH filters re-apply after the fetch + render (D4: a re-render "
"never loses the queries)"
)
# The section show/hide helper: heading + search + wrap together.
helper = js.find("function setRevokedSectionVisible")
assert helper != -1, "the setRevokedSectionVisible(n) helper must exist"
helper_body = js[helper:js.find("\n }", helper)]
for name in ("revokedHeading.hidden", "searchRevoked.hidden", "revokedWrap.hidden"):
assert name in helper_body, f"the section helper must toggle {name}"
# applyFilter: pure DOM — case-insensitive label substring over
# the data rows (the no-match/empty state rows excluded), the
# no-match row visible ⟺ non-empty query + zero visible rows, its
# <td> textContent carries the ORIGINAL query in quotes.
f = js.find("function applyFilter(")
assert f != -1, "applyFilter must exist"
f_body = js[f:js.find("\n }", f)]
assert ".toLowerCase()" in f_body, "the match is case-insensitive"
assert "tr === targetNoMatchRow" in f_body, (
"the no-match row is never treated as a data row"
)
assert "emptyRow" in f_body, "the empty-state row is not a data row"
assert ".tokens-label-cell" in f_body, (
"the label cell is the filter's data source"
)
assert 'No tokens match "${' in f_body, (
"the no-match copy (the user's original query in quotes, textContent)"
)
assert "innerHTML" not in f_body, "applyFilter is textContent-only"
# The input listeners: armed in the ADMIN branch only (after the
# whoami gate), they set the module query + applyFilter — NO
# fetch (D4 is client-side).
gate_i = js.find("if (!(await fetchIsAdmin()))")
assert gate_i != -1
for i, var in (
(js.find('searchActive.addEventListener("input"'), "activeQuery"),
(js.find('searchRevoked.addEventListener("input"'), "revokedQuery"),
):
assert i != -1, f"the {var} input listener must be armed"
assert gate_i < i, f"the {var} listener is armed in the ADMIN branch (after the gate)"
seg = js[i:js.find("});", i)]
assert f"{var} =" in seg, f"the listener writes the {var} module state"
assert "fetch(" not in seg, "the search is client-side (no fetch)"
branch = js[gate_i:js.find("return;", gate_i)]
assert "searchActive.hidden = true" in branch, (
"the anonymous branch keeps the active search hidden"
)
assert "searchActive.hidden = false" in js, (
"the admin branch reveals the active search (with the create row)"
)
# The required name (D3): the exact line + re-focus + the early
# return BEFORE any fetch; the old fallback is GONE from the file.
gen = js.find("async function generateToken()")
assert gen != -1, "generateToken must exist"
gen_body = js[gen:]
check_i = gen_body.find("if (!label)")
fetch_i = gen_body.find('fetch("/api/tokens"')
assert 0 <= check_i < fetch_i, (
"the blank-name check runs BEFORE any request (D3: the request "
"simply doesn't happen)"
)
assert 'announce("Give the token a name first.")' in gen_body, (
"the exact D3 live-region line"
)
assert "labelInput.focus()" in gen_body, "the name input re-focuses"
assert '|| "token"' not in js, (
"the old blank-label 'token' fallback is DELETED (D3)"
)
def test_tokens_split_css_pins() -> None:
"""Phase 101 task 02: styles.css carries the .token-search surface
(full width, the ≥44px target, the house input family — --line
hairline, --surface fill, ink text — no new hue) and the
.tokens-revoked-heading sub-heading (the phase-97 .kb-level h2
voice: mono, 1rem, brand-ink); the empty/no-match rows' styling
stays CLASS-based (.tokens-empty-row — both tables covered)."""
css = _asset("styles.css")
block = re.search(r"\.token-search \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .token-search"
body = block.group(1)
assert "width: 100%" in body, "the search input is full width"
assert "min-height: 44px" in body, "the ≥44px touch target"
assert "border: 1px solid var(--line)" in body, "the house input hairline"
assert "background: var(--surface)" in body, "the house input surface"
heading = re.search(r"\.tokens-revoked-heading \{([\s\S]*?)\n\}", css)
assert heading, "styles.css must style .tokens-revoked-heading"
hbody = heading.group(1)
assert "font-family: var(--mono)" in hbody, "the .kb-level h2 voice (mono)"
assert "font-size: 1rem" in hbody
assert "color: var(--brand-ink)" in hbody, "brand-ink — AA on the page background"
assert re.search(r"\.tokens-empty-row td \{", css), (
"the empty/no-match rows' styling is CLASS-based (both tables)"
)
# ---------- phase 101 task 03: the Regenerate control (rotation) ----------
def test_tokens_js_regenerate_control() -> None:
"""Phase 101 task 03 (D2) in tokens.js: the active row's Actions
cell appends the Regenerate control BEFORE the Revoke control, and
EACH control owns its OWN .tokens-actions wrapper span (a confirm
in one never clobbers the other — the shared cell hosts two
independent confirm scopes); makeRegenerateControl is a structural
mirror of makeRevokeControl — the .token-regenerate button (label
"Regenerate", aria-label "Regenerate token: <label>"), the first
click swaps the cell to the confirm pair (the EXACT text
"Regenerate? The current token is revoked.", the
history-confirm-yes/no classes, focus to Yes), No / a failure
restore via restoreRegenerate (the button back, focus restored);
confirmRegenerate disables the Yes button while in flight, POSTs
/api/tokens/<id>/regenerate (no body), and on 201 runs
loadTokens() FIRST, THEN reveals the once-block (the value-only
contract), THEN announces the D2 line; a 404 removes the row +
re-fetches + the house 404 line; a 409 re-fetches + the same
line; any other failure / network error announces the neutral
copy and restores (retryable)."""
js = _asset("tokens.js")
# The Actions cell order: Regenerate FIRST (the primary lifecycle
# action), both controls appended in the active variant.
row_i = js.find("function makeRow(tok, table)")
assert row_i != -1, "makeRow must exist"
row_body = js[row_i:js.find("\n }", row_i)]
assert "actionsTd.append(" in row_body, (
"the Actions cell hosts BOTH controls"
)
regen_i = row_body.find("makeRegenerateControl(tok, tr)")
revoke_i = row_body.find("makeRevokeControl(tok, tr)")
assert 0 <= regen_i < revoke_i, (
"the active Actions cell appends Regenerate BEFORE Revoke (D2)"
)
# The per-control wrapper spans: EACH control owns its OWN
# .tokens-actions span (the two swap-scopes are independent).
mk_i = js.find("function makeRegenerateControl(")
assert mk_i != -1, "makeRegenerateControl must exist"
mk_body = js[mk_i:js.find("\n }", mk_i)]
assert 'cell.className = "tokens-actions"' in mk_body, (
"the Regenerate control owns its OWN .tokens-actions wrapper span"
)
mkr_i = js.find("function makeRevokeControl(")
assert mkr_i != -1, "makeRevokeControl must exist"
mkr_body = js[mkr_i:js.find("\n }", mkr_i)]
assert 'cell.className = "tokens-actions"' in mkr_body, (
"the Revoke control keeps its OWN .tokens-actions wrapper span"
)
# The button: the .token-regenerate class, the label, the
# aria-label (the row buttons carry their own aria-labels — the
# house convention).
assert 'regenBtn.className = "token-regenerate"' in mk_body, (
"the button carries the .token-regenerate class"
)
assert 'regenBtn.textContent = "Regenerate"' in mk_body, (
"the button is labeled Regenerate"
)
assert "Regenerate token: ${tok.label}" in mk_body, (
"the aria-label names the token (Regenerate token: <label>)"
)
# The two-step swap: the EXACT confirm text, the history-confirm-*
# pair, focus to Yes.
assert (
'label.textContent = "Regenerate? The current token is revoked."'
in mk_body
), "the confirm text is EXACT (the D2 copy)"
assert 'label.className = "history-confirm-text"' in mk_body
assert 'yes.className = "history-confirm-yes"' in mk_body, (
"the Yes button reuses the house confirm class"
)
assert 'no.className = "history-confirm-no"' in mk_body, (
"the No button reuses the house confirm class"
)
assert "yes.focus()" in mk_body, "focus moves to Yes (keyboard confirm)"
# The restore path: No and a failure bring the button back, focus
# restored (the revoke control's restore pattern, copied).
assert 'no.addEventListener("click", restoreRegenerate)' in mk_body, (
"No restores the Regenerate button"
)
assert "cell.replaceChildren(regenBtn)" in mk_body, (
"the restore swaps the cell back to the button"
)
assert "regenBtn.focus()" in mk_body, "the restore returns the focus"
# confirmRegenerate: the disabled-while-in-flight Yes, the POST
# path (JSON, NO body — the revoke control's request shape).
cr_i = js.find("async function confirmRegenerate(")
assert cr_i != -1, "confirmRegenerate must exist"
body = js[cr_i:js.find("\n }", cr_i)]
dis_i = body.find("yesBtn.disabled = true")
fetch_i = body.find("/regenerate")
assert 0 <= dis_i < fetch_i, (
"the Yes button disables BEFORE the request (no double-fire)"
)
assert (
'fetch(`/api/tokens/${tok.id}/regenerate`, { method: "POST" })'
in body
), "the POST path is /api/tokens/<id>/regenerate with NO body"
# The 201 sequence (pinned order): the JSON parse, the re-entrant
# loadTokens() FIRST (the relocation), the once-block reveal
# (value only — the A4 contract), THEN the D2 live-region line.
json_i = body.find("const data = await r.json()")
load_i = body.find("await loadTokens()", json_i)
reveal_i = body.find("onceValue.value = data.token", json_i)
show_i = body.find("onceBlock.hidden = false", json_i)
ann_i = body.find('Regenerated "${tok.label}"', json_i)
assert 0 <= json_i < load_i < reveal_i < show_i < ann_i, (
"on 201 the load runs FIRST, then the once-block reveal, then "
"the D2 line (D2's pinned sequence)"
)
assert (
'Regenerated "${tok.label}" — copy the new token now; '
"it won't be shown again."
) in body, "the D2 live-region line is EXACT"
# The 404: the row vanished — row.remove() + the re-fetch
# (reconciliation) + the house 404 line (the revoke control's
# existing copy — one house message for the one common case).
i404 = body.find("r.status === 404")
i409 = body.find("r.status === 409")
iok = body.find("if (!r.ok)")
assert 0 <= i404 < i409 < iok, (
"the 404/409 branches precede the generic failure"
)
seg404 = body[i404:i409]
assert "row.remove()" in seg404, "the 404 removes the vanished row"
assert "await loadTokens()" in seg404, (
"the 404 re-fetches (the reconciliation)"
)
assert 'announce("That token was already revoked.")' in seg404, (
"the 404 reuses the house line"
)
seg409 = body[i409:iok]
assert "await loadTokens()" in seg409, (
"the 409 re-fetches (the row was revoked between render and click)"
)
assert 'announce("That token was already revoked.")' in seg409, (
"the 409 lands the SAME house line"
)
# The retryable failures: the neutral two-line house copy + the
# restore (the button back) — network and non-2xx alike.
assert (
'announce(`Couldn\'t regenerate "${tok.label}" — is the app reachable?`)' in body
), "the network line is the house two-line convention"
assert (
'announce(`Couldn\'t regenerate "${tok.label}" — try again.`)' in body
), "the non-2xx line is the neutral retry copy"
assert body.count("restoreRegenerate();") == 2, (
"BOTH the network and the non-2xx failures restore the button (retryable)"
)
assert "innerHTML" not in body, "textContent only (XSS-safe by construction)"
def test_tokens_regenerate_css_pins() -> None:
"""Phase 101 task 03: styles.css carries the .token-regenerate
button — the .token-revoke's structural twin (the same ≥44px
target, --line hairline, radius, transparent fill, ink-soft text)
with the NEUTRAL action's hover (brand-soft / brand-ink — NOT the
revoke's error hover: a rotation is a hand-out, not a deletion) and
a :disabled rule consistent with the revoke button's (the dimmed
in-flight state) — no new hue (the phase-92 monochrome invariant);
the confirm pair reuses the existing .history-confirm-* rules
unchanged."""
css = _asset("styles.css")
block = re.search(r"\.token-regenerate \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .token-regenerate"
body = block.group(1)
assert "min-height: 44px" in body, (
"the ≥44px touch target (the .token-revoke twin)"
)
assert "border: 1px solid var(--line)" in body, "the house ghost hairline"
assert "border-radius: var(--radius-sm)" in body, "the house radius"
assert "background: transparent" in body, "the transparent fill"
assert "color: var(--ink-soft)" in body, "the ghost text (ink-soft)"
assert "var(--err-" not in body, "no new hue — the monochrome invariant"
hover = re.search(r"\.token-regenerate:hover:not\(:disabled\) \{([\s\S]*?)\}", css)
assert hover, "the neutral hover rule (gated like the revoke's)"
hbody = hover.group(1)
assert "var(--brand-soft)" in hbody, "the hover fill is the NEUTRAL brand-soft"
assert "var(--brand-ink)" in hbody, "the hover text is brand-ink"
assert "var(--err-" not in hbody, (
"the hover is NOT the revoke's error hover (rotation ≠ deletion)"
)
disabled = re.search(r"\.token-regenerate:disabled \{([\s\S]*?)\}", css)
assert disabled, "the :disabled state (the in-flight look)"
dbody = disabled.group(1)
assert "opacity: 0.5" in dbody and "cursor: wait" in dbody, (
"consistent with the revoke button's disabled rule"
)
# ---------- phase 91 task 04: the Theme view (skeleton) ----------
+106 -1
View File
@@ -14,7 +14,14 @@ admin API (task 02's endpoints) and the future token-auth login (task
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``.
* ``mark_used`` — bumps ``last_used_at``;
* ``regenerate_token`` (phase 101) — the atomic rotation matrix: an
active row is revoked (first stamp) and its successor (same label,
fresh well-formed plaintext, hash-only row) takes its place — the new
plaintext round-trips, the old one no longer authenticates; a missing
id returns ``None``; an already-revoked id raises
``TokenAlreadyRevoked``; and the service NEVER commits — the caller's
rollback undoes both writes.
House DB-test pattern (the ``test_sources_meta`` precedent): the service
is a thin session wrapper whose contract (server-default ``created_at``,
@@ -172,3 +179,101 @@ def test_mark_used_stamps_last_used_at(db: Session) -> None:
seconds=1
)
db.commit()
# ---------- phase 101, task 01: the atomic rotation ----------
def test_regenerate_token_rotates_an_active_row(db: Session) -> None:
"""Active row → a NEW row (different id) with the SAME label + a
well-formed new plaintext (``bor_`` + 32 hex) whose sha256 IS the
stored hash; the OLD row is stamped with its first ``revoked_at``;
the new plaintext round-trips through ``find_active_by_token`` and
the old plaintext no longer authenticates (dead is dead)."""
old_row, old_plain = _create_and_commit(db, "alice")
before = datetime.now(UTC)
rotated = tok.regenerate_token(db, old_row.id)
assert rotated is not None
new_row, new_plain = rotated
db.commit() # the caller owns the commit (the endpoint's job)
db.refresh(old_row)
db.refresh(new_row)
# The successor: a different id, the SAME hand-out label, a fresh
# well-formed plaintext (never a reuse of the old one).
assert new_row.id != old_row.id
assert new_row.label == old_row.label == "alice"
assert TOKEN_SHAPE.fullmatch(new_plain), new_plain
assert new_plain != old_plain
# The successor row stores ONLY the sha256 of the NEW plaintext.
assert new_row.token_hash == tok.hash_token(new_plain)
assert new_row.token_hash != tok.hash_token(old_plain)
assert new_row.last_used_at is None
assert new_row.revoked_at is None
# The old row carries its first (and only) revocation stamp.
assert old_row.revoked_at is not None
assert old_row.revoked_at.tzinfo is not None
assert before - timedelta(seconds=1) <= old_row.revoked_at <= (
datetime.now(UTC) + timedelta(seconds=1)
)
# The rotation takes effect immediately, both directions.
hit = tok.find_active_by_token(db, new_plain)
assert hit is not None
assert hit.id == new_row.id
assert tok.find_active_by_token(db, old_plain) is None
def test_regenerate_token_missing_id_returns_none(db: Session) -> None:
"""Unknown id → ``None`` (the endpoint maps it to 404) — nothing is
created, no stamp anywhere."""
_create_and_commit(db) # the table is NOT empty — the miss is by id
assert tok.regenerate_token(db, uuid.uuid4()) is None
db.rollback()
assert db.execute(text("SELECT count(*) FROM api_tokens")).scalar_one() == 1
def test_regenerate_token_already_revoked_raises(db: Session) -> None:
"""A dead token cannot be rotated: an already-revoked id raises
``TokenAlreadyRevoked`` (the endpoint's 409) and creates NOTHING —
no successor row, the original stamp untouched."""
row, _ = _create_and_commit(db)
assert tok.revoke(db, row.id) is True
db.commit()
db.refresh(row)
original_stamp = row.revoked_at
assert original_stamp is not None
with pytest.raises(tok.TokenAlreadyRevoked):
tok.regenerate_token(db, row.id)
db.rollback()
assert db.execute(text("SELECT count(*) FROM api_tokens")).scalar_one() == 1
db.refresh(row)
assert row.revoked_at == original_stamp
def test_regenerate_token_service_never_commits(db: Session) -> None:
"""Atomicity + the house commit split: the service flushes, never
commits — the caller's rollback AFTER the rotation undoes BOTH
writes (the stamp AND the successor), so the old token stays live."""
old_row, old_plain = _create_and_commit(db, "carol")
rotated = tok.regenerate_token(db, old_row.id)
assert rotated is not None
new_row, _ = rotated
db.rollback() # the caller refuses the rotation
db.expire_all()
survivor = db.get(ApiToken, old_row.id)
assert survivor is not None
assert survivor.revoked_at is None # the stamp rolled back
assert db.get(ApiToken, new_row.id) is None # the successor rolled back
# The old credential is alive again — the rotation never happened.
hit = tok.find_active_by_token(db, old_plain)
assert hit is not None
assert hit.id == old_row.id
db.commit()