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`.
280 lines
10 KiB
Python
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()
|