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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user