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`.
351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""Integration: the admin token API (phase 79, task 02).
|
|
|
|
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 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
|
|
serialized body;
|
|
* 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`` + 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.
|
|
|
|
Requires: podman compose up -d db
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
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
|
|
#: (task 03's mark_used lands later; here the test stamps the column
|
|
#: directly to pin the "revoke preserves last_used_at" contract).
|
|
USED_STAMP = datetime(2026, 9, 6, 12, 0, 0, tzinfo=UTC)
|
|
|
|
|
|
@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(admin_client: TestClient, label: str = "alice") -> dict:
|
|
"""POST a token (201) and return the response body."""
|
|
r = admin_client.post("/api/tokens", json={"label": label})
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
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), 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"}
|
|
r = client.get("/api/tokens")
|
|
assert r.status_code == 403
|
|
assert r.json() == {"detail": "admin only"}
|
|
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(
|
|
admin_client: TestClient, db: Session
|
|
) -> None:
|
|
"""201 carries the well-formed plaintext; the row stores only the
|
|
hash; the LIST never exposes either the plaintext or the hash."""
|
|
body = _create(admin_client, " alice ")
|
|
|
|
# The 201 shape: exactly the display fields + the plaintext once.
|
|
assert set(body) == {"id", "label", "token", "created_at"}
|
|
assert body["label"] == "alice" # the service strips
|
|
assert TOKEN_SHAPE.fullmatch(body["token"]), body["token"]
|
|
datetime.fromisoformat(body["created_at"]) # parses
|
|
|
|
# The stored row carries only the hash of the FULL token string.
|
|
row = db.execute(
|
|
select(ApiToken).where(ApiToken.id == uuid.UUID(body["id"]))
|
|
).scalar_one()
|
|
assert row.token_hash == hashlib.sha256(body["token"].encode("utf-8")).hexdigest()
|
|
assert row.token_hash != body["token"]
|
|
|
|
# The list: no `token` key, no `token_hash` key, and NEITHER the
|
|
# plaintext NOR the hash string appears anywhere in the body.
|
|
r = admin_client.get("/api/tokens")
|
|
assert r.status_code == 200
|
|
items = r.json()["tokens"]
|
|
assert len(items) == 1
|
|
item = items[0]
|
|
# 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
|
|
|
|
|
|
def test_two_tokens_same_label_both_created(admin_client: TestClient) -> None:
|
|
"""Labels are display-only and NOT unique — two tokens may share a
|
|
label (the column has no unique constraint, task 01)."""
|
|
a = _create(admin_client, "alice")
|
|
b = _create(admin_client, "alice")
|
|
assert a["id"] != b["id"]
|
|
assert a["token"] != b["token"]
|
|
|
|
items = admin_client.get("/api/tokens").json()["tokens"]
|
|
assert len(items) == 2
|
|
assert {i["label"] for i in items} == {"alice"}
|
|
|
|
|
|
def test_create_blank_or_overlong_label_422(admin_client: TestClient) -> None:
|
|
"""The schema fails loud (house ``ValueError`` pattern): whitespace
|
|
only, empty, and >120 chars after strip are all 422."""
|
|
for label in ("", " ", "x" * 121):
|
|
r = admin_client.post("/api/tokens", json={"label": label})
|
|
assert r.status_code == 422, (label, r.status_code, r.text)
|
|
# A padded-but-valid label passes (trim-before-constrain).
|
|
assert _create(admin_client, " carol ")["label"] == "carol"
|
|
|
|
|
|
def test_revoke_204_idempotent_and_preserves_last_used(
|
|
admin_client: TestClient, db: Session
|
|
) -> None:
|
|
"""Revoke → 204; the list item flips to ``revoked: true`` and keeps
|
|
its ``last_used_at``; a second revoke is still 204 with the ORIGINAL
|
|
stamp preserved (no re-stamp)."""
|
|
body = _create(admin_client, "dave")
|
|
token_id = uuid.UUID(body["id"])
|
|
|
|
# Stamp "already used" directly (task 03's mark_used is API-side).
|
|
db.execute(
|
|
update(ApiToken).where(ApiToken.id == token_id).values(last_used_at=USED_STAMP)
|
|
)
|
|
db.commit()
|
|
|
|
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
|
|
|
|
item = admin_client.get("/api/tokens").json()["tokens"][0]
|
|
assert item["revoked"] is True
|
|
assert datetime.fromisoformat(item["last_used_at"]) == USED_STAMP
|
|
|
|
db.expire_all()
|
|
original_stamp = db.execute(
|
|
select(ApiToken).where(ApiToken.id == token_id)
|
|
).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()
|
|
row = db.execute(select(ApiToken).where(ApiToken.id == token_id)).scalar_one()
|
|
assert row.revoked_at == original_stamp
|
|
assert datetime.fromisoformat(
|
|
admin_client.get("/api/tokens").json()["tokens"][0]["last_used_at"]
|
|
) == 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")
|
|
assert r.status_code == 404
|
|
assert r.json() == {"detail": "token not found"}
|
|
|
|
|
|
def test_list_is_newest_first(admin_client: TestClient, db: Session) -> None:
|
|
"""``created_at desc, id desc``: forcing a deterministic gap pins the
|
|
ordering (transaction timestamps can be coarser than the insert
|
|
gap, and uuid4 ids would tie-break randomly)."""
|
|
older = _create(admin_client, "first")
|
|
newer = _create(admin_client, "second")
|
|
|
|
db.execute(
|
|
update(ApiToken)
|
|
.where(ApiToken.id == uuid.UUID(older["id"]))
|
|
.values(created_at=datetime.now(UTC) - timedelta(hours=1))
|
|
)
|
|
db.commit()
|
|
|
|
items = admin_client.get("/api/tokens").json()["tokens"]
|
|
assert [i["id"] for i in items] == [newer["id"], older["id"]]
|