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
+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)