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
+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()