"""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 all three endpoints (router-level ``require_admin``; a token USER, once task 03 lands, is 403 here too — pinned in task 03's matrix); * 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`` and the row keeps its ``last_used_at``; unknown id → 404 ``token not found``; * the list is 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.models import ApiToken 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 test_anonymous_403_on_all_three(client: TestClient) -> None: """Router-level ``require_admin``: every route is 403 for the unsigned-in caller (one fixed detail — no enumeration).""" 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"} 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] assert set(item) == {"id", "label", "created_at", "last_used_at", "revoked"} assert item["id"] == body["id"] assert item["label"] == "alice" assert item["last_used_at"] is None # not used yet assert item["revoked"] is False 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 # 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 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"]]