"""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"]]