phase: 101_tokens_page_overhaul
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:
+43
-1
@@ -19,7 +19,16 @@ compare would be theatre, so the contrast is documented, not replicated.
|
||||
|
||||
House commit convention (the ``app.rag.sources_meta`` pattern): the service
|
||||
functions flush but never commit — the calling endpoint owns the commit, so
|
||||
a failed request can never leave a half-applied token mutation.
|
||||
a failed request can never leave a half-applied token mutation. This holds
|
||||
for the composite :func:`regenerate_token` rotation too: the revocation
|
||||
stamp and the successor's insert are flushed by ONE call and covered by
|
||||
ONE commit — a create failure rolls the revoke back with it.
|
||||
|
||||
Rotation (phase 101): :func:`regenerate_token` revokes the old row and
|
||||
creates its successor (same label) atomically. A4 never weakens — the old
|
||||
plaintext was already one-shot (its 201 body was the only wire moment),
|
||||
and the new plaintext is returned by the service exactly once, for the
|
||||
rotation's 201 body to ship.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -116,3 +125,36 @@ def revoke(db: Session, token_id: uuid.UUID) -> bool:
|
||||
if row.revoked_at is None:
|
||||
row.revoked_at = datetime.now(UTC)
|
||||
return True
|
||||
|
||||
|
||||
class TokenAlreadyRevoked(Exception):
|
||||
"""A revoked (dead) token cannot be rotated — the regenerate endpoint's 409."""
|
||||
|
||||
|
||||
def regenerate_token(
|
||||
db: Session, token_id: uuid.UUID
|
||||
) -> tuple[ApiToken, str] | None:
|
||||
"""Rotate one ACTIVE token; return the (NEW row, new plaintext) ONCE.
|
||||
|
||||
The rotation is ONE atomic unit: ``revoked_at`` is stamped on the old
|
||||
row (the :func:`revoke` primitive — its first stamp IS the
|
||||
revocation time) and the successor row is created with the SAME
|
||||
label (the hand-out name persists, phase 101 D2) — both writes are
|
||||
flushed here and covered by the caller's SINGLE commit, so a create
|
||||
failure rolls the revoke back with it (the service flushes, never
|
||||
commits — the house convention). A missing id returns ``None`` (the
|
||||
endpoint's 404); an already-revoked row raises
|
||||
:class:`TokenAlreadyRevoked` (a dead token cannot be rotated — the
|
||||
endpoint's 409). A4 never weakens: the old plaintext was already
|
||||
one-shot, and the new plaintext exists outside this function only in
|
||||
this return value — the rotation's 201 body is its one wire moment.
|
||||
"""
|
||||
row = db.get(ApiToken, token_id)
|
||||
if row is None:
|
||||
return None
|
||||
if row.revoked_at is not None:
|
||||
raise TokenAlreadyRevoked()
|
||||
revoke(db, row.id) # stamps the first (and only) revocation time
|
||||
new_row, plaintext = create_token(db, row.label)
|
||||
db.flush() # one unit for the caller's commit: stamp + successor
|
||||
return new_row, plaintext
|
||||
|
||||
Reference in New Issue
Block a user