Files
ducoterra 4dbac1660a
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 13s
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`.
2026-09-12 15:16:02 -04:00

280 lines
10 KiB
Python

"""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``;
* ``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``,
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()
# ---------- 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()