Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
199 lines
7.7 KiB
Python
199 lines
7.7 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 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"]]
|