feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""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"]]
|
||||
Reference in New Issue
Block a user