feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

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:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+53 -4
View File
@@ -1,11 +1,25 @@
"""Integration tests: HTTP API surface (no database required)."""
"""Integration tests: HTTP API surface (mostly no database required).
Phase 79 note: the user-gated endpoints (chat, suggestions) are driven
here by a signed-in ADMIN client — the admin path of ``require_user``
short-circuits before any DB touch, so this module stays database-free
(the 401 auth contract itself is pinned in ``test_auth_api.py``).
Phase 80 note: the suggestions pins are the exception — the chips are
the last 3 questions asked once any are saved, so the env-override
pin (the override is the SEED) needs an empty ``saved_chats``;
the full state matrix lives in ``test_suggestions_api.py``.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import get_settings
from tests.conftest import ADMIN_PASSWORD
def test_health_reports_ok(client) -> None:
@@ -114,16 +128,28 @@ def test_config_docs_flag_tracks_settings(client) -> None:
def test_suggestions_returns_list(client) -> None:
# Phase 79: the chips are user-gated — sign in as the admin first
# (the test's purpose is the list shape, not the auth contract).
# Phase 80: the chips are the last 3 questions asked OR the seed —
# the per-state exact lists are pinned in test_suggestions_api.py;
# here the DB-free shape pin holds in EVERY state: a list of
# non-blank strings (1–3 chips once questions exist, the seed
# while none do).
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.get("/api/suggestions")
assert r.status_code == 200
suggestions = r.json()["suggestions"]
assert isinstance(suggestions, list)
assert len(suggestions) >= 3
assert all(isinstance(s, str) and s.strip() for s in suggestions)
def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
"""GET /api/suggestions reflects the BOR_SUGGESTIONS JSON env override."""
def test_suggestions_honors_bor_suggestions_env_override(
monkeypatch, db: Session
) -> None:
"""GET /api/suggestions reflects the BOR_SUGGESTIONS JSON env
override — as the SEED (phase 80): it appears while ZERO questions
have been saved, so the pin needs an empty ``saved_chats`` (the
full state matrix is test_suggestions_api.py)."""
from fastapi.testclient import TestClient
from app.main import create_app
@@ -134,13 +160,22 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
"How do I deploy a service?",
"What proxy fronts reeseapps.com?",
]
db.execute(text("TRUNCATE saved_chats"))
db.commit()
get_settings.cache_clear()
try:
monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(override))
fresh_client = TestClient(create_app())
finally:
get_settings.cache_clear()
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# Phase 79: sign the fresh client in as the admin (the chips are
# user-gated; the override's value is what this test pins).
assert fresh_client.post(
"/api/login", json={"password": ADMIN_PASSWORD}
).status_code == 204
r = fresh_client.get("/api/suggestions")
assert r.status_code == 200
assert r.json() == {"suggestions": override}
@@ -167,6 +202,10 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
# shell-body marker (the History view section is inside the
# shell; the old standalone page's title is client-side now).
("/history.html", 'id="view-history"'), # phase 76: shell route (was "Saved chats")
# Phase 79 (task 06): /tokens.html is a SHELL route too — the
# shell-body marker (the Tokens view section is inside the
# shell; the per-view title is client-side now).
("/tokens.html", 'id="view-tokens"'), # phase 79: shell route
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
],
)
@@ -206,6 +245,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
"path",
["/sources.html", "/document.html", "/login.html", "/tuning.html",
"/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03)
"/tokens.html", # phase 79 task 06: + Tokens (shell route)
"/shared.html"], # phase 51: + the anonymous shared page
)
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
@@ -226,6 +266,11 @@ def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
("/sources.html", 'id="view-rag"', "Sources · Brain of Reese"),
("/git-sources.html", 'id="view-git-sources"', "Git sources · Brain of Reese"),
("/history.html", 'id="view-history"', "Saved chats · Brain of Reese"), # phase 76 task 03
# phase 79 task 06: the sixth view — there was never a
# standalone tokens.html, so "old_title" is the router's
# client-side title: the pin asserts the shell never carries
# the per-view title statically (the router writes it).
("/tokens.html", 'id="view-tokens"', "Access tokens · Brain of Reese"), # phase 79 task 06
],
)
def test_shell_routes_serve_the_shell_no_cache_versioned(
@@ -374,5 +419,9 @@ def test_ui_chrome_has_no_emoji(client, path: str) -> None:
def test_chat_requires_message(client) -> None:
"""The empty-message 422 validation pin (phase 79: the anonymous
caller now 401s BEFORE validation — sign in as the admin so this
test keeps testing validation, not the auth contract)."""
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.post("/api/chat", json={"message": ""})
assert r.status_code == 422
+254 -19
View File
@@ -1,11 +1,18 @@
"""Integration: the auth surface (phase 16) + the public-API regression
guards.
"""Integration: the auth surface (phase 16 admin; phase 79 token users).
Covers the full login/logout lifecycle against the real app (TestClient
keeps the cookie jar): wrong password → 401 + still-gated; correct →
204 + cookie → admin everywhere gated; logout → 403 again. And the
**anonymous** guarantees that phase 16 must not break: the document
viewer stays public (soft rule) and ``POST /api/chat`` still streams.
Covers the full admin lifecycle against the real app (TestClient keeps
the cookie jar): wrong password → 401 + still-gated; correct → 204 +
cookie → admin everywhere gated; logout → 403 again. And the phase-79
token flows: ``POST /api/token-auth`` (valid → 204 + the ``user`` role;
invalid / malformed / revoked / empty → ONE generic 401), the
three-role ``whoami``, logout clearing the token session, and the
LIVE revocation check (a revoked token is refused on the next request
and the dead session's keys are dropped — whoami falls to anonymous).
The phase-16 anonymous pins are SUPERSEDED by the phase-79 contract:
the ONLY anonymous content is the shared chats (plus the login/infra
endpoints the gate itself needs) — anonymous chat / suggestions /
document content now 401 ``authentication required``.
Requires: podman compose up -d db
"""
@@ -32,11 +39,16 @@ QUESTION = "How is my Kubernetes cluster set up?"
@pytest.fixture(autouse=True)
def clean_tables(db) -> Iterator[None]:
"""Docs + steering + query log are global state: reset around tests."""
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
"""Docs + steering + query log + tokens are global state: reset
around tests."""
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, api_tokens")
)
db.commit()
yield
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, api_tokens")
)
db.commit()
@@ -54,7 +66,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
def _seed_one_doc(db) -> Document:
"""One minimal document (for the public document-content endpoint)."""
"""One minimal document (for the user-gated document-content endpoint)."""
doc = Document(
source="docs",
path="homelab/kubernetes.md",
@@ -69,6 +81,28 @@ def _seed_one_doc(db) -> Document:
return doc
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)."""
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 _create_token(admin: TestClient, label: str = "alice") -> tuple[str, str]:
"""Admin issues one token; returns ``(token_id, plaintext)`` — the
plaintext is the 201 body's one-and-only wire moment (task 02)."""
r = admin.post("/api/tokens", json={"label": label})
assert r.status_code == 201, r.text
body = r.json()
return body["id"], body["token"]
# ---------- phase 16: the admin lifecycle (unchanged contract) ----------
def test_wrong_password_401_and_still_gated(client: TestClient) -> None:
r = client.post("/api/login", json={"password": "not-the-password"})
assert r.status_code == 401
@@ -93,7 +127,8 @@ def test_wrong_password_401_and_still_gated(client: TestClient) -> None:
def test_login_logout_lifecycle(client: TestClient) -> None:
# Anonymous shape before anything.
# Anonymous shape before anything (phase 79: the anonymous whoami
# gains the explicit role — the UI keys its admin surfaces off it).
who = client.get("/api/whoami").json()
assert who == {"authenticated": False, "role": "anonymous"}
@@ -145,8 +180,205 @@ def test_forged_cookie_is_rejected(client: TestClient) -> None:
assert client.get("/api/docs").status_code == 403
def test_anonymous_document_content_stays_public(client: TestClient, db) -> None:
"""Soft rule (phase 16): the catalog is gated, the viewer is not."""
# ---------- phase 79: the token login + the user role ----------
def test_token_auth_valid_signs_in_token_user(client: TestClient) -> None:
"""A valid token → 204 + session cookie → whoami reports the THIRD
role: ``{authenticated: true, role: "user"}``."""
admin = _admin_client()
_token_id, token = _create_token(admin)
r = client.post("/api/token-auth", json={"token": token})
assert r.status_code == 204
assert "bor_session" in client.cookies # the signed session cookie
assert client.get("/api/whoami").json() == {
"authenticated": True,
"role": "user",
}
def test_token_auth_all_failures_share_one_generic_401(client: TestClient) -> None:
"""Malformed / unknown / revoked / empty are INDISTINGUISHABLE — one
401 ``{"detail": "invalid token"}`` for every failure shape (the
phase-16 no-enumeration pattern; a 422 would hint at input-shape
differences on a credential endpoint)."""
admin = _admin_client()
_token_id, token = _create_token(admin)
failures = [
token + "0", # unknown (no row for this hash)
"bor_" + "0" * 32, # well-shaped, never issued
"short", # malformed: too short
"wrongprefix_" + "0" * 32, # malformed: wrong prefix
"", # empty
" ", # whitespace only
]
for bad in failures:
r = client.post("/api/token-auth", json={"token": bad})
assert r.status_code == 401, bad
assert r.json() == {"detail": "invalid token"}, bad
assert "bor_session" not in client.cookies # no session on failure
# A failed attempt must not sign anyone in.
assert client.get("/api/whoami").json() == {
"authenticated": False,
"role": "anonymous",
}
# Revoked → the SAME 401 (not a different "revoked" message).
_bob_id, bob_token = _create_token(admin, "bob")
bob_id = next(
t["id"] for t in admin.get("/api/tokens").json()["tokens"] if t["label"] == "bob"
)
assert admin.post(f"/api/tokens/{bob_id}/revoke").status_code == 204
r = client.post("/api/token-auth", json={"token": bob_token})
assert r.status_code == 401
assert r.json() == {"detail": "invalid token"}
def test_token_user_surface_matrix(client: TestClient, db, seeded_kb: FakeRagLLM) -> None:
"""The owner's sentence (phase 79): a token user gets the app surface
— chat streams, suggestion chips, cited document content — and NO
admin surface (tokens, docs list, saved chats, steering, git
sources stay 403 for them; saved chats have no per-user
attribution)."""
admin = _admin_client()
_token_id, token = _create_token(admin)
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
# Chat streams (the mocked LLM — the real turn pipeline).
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
status, content_type, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert status == 200
assert content_type.startswith("text/event-stream")
assert frames[-1]["type"] == "done"
assert frames[-1]["deflected"] is False
assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md"
# Suggestion chips + the cited document's content (the viewer).
assert client.get("/api/suggestions").status_code == 200
r = client.get(
"/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
)
assert r.status_code == 200
assert r.json()["title"] == "Kubernetes Homelab Cluster"
# The admin surface stays admin-only for a token user (A3 scope).
for path in ("/api/tokens", "/api/docs", "/api/chats", "/api/steering", "/api/git-sources"):
r = client.get(path)
assert r.status_code == 403, path
assert r.json() == {"detail": "admin only"}, path
def test_token_user_cannot_create_or_list_tokens(client: TestClient) -> None:
"""The token ADMIN surface is closed to token holders on every
method, not just GET (A3: only the admin manages tokens)."""
admin = _admin_client()
_token_id, token = _create_token(admin)
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
assert client.post("/api/tokens", json={"label": "evil"}).status_code == 403
assert client.get("/api/tokens").status_code == 403
assert client.post("/api/tokens/00000000-0000-0000-0000-000000000000/revoke").status_code == 403
# The admin's own token list is untouched.
assert [t["label"] for t in admin.get("/api/tokens").json()["tokens"]] == ["alice"]
def test_logout_clears_token_session(client: TestClient) -> None:
"""One logout, one session dict: a token holder's logout drops the
``user`` role — whoami anonymous and the gated surface 401s again."""
admin = _admin_client()
_token_id, token = _create_token(admin)
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
assert client.get("/api/whoami").json()["role"] == "user"
assert client.post("/api/logout").status_code == 204
assert "bor_session" not in client.cookies
assert client.get("/api/whoami").json() == {
"authenticated": False,
"role": "anonymous",
}
assert client.get("/api/suggestions").status_code == 401
assert client.post("/api/chat", json={"message": "hi"}).status_code == 401
def test_revocation_mid_session_enforced_live(
client: TestClient, db, seeded_kb: FakeRagLLM
) -> None:
"""The live check (A4): a token revoked MID-SESSION is refused on
the holder's next request — chat 401 — and the dead session's keys
are dropped right there (whoami falls to anonymous without a
logout)."""
admin = _admin_client()
token_id, token = _create_token(admin)
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
assert client.get("/api/whoami").json()["role"] == "user"
# The turn BEFORE the revocation still streams.
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
status, _ct, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert status == 200
assert frames[-1]["type"] == "done"
# The admin revokes (idempotent 204 from task 02).
assert admin.post(f"/api/tokens/{token_id}/revoke").status_code == 204
# The NEXT request is refused — 401, not a stream — and the session
# is dead (the live check popped both user keys).
r = client.post("/api/chat", json={"message": "hello"})
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
assert client.get("/api/whoami").json() == {
"authenticated": False,
"role": "anonymous",
}
# …and a FRESH login attempt with the revoked token fails too (the
# token itself is dead, not just this session).
r = client.post("/api/token-auth", json={"token": token})
assert r.status_code == 401
assert r.json() == {"detail": "invalid token"}
# ---------- phase 79: the enforcement matrix (the 401 contract) ----------
def test_anonymous_chat_now_401s(client: TestClient, db) -> None:
"""The phase-16 "chat still streams anonymously" pin is SUPERSEDED:
anonymous chat gets a plain 401 ``authentication required`` (401,
not 403 — no higher privilege would unblock them)."""
r = client.post("/api/chat", json={"message": "hello"})
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
def test_anonymous_suggestions_and_document_content_401(client: TestClient, db) -> None:
"""The other two gated endpoints share the same 401 contract."""
r = client.get("/api/suggestions")
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
_seed_one_doc(db)
r = client.get(
"/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
)
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
def test_document_content_admin_contract(client: TestClient, db) -> None:
"""The phase-16 soft rule (viewer public) is superseded: the viewer
content is token-or-admin — the admin gets the full DocContent
shape, unknown pairs still 404 (no enumeration of titles)."""
client.post("/api/login", json={"password": ADMIN_PASSWORD})
_seed_one_doc(db)
r = client.get(
@@ -168,15 +400,18 @@ def test_anonymous_document_content_stays_public(client: TestClient, db) -> None
}
assert body["summary"] is None
# Unknown docs still 404 anonymously (no enumeration of titles).
# Unknown docs still 404 (same shape as the phase-16 pin).
r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"})
assert r.status_code == 404
assert r.json() == {"detail": "document not found"}
def test_anonymous_chat_still_streams(
client: TestClient, seeded_kb: FakeRagLLM
) -> None:
"""Regression guard: sign-in must not have locked chat (A10 public)."""
def test_admin_chat_still_streams(client: TestClient, db, seeded_kb: FakeRagLLM) -> None:
"""The streaming assertions of the old anonymous pin survive under a
signed-in (admin) client — sign-in locked out strangers, not the
owner."""
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
status, content_type, frames = _stream_chat(client, QUESTION)
@@ -74,8 +74,9 @@ def file_validators(page_file: Path) -> tuple[str, str]:
#: computation below must use the file that actually backs the
#: response, or the conditional-GET probe would carry a validator no
#: browser ever saw. Tasks 02/03 extended this as the remaining views
#: folded in (task 03 — History — completes the set: all four
#: non-chat navbar views). The page CONTRACT itself is unchanged: the
#: folded in (task 03 — History — completed the phase-76 set: all
#: four non-chat navbar views; phase 79 task 06 adds the sixth —
#: Tokens). The page CONTRACT itself is unchanged: the
#: phase-33 middleware wraps the whole app and lists the path in
#: HTML_PAGES, so the shell-route response is normalized exactly like
#: a static page (200, no-cache, ?v=, no validators — asserted by
@@ -85,6 +86,7 @@ SHELL_BACKED_PAGES = {
"/sources.html": "index.html", # phase 76 task 02
"/git-sources.html": "index.html", # phase 76 task 02
"/history.html": "index.html", # phase 76 task 03
"/tokens.html": "index.html", # phase 79 task 06
}
+12
View File
@@ -32,6 +32,7 @@ from app.rag import agent
from app.rag.agent import AGENT_TOOLS
from app.rag.importer import import_sources
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece
from tests.conftest import ADMIN_PASSWORD
if TYPE_CHECKING:
from app.rag.scaffolding import ScaffoldingFilter
@@ -220,6 +221,17 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
db.commit()
@pytest.fixture(autouse=True)
def _admin_signed_in(client: TestClient) -> None:
"""Phase 79 (task 03): ``POST /api/chat`` is user-gated — every turn
in this module runs as the signed-in ADMIN, so the shared ``client``
logs in once per test (the TestClient cookie jar carries the session
for every request of the test). The anonymous 401 contract itself is
pinned in ``test_auth_api.py``."""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
def _stream_chat(client: TestClient, message: str) -> tuple[int, str, list[dict[str, Any]]]:
with client.stream("POST", "/api/chat", json={"message": message}) as r:
assert r.status_code == 200
+32 -17
View File
@@ -4,7 +4,10 @@ Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
* GET: 200 with the full field set for a seeded document (all formats);
* GET: ``summary`` surfaced for summarized docs, ``null`` for markdown
(phase 36);
* GET: anonymous access stays 200 (phase 16 soft rule — public viewer);
* GET: user-gated (phase 79 — the phase-16 "public viewer" soft rule is
superseded; the shared chats are the anonymous surface now), so the
shared ``client`` is signed in as the admin for the module's contract
tests and the anonymous 403/401 pins build their own client;
* GET: 404 for an unknown (source, path) pair;
* GET: 404 for traversal-style ``path`` values (no filesystem access → no
leak).
@@ -32,11 +35,23 @@ from sqlalchemy import select, text
from sqlalchemy.orm import Session
import app.api.docs as docs_api
from app.main import app as fastapi_app
from app.models import Chunk, Document
from app.rag.llm import EmbeddingError
from tests.conftest import ADMIN_PASSWORD
from tests.fakes import FakeEmbedder
@pytest.fixture(autouse=True)
def _user_signed_in(client: TestClient) -> None:
"""Phase 79 (task 03): the document-content endpoint is user-gated —
the shared ``client`` signs in as the admin for this module's GET /
PATCH contract tests. The ONE test that pins the anonymous PATCH 403
builds its own client (it must stay unsigned)."""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
def _seed_doc(
db,
source: str = "Homelab",
@@ -195,8 +210,8 @@ def test_summary_patch_update_reembeds_summary_chunk(
if not v[3]:
assert v == before[cid] # content chunks untouched, byte for byte
assert fake.calls == [[new]] # one embed call, the new text only
# The public viewer's data source now carries the new summary
# verbatim (anonymous — the viewer stays public, phase 16).
# The viewer's data source now carries the new summary verbatim
# (the ``client`` is signed in — phase 79 gated the viewer).
g = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
@@ -323,15 +338,17 @@ def test_summary_patch_404_unknown_pair(admin_client: TestClient, db: Session) -
db.commit()
def test_summary_patch_403_anonymous(client: TestClient, db: Session) -> None:
"""The edit affordance is admin-only (phase 57, D4 — the viewer stays
public): an anonymous PATCH gets 403 ``admin only`` and touches
nothing."""
def test_summary_patch_403_anonymous(db: Session) -> None:
"""The edit affordance is admin-only (phase 57, D4 — the viewer is
user-gated since phase 79): an anonymous PATCH gets 403 ``admin
only`` and touches nothing. A FRESH client — the module's autouse
fixture signed the shared one in."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
anonymous = TestClient(fastapi_app)
try:
r = client.patch(
r = anonymous.patch(
"/api/documents/summary",
json={
"source": "Homelab",
@@ -417,11 +434,9 @@ def test_content_200_all_fields(client, db) -> None:
def test_content_summary_surfaced_for_summarized_doc(client, db) -> None:
"""A non-markdown document with a phase-30 summary returns it verbatim
(phase 36 — the viewer's data contract gains the nullable field).
Anonymous by design: the ``client`` fixture carries no admin cookie,
so the 200 here re-confirms the phase-16 soft rule (public viewer).
"""
(phase 36 — the viewer's data contract gains the nullable field),
for a signed-in caller (phase 79 — the viewer is token-or-admin; the
anonymous 401 contract is pinned in ``test_auth_api.py``)."""
summary = (
"GitLab CE runs in a Podman compose stack on the homelab NAS with a "
"persistent volume for data and a backup job."
@@ -438,7 +453,7 @@ def test_content_summary_surfaced_for_summarized_doc(client, db) -> None:
"/api/documents/content",
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
)
assert r.status_code == 200 # anonymous (no cookie) — public viewer
assert r.status_code == 200
body = r.json()
assert body["summary"] == summary # verbatim, no wrapping
assert body["content"] == "services:\n gitlab:\n image: gitlab/gitlab-ce"
@@ -448,8 +463,8 @@ def test_content_summary_surfaced_for_summarized_doc(client, db) -> None:
def test_content_summary_null_for_markdown_doc(client, db) -> None:
"""Markdown documents carry no summary (phase 30) → JSON ``null``, and
anonymous access still returns 200 (phase 16 soft rule)."""
"""Markdown documents carry no summary (phase 30) → JSON ``null``
(signed-in caller — phase 79 gated the viewer)."""
_seed_doc(
db,
path="kubernetes.md",
@@ -461,7 +476,7 @@ def test_content_summary_null_for_markdown_doc(client, db) -> None:
"/api/documents/content",
params={"source": "Homelab", "path": "kubernetes.md"},
)
assert r.status_code == 200 # anonymous (no cookie) — public viewer
assert r.status_code == 200
body = r.json()
assert "summary" in body
assert body["summary"] is None
+12
View File
@@ -34,11 +34,23 @@ from app.models import Document, KbOverview
from app.rag.importer import import_sources
from app.rag.prompts import build_deflect_prompt, build_high_prompt
from app.rag.retriever import retrieve, weak_hit_titles
from tests.conftest import ADMIN_PASSWORD
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
OFF_TOPIC = "How do I bake sourdough bread?"
@pytest.fixture(autouse=True)
def _admin_signed_in(client: TestClient) -> None:
"""Phase 79 (task 03): ``POST /api/chat`` is user-gated — this module
reuses ``test_chat_api._stream_chat`` with the shared (module-local
to THIS file) ``client``, so it signs the admin in once per test
(the autouse in ``test_chat_api`` does not apply across the import).
"""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
#: A multi-line, multi-bullet outline: the section must carry it whole
#: (well within ``BOR_KB_OVERVIEW_MAX_CHARS``) and the per-turn log line
#: records its length.
+313
View File
@@ -0,0 +1,313 @@
"""Integration: migration 0012 (api_tokens) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0011.py`` (information_schema / pg_indexes assertions
on the state the migration must leave). The tests target revision
``0012`` explicitly so later migrations cannot break them:
* upgrade 0011 → 0012 → the ``api_tokens`` table exists with the full
column contract (``id`` UUID PK; ``label`` VARCHAR(120) NOT NULL;
``token_hash`` VARCHAR(64) NOT NULL + the UNIQUE index
``ix_api_tokens_token_hash`` — the stored credential;
``created_at`` TIMESTAMPTZ NOT NULL default now(); ``last_used_at`` /
``revoked_at`` TIMESTAMPTZ NULL);
* inserted rows round-trip: ``created_at`` is stamped server-side and
``last_used_at`` / ``revoked_at`` are NULL until the service
(tasks 02/03) sets them; explicit lifecycle values round-trip
verbatim;
* two identical token hashes are rejected by the unique index (the hash
is the unique lookup key), while a repeated ``label`` is fine
(display-only);
* downgrade to 0011 → the table and index are gone (A13 — reversible),
the rest of the schema (e.g. ``doc_drafts.token``) survives;
* upgrade back to 0012 → the table and the unique index are back
(round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import hashlib
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
def _hash(token: str = "bor_0123456789abcdef0123456789abcdef") -> str:
"""The stored credential: the sha256 hex digest of the FULL token
string (always 64 hex chars — exactly what the String(64) column
width pins). The probes use fixed tokens distinct from any real
``bor_`` + 32-hex token an operator might hold."""
return hashlib.sha256(token.encode()).hexdigest()
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown upgrades
to head no matter what happened, so the dev DB is never left below
head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _table_exists(db: Session, table: str) -> bool:
count: Any = db.execute(
text(
"SELECT count(*) FROM information_schema.tables"
" WHERE table_schema = 'public' AND table_name = :t"
),
{"t": table},
).scalar()
assert count is not None, "information_schema count must be an int"
return int(count) == 1
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default, character_maximum_length)
for one table column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default, character_maximum_length"
" FROM information_schema.columns"
" WHERE table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
).fetchone()
return tuple(row) if row is not None else None
def _unique_hash_index(db: Session) -> int:
"""1 iff ``ix_api_tokens_token_hash`` exists as a UNIQUE index."""
count: Any = db.execute(
text(
"SELECT count(*) FROM pg_indexes"
" WHERE tablename = 'api_tokens'"
" AND indexname = 'ix_api_tokens_token_hash'"
),
).scalar()
assert count is not None, "pg_indexes count must be an int"
is_unique: Any = db.execute(
text(
"SELECT indisunique FROM pg_index"
" WHERE indexrelid ="
" (SELECT oid FROM pg_class WHERE relname = 'ix_api_tokens_token_hash')"
),
).scalar()
return int(count) if is_unique else 0
def _insert(db: Session, *, label: str, token_hash: str | None = None) -> uuid.UUID:
"""Insert one api_tokens row. ``token_hash=None`` is not a valid
state (NOT NULL) — the migration carries no server default; the
service (task 02) always supplies the digest of the full token."""
sql = (
"INSERT INTO api_tokens (id, label, token_hash) "
"VALUES (gen_random_uuid(), :l, :h) RETURNING id"
)
token_id: uuid.UUID = db.execute(
text(sql), {"l": label, "h": token_hash or _hash()}
).scalar_one()
db.commit()
return token_id
def _delete(db: Session, token_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM api_tokens WHERE id = :i"), {"i": token_id})
db.commit()
def test_upgrade_to_0012_adds_api_tokens(db: Session, alembic: Config) -> None:
"""Upgrade 0011 → 0012: the table + the unique token-hash index
exist with the full column contract; the table is absent at 0011."""
command.downgrade(alembic, "0011") # start from the pre-0012 state
assert _version(db) == "0011"
assert not _table_exists(db, "api_tokens"), "api_tokens must be absent at 0011"
assert _unique_hash_index(db) == 0, "the token-hash index must be absent at 0011"
command.upgrade(alembic, "0012")
assert _version(db) == "0012", "alembic_version must be at 0012"
assert _table_exists(db, "api_tokens"), "api_tokens must exist at 0012"
id_col = _column(db, "api_tokens", "id")
assert id_col is not None, "api_tokens.id is missing"
assert id_col[0] == "uuid", "api_tokens.id must be UUID"
assert id_col[1] == "NO", "api_tokens.id must be NOT NULL (PK)"
label = _column(db, "api_tokens", "label")
assert label is not None, "api_tokens.label is missing"
assert label[0] == "character varying", "api_tokens.label must be VARCHAR"
assert label[1] == "NO", "api_tokens.label must be NOT NULL"
assert label[3] == 120, "api_tokens.label must be String(120)"
token_hash = _column(db, "api_tokens", "token_hash")
assert token_hash is not None, "api_tokens.token_hash is missing"
assert token_hash[0] == "character varying", "api_tokens.token_hash must be VARCHAR"
assert token_hash[1] == "NO", "api_tokens.token_hash must be NOT NULL"
assert token_hash[3] == 64, (
"api_tokens.token_hash must be String(64) — the sha256 hex digest"
)
assert _unique_hash_index(db) == 1, "the unique token-hash index is missing"
created = _column(db, "api_tokens", "created_at")
assert created is not None, "api_tokens.created_at is missing"
assert created[0] == "timestamp with time zone", (
"api_tokens.created_at must be TIMESTAMPTZ"
)
assert created[1] == "NO", "api_tokens.created_at must be NOT NULL"
assert str(created[2]).startswith("now("), (
"api_tokens.created_at must have server default now()"
)
for name in ("last_used_at", "revoked_at"):
col = _column(db, "api_tokens", name)
assert col is not None, f"api_tokens.{name} is missing"
assert col[0] == "timestamp with time zone", (
f"api_tokens.{name} must be TIMESTAMPTZ"
)
assert col[1] == "YES", f"api_tokens.{name} must be NULL until set"
def test_inserted_rows_round_trip_the_lifecycle_states(
db: Session, alembic: Config
) -> None:
"""At 0012, an inserted row has a server-stamped ``created_at`` and
NULL ``last_used_at`` / ``revoked_at`` (the fresh-credential state);
explicit lifecycle values round-trip verbatim (the service's
mark_used / revoke paths, tasks 02/03)."""
command.upgrade(alembic, "head")
token_id = _insert(db, label="alice", token_hash=_hash())
try:
row = db.execute(
text(
"SELECT label, token_hash, created_at, last_used_at, revoked_at"
" FROM api_tokens WHERE id = :i"
),
{"i": token_id},
).fetchone()
assert row is not None, "the token row must exist"
assert row[0] == "alice", "the label must round-trip verbatim"
assert row[1] == _hash(), "the token hash must round-trip verbatim"
assert row[2] is not None, "created_at must be stamped server-side"
assert row[3] is None, "last_used_at must be NULL until first use"
assert row[4] is None, "revoked_at must be NULL while active"
# The service's lifecycle updates (mark_used / revoke) round-trip.
db.execute(
text(
"UPDATE api_tokens SET last_used_at = now(), revoked_at = now()"
" WHERE id = :i"
),
{"i": token_id},
)
db.commit()
used = db.execute(
text(
"SELECT last_used_at, revoked_at FROM api_tokens WHERE id = :i"
),
{"i": token_id},
).fetchone()
assert used is not None, "the updated row must exist"
assert used[0] is not None and used[1] is not None, (
"last_used_at/revoked_at must round-trip explicit values"
)
finally:
_delete(db, token_id)
def test_unique_index_rejects_duplicate_hashes_label_is_not_unique(
db: Session, alembic: Config
) -> None:
"""Two identical token hashes are rejected by the unique index —
the hash is the unique lookup key (the share-token precedent,
phase 51); a repeated ``label`` is fine (display-only)."""
command.upgrade(alembic, "head")
dup_hash = _hash("bor_11111111111111111111111111111111")
first_id = _insert(db, label="alice", token_hash=dup_hash)
other_id: uuid.UUID | None = None
twin_id: uuid.UUID | None = None
try:
try:
_insert(db, label="bob", token_hash=dup_hash)
except IntegrityError:
db.rollback() # the aborted transaction must not leak
else:
pytest.fail("a duplicate api_tokens.token_hash must be rejected")
# A different hash is fine — only the exact duplicate is unique.
other_id = _insert(db, label="bob", token_hash=_hash("bor_deadbeef" * 4))
# The same label under a different hash is fine — display-only.
twin_id = _insert(db, label="alice", token_hash=_hash("bor_cafebabe" * 4))
finally:
_delete(db, first_id)
if other_id is not None:
_delete(db, other_id)
if twin_id is not None:
_delete(db, twin_id)
def test_downgrade_to_0011_drops_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0011: the table and the unique index are gone
(A13 — reversible) while the rest of the schema survives."""
command.downgrade(alembic, "0011")
assert _version(db) == "0011"
assert not _table_exists(db, "api_tokens"), "api_tokens must be dropped"
assert _unique_hash_index(db) == 0, "the token-hash index must be dropped"
token_col = _column(db, "doc_drafts", "token")
assert token_col is not None and token_col[0] == "uuid", (
"doc_drafts.token must survive the downgrade"
)
meta = _column(db, "saved_chats", "share_token")
assert meta is not None and meta[0] == "uuid", (
"saved_chats.share_token must survive the downgrade"
)
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0011, then upgrade back to 0012: the table and the
unique index are back."""
command.downgrade(alembic, "0011")
command.upgrade(alembic, "0012")
assert _version(db) == "0012", "round-trip upgrade must land at 0012"
assert _table_exists(db, "api_tokens"), "api_tokens must be back"
assert _unique_hash_index(db) == 1, "the unique token-hash index must be back"
token_hash = _column(db, "api_tokens", "token_hash")
assert token_hash is not None and token_hash[1] == "NO", (
"token_hash must be VARCHAR NOT NULL after the round-trip"
)
assert token_hash[3] == 64, "token_hash must be String(64) after the round-trip"
created = _column(db, "api_tokens", "created_at")
assert created is not None and created[1] == "NO", (
"created_at must be TIMESTAMPTZ NOT NULL after the round-trip"
)
assert str(created[2]).startswith("now("), (
"created_at must default to now() after the round-trip"
)
+318
View File
@@ -0,0 +1,318 @@
"""Integration: the onboarding-chips endpoint (phase 80, task 01) —
the full state matrix of ``GET /api/suggestions``.
The chips are the **last 3 questions asked** — the three most recent
user questions across ALL saved chats: chats are walked newest-
``updated_at`` first (``created_at`` tiebreak), each chat's
``bor.chat.v1`` message list is walked newest-first, exact-
(case-sensitive) de-duplicated, capped at 3. A fresh deployment —
zero saved questions — gets the SEED list instead
(``get_settings().suggestions``: the ``BOR_SUGGESTIONS`` override or
the built-in default). The override's JSON parsing is pinned at unit
level (``tests/unit/test_config.py``), so this suite stays
env-agnostic: the empty-DB contract is "exactly
``get_settings().suggestions``, whatever the environment makes that".
Matrix (task item 2):
* empty DB → exactly ``get_settings().suggestions``;
* cap + order: 4 questions in ONE chat → the 3 newest, newest first;
* chat order: two chats with DISTINCT ``updated_at`` (stamped
explicitly) → the newer chat's questions outrank the older chat's
newest-LOOKING question;
* dedup: the same text asked in two chats → exactly once; a
differently-cased variant is KEPT (exact dedup);
* partial: 1–2 saved questions deployment-wide → exactly those chips
(NO seed top-up — the A6 contract);
* brain-only: all-``brain`` (or blank user texts) contribute nothing;
an all-brain deployment → the seed;
* anonymous → 401 ``authentication required`` (the phase-79 contract,
pinned here too).
The deflection "Maybe try" chips (``app.rag.suggestions.
derive_suggestions``) are a SEPARATE contract — untouched.
Real Postgres (``podman compose up -d db``).
Requires: podman compose up -d db
"""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import SavedChat
#: Fixed question texts — the matrix asserts EXACT chip lists, so the
#: texts are distinct per purpose.
Q_ONE = "How did I install gitlab?"
Q_TWO = "Which node runs my Borg backups?"
Q_THREE = "How do I prune deleted docs?"
Q_FOUR = "What proxy fronts reeseapps.com?"
Q_FIVE = "How is my K3S cluster set up?"
Q_SIX = "How do I deploy a service?"
@pytest.fixture(autouse=True)
def clean_chats(db: Session) -> Iterator[None]:
"""``saved_chats`` is global state: reset around every test."""
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
db.execute(text("TRUNCATE saved_chats"))
db.commit()
def _user(text: str) -> dict[str, Any]:
return {"who": "user", "text": text}
def _brain(text: str = "You've got this!") -> dict[str, Any]:
return {"who": "brain", "text": text}
def _add_chat(
db: Session,
*,
title: str,
messages: list[dict[str, Any]],
updated_at: datetime,
) -> None:
"""One saved-chat row with EXPLICIT ``created_at``/``updated_at``
stamps (deterministic order — no reliance on ``now()``
resolution)."""
db.add(
SavedChat(
title=title,
messages=messages,
created_at=updated_at,
updated_at=updated_at,
)
)
db.commit()
def _chips(admin_client: TestClient) -> list[str]:
r = admin_client.get("/api/suggestions")
assert r.status_code == 200, r.text
return r.json()["suggestions"]
# ---------- empty DB: the seed ----------
def test_empty_db_returns_seed(admin_client: TestClient) -> None:
"""Zero saved questions → exactly the seed list — env-agnostic:
``get_settings().suggestions`` (the ``BOR_SUGGESTIONS`` override or
the built-in default, whatever the environment makes it)."""
r = admin_client.get("/api/suggestions")
assert r.status_code == 200
assert r.json() == {"suggestions": get_settings().suggestions}
# ---------- cap + order within one chat ----------
def test_cap_three_and_newest_first_within_a_chat(
admin_client: TestClient, db: Session
) -> None:
"""4 user questions (brain replies between them) in ONE chat →
exactly the 3 NEWEST, newest first."""
_add_chat(
db,
title="one long chat",
messages=[
_user(Q_ONE), _brain("a1"),
_user(Q_TWO), _brain("a2"),
_user(Q_THREE), _brain("a3"),
_user(Q_FOUR), _brain("a4"),
],
updated_at=datetime.now(UTC),
)
assert _chips(admin_client) == [Q_FOUR, Q_THREE, Q_TWO]
# ---------- chat order across chats ----------
def test_newer_chat_walked_first(admin_client: TestClient, db: Session) -> None:
"""Two chats with DISTINCT ``updated_at`` (stamped explicitly):
the newer chat is walked FIRST — its single question outranks the
older chat's newest-LOOKING (last-in-conversation) question."""
base = datetime.now(UTC)
_add_chat(
db,
title="older chat",
messages=[_user(Q_FIVE), _brain("…"), _user(Q_SIX), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="newer chat",
messages=[_user(Q_ONE), _brain("…")],
updated_at=base + timedelta(hours=2),
)
# Newer chat first (Q_ONE), then the older chat newest-first
# (Q_SIX — its LAST question — before Q_FIVE).
assert _chips(admin_client) == [Q_ONE, Q_SIX, Q_FIVE]
# ---------- dedup ----------
def test_verbatim_reask_counts_once_across_chats(
admin_client: TestClient, db: Session
) -> None:
"""The SAME question text asked in two chats appears EXACTLY ONCE
in the chips."""
base = datetime.now(UTC)
_add_chat(
db,
title="older",
messages=[_user(Q_THREE), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="newer",
messages=[
_user(Q_ONE), _brain("…"),
_user(Q_THREE), _brain("…"), # verbatim re-ask (newer chat)
],
updated_at=base + timedelta(hours=2),
)
# Newest first: the re-ask (LAST message of the newer chat) leads —
# and it appears exactly once (the older chat's copy is deduped).
chips = _chips(admin_client)
assert chips == [Q_THREE, Q_ONE]
assert chips.count(Q_THREE) == 1
def test_dedup_is_exact_not_case_insensitive(
admin_client: TestClient, db: Session
) -> None:
"""A differently-cased re-ask is a DIFFERENT question (exact,
case-sensitive dedup — case-insensitive would drop it): both
variants show, and the verbatim re-ask in the older chat still
counts once."""
base = datetime.now(UTC)
lower_variant = Q_THREE.lower()
_add_chat(
db,
title="older",
messages=[_user(Q_THREE), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="newer",
messages=[
_user(lower_variant), _brain("…"),
_user(Q_THREE), _brain("…"),
_user(Q_ONE), _brain("…"),
],
updated_at=base + timedelta(hours=2),
)
# Newer chat walked newest-first: Q_ONE, Q_THREE, lower_variant —
# all three kept (the case variant is NOT a duplicate).
assert _chips(admin_client) == [Q_ONE, Q_THREE, lower_variant]
# ---------- partial: no seed top-up ----------
def test_exactly_two_questions_give_exactly_two_chips(
admin_client: TestClient, db: Session
) -> None:
"""1–2 saved questions deployment-wide → EXACTLY those chips — NO
mixing/top-up with the seed (the A6 contract)."""
base = datetime.now(UTC)
_add_chat(
db,
title="a",
messages=[_user(Q_TWO), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="b",
messages=[_user(Q_ONE), _brain("…")],
updated_at=base + timedelta(hours=1),
)
assert _chips(admin_client) == [Q_ONE, Q_TWO]
def test_exactly_one_question_gives_exactly_one_chip(
admin_client: TestClient, db: Session
) -> None:
"""The 1-question boundary of the same contract: exactly one chip,
never padded toward the cap or mixed with the seed."""
_add_chat(
db,
title="a",
messages=[_user(Q_TWO), _brain("…")],
updated_at=datetime.now(UTC),
)
assert _chips(admin_client) == [Q_TWO]
# ---------- brain-only / blank user texts ----------
def test_brain_and_blank_user_texts_contribute_nothing(
admin_client: TestClient, db: Session
) -> None:
"""A chat whose messages are all ``who == "brain"`` (plus a blank
user text) contributes NOTHING: the chips hold exactly the one
real question from the other chat — no brain text, no blank, no
seed top-up."""
base = datetime.now(UTC)
_add_chat(
db,
title="brain only + blank user",
messages=[
_brain("just brain talking"),
_user(" "), # blank user text — skipped
_brain("more brain"),
],
updated_at=base,
)
_add_chat(
db,
title="real question",
messages=[_user(Q_FOUR), _brain("…")],
updated_at=base + timedelta(hours=1),
)
assert _chips(admin_client) == [Q_FOUR]
def test_all_brain_deployment_returns_seed(admin_client: TestClient, db: Session) -> None:
"""A deployment with ONLY brain/blank conversations (zero saved
questions) → the full seed list."""
_add_chat(
db,
title="all brain",
messages=[_brain("a"), _user("\t"), _brain("b")],
updated_at=datetime.now(UTC),
)
r = admin_client.get("/api/suggestions")
assert r.status_code == 200
assert r.json() == {"suggestions": get_settings().suggestions}
# ---------- auth pin (phase 79) ----------
def test_anonymous_is_401(client: TestClient) -> None:
"""The phase-79 contract, pinned here too: an unsigned-in caller
cannot read the chips."""
r = client.get("/api/suggestions")
assert r.status_code == 401
assert r.json() == {"detail": "authentication required"}
+198
View File
@@ -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"]]