diff --git a/.agents/phases/todo/77_navbar_refresh/00_phase.md b/.agents/phases/complete/77_navbar_refresh/00_phase.md similarity index 100% rename from .agents/phases/todo/77_navbar_refresh/00_phase.md rename to .agents/phases/complete/77_navbar_refresh/00_phase.md diff --git a/.agents/phases/todo/77_navbar_refresh/01_router_refresh_hook.md b/.agents/phases/complete/77_navbar_refresh/01_router_refresh_hook.md similarity index 100% rename from .agents/phases/todo/77_navbar_refresh/01_router_refresh_hook.md rename to .agents/phases/complete/77_navbar_refresh/01_router_refresh_hook.md diff --git a/.agents/phases/todo/77_navbar_refresh/02_refresh_other_views.md b/.agents/phases/complete/77_navbar_refresh/02_refresh_other_views.md similarity index 100% rename from .agents/phases/todo/77_navbar_refresh/02_refresh_other_views.md rename to .agents/phases/complete/77_navbar_refresh/02_refresh_other_views.md diff --git a/.agents/phases/todo/77_navbar_refresh/03_history_refresh_button.md b/.agents/phases/complete/77_navbar_refresh/03_history_refresh_button.md similarity index 100% rename from .agents/phases/todo/77_navbar_refresh/03_history_refresh_button.md rename to .agents/phases/complete/77_navbar_refresh/03_history_refresh_button.md diff --git a/.agents/phases/todo/78_static_background/00_phase.md b/.agents/phases/complete/78_static_background/00_phase.md similarity index 100% rename from .agents/phases/todo/78_static_background/00_phase.md rename to .agents/phases/complete/78_static_background/00_phase.md diff --git a/.agents/phases/todo/78_static_background/01_remove_glow_layers.md b/.agents/phases/complete/78_static_background/01_remove_glow_layers.md similarity index 100% rename from .agents/phases/todo/78_static_background/01_remove_glow_layers.md rename to .agents/phases/complete/78_static_background/01_remove_glow_layers.md diff --git a/.agents/phases/todo/78_static_background/02_regression_sweep_commit.md b/.agents/phases/complete/78_static_background/02_regression_sweep_commit.md similarity index 100% rename from .agents/phases/todo/78_static_background/02_regression_sweep_commit.md rename to .agents/phases/complete/78_static_background/02_regression_sweep_commit.md diff --git a/.agents/phases/todo/79_api_tokens/00_phase.md b/.agents/phases/complete/79_api_tokens/00_phase.md similarity index 100% rename from .agents/phases/todo/79_api_tokens/00_phase.md rename to .agents/phases/complete/79_api_tokens/00_phase.md diff --git a/.agents/phases/todo/79_api_tokens/01_token_model_migration.md b/.agents/phases/complete/79_api_tokens/01_token_model_migration.md similarity index 100% rename from .agents/phases/todo/79_api_tokens/01_token_model_migration.md rename to .agents/phases/complete/79_api_tokens/01_token_model_migration.md diff --git a/.agents/phases/todo/79_api_tokens/02_token_admin_api.md b/.agents/phases/complete/79_api_tokens/02_token_admin_api.md similarity index 100% rename from .agents/phases/todo/79_api_tokens/02_token_admin_api.md rename to .agents/phases/complete/79_api_tokens/02_token_admin_api.md diff --git a/.agents/phases/todo/79_api_tokens/03_token_auth_enforcement.md b/.agents/phases/complete/79_api_tokens/03_token_auth_enforcement.md similarity index 100% rename from .agents/phases/todo/79_api_tokens/03_token_auth_enforcement.md rename to .agents/phases/complete/79_api_tokens/03_token_auth_enforcement.md diff --git a/.agents/phases/todo/79_api_tokens/04_migrate_anonymous_e2e.md b/.agents/phases/complete/79_api_tokens/04_migrate_anonymous_e2e.md similarity index 100% rename from .agents/phases/todo/79_api_tokens/04_migrate_anonymous_e2e.md rename to .agents/phases/complete/79_api_tokens/04_migrate_anonymous_e2e.md diff --git a/.agents/phases/todo/79_api_tokens/05_frontend_token_gate.md b/.agents/phases/complete/79_api_tokens/05_frontend_token_gate.md similarity index 100% rename from .agents/phases/todo/79_api_tokens/05_frontend_token_gate.md rename to .agents/phases/complete/79_api_tokens/05_frontend_token_gate.md diff --git a/.agents/phases/todo/79_api_tokens/06_tokens_admin_view.md b/.agents/phases/complete/79_api_tokens/06_tokens_admin_view.md similarity index 100% rename from .agents/phases/todo/79_api_tokens/06_tokens_admin_view.md rename to .agents/phases/complete/79_api_tokens/06_tokens_admin_view.md diff --git a/.agents/phases/todo/79_api_tokens/07_e2e_story_suite.md b/.agents/phases/complete/79_api_tokens/07_e2e_story_suite.md similarity index 100% rename from .agents/phases/todo/79_api_tokens/07_e2e_story_suite.md rename to .agents/phases/complete/79_api_tokens/07_e2e_story_suite.md diff --git a/.agents/phases/todo/79_api_tokens/08_regression_sweep_commit.md b/.agents/phases/complete/79_api_tokens/08_regression_sweep_commit.md similarity index 100% rename from .agents/phases/todo/79_api_tokens/08_regression_sweep_commit.md rename to .agents/phases/complete/79_api_tokens/08_regression_sweep_commit.md diff --git a/.agents/phases/todo/80_history_suggestion_chips/00_phase.md b/.agents/phases/complete/80_history_suggestion_chips/00_phase.md similarity index 100% rename from .agents/phases/todo/80_history_suggestion_chips/00_phase.md rename to .agents/phases/complete/80_history_suggestion_chips/00_phase.md diff --git a/.agents/phases/todo/80_history_suggestion_chips/01_last_questions_endpoint.md b/.agents/phases/complete/80_history_suggestion_chips/01_last_questions_endpoint.md similarity index 100% rename from .agents/phases/todo/80_history_suggestion_chips/01_last_questions_endpoint.md rename to .agents/phases/complete/80_history_suggestion_chips/01_last_questions_endpoint.md diff --git a/.agents/phases/todo/80_history_suggestion_chips/02_seed_semantics_docs.md b/.agents/phases/complete/80_history_suggestion_chips/02_seed_semantics_docs.md similarity index 100% rename from .agents/phases/todo/80_history_suggestion_chips/02_seed_semantics_docs.md rename to .agents/phases/complete/80_history_suggestion_chips/02_seed_semantics_docs.md diff --git a/.agents/phases/todo/80_history_suggestion_chips/03_chips_new_chat_refetch.md b/.agents/phases/complete/80_history_suggestion_chips/03_chips_new_chat_refetch.md similarity index 100% rename from .agents/phases/todo/80_history_suggestion_chips/03_chips_new_chat_refetch.md rename to .agents/phases/complete/80_history_suggestion_chips/03_chips_new_chat_refetch.md diff --git a/.agents/phases/todo/80_history_suggestion_chips/04_e2e_suite_commit.md b/.agents/phases/complete/80_history_suggestion_chips/04_e2e_suite_commit.md similarity index 100% rename from .agents/phases/todo/80_history_suggestion_chips/04_e2e_suite_commit.md rename to .agents/phases/complete/80_history_suggestion_chips/04_e2e_suite_commit.md diff --git a/.agents/reports/76_spa_nav_shell/76_spa_nav_shell__06_regression_sweep_commit.a1.md b/.agents/reports/76_spa_nav_shell/76_spa_nav_shell__06_regression_sweep_commit.a1.md new file mode 100644 index 0000000..4e68d9b --- /dev/null +++ b/.agents/reports/76_spa_nav_shell/76_spa_nav_shell__06_regression_sweep_commit.a1.md @@ -0,0 +1,8 @@ +**Task 06 (phase 76 final) — complete.** + +- **Gates (item 1):** `uv run pytest --cov=app` → 1565 passed, app/ **99%** (>90%); `uv run ruff check . && uv run pyright` → clean; isolation spot-checks — all 8 watch-list suites **PASS** (nav_switch_keeps_stream, sources_midstream_bug, hidden_tab_stream, stop_generation, chat_persistence, chat_history, header_consistency, cache_busting). +- **Full E2E batch:** 286 passed / 23 failed in one process; all 23 re-ran **green in isolation** (11 files, +8 watch-list = 19 suites PASS) — the known pre-existing port-sharing artifact (documented in this phase's task-03 report; AGENTS.md rule 9 = isolation is the protocol). No phase-76 regressions found; no prior defects to fix. +- **Owner repro (real browser, real LLM, :8010):** `tell me about everquest` → RAG mid-stream → Chat: window sentinel survived (same document), FULL answer, **one** brain bubble, no error banner, **+1 settled `query_log` row**, zero `turn cancelled` lines; auto-saved row matches. Control (real nav to `/shared.html` mid-stream): sentinel wiped, no settled row, cancel line logged, partial persisted. 9 screenshots in `.agents/screenshots/` (tracked, committed). +- **Commit:** `ffa919b fix(chat): keep in-flight answers alive across in-app view switches` (`--no-gpg-sign`, 78 files) — body records root cause, the owner-confirmed phase-48 refinement, Option A over B/C, boundaries, and the phase-20 suite rewrite. Phase dir moved to `.agents/phases/complete/`; `git status` clean. +- **Deviations:** n/a file is empty (no further phases queued); used `/shared.html` for the control (not `/login.html`, which auto-redirects admins) — same real-departure control the suite pins; the repro's answer was an honest deflection (`deflected=True`) since the dev KB holds no EverQuest docs — the settle (not the topic) is the proof, as expected post-e2e fixture seeding. +- **Next pending task:** none — phase 76 complete; `todo/` is empty. diff --git a/.agents/reports/76_spa_nav_shell/76_spa_nav_shell__06_regression_sweep_commit.a1.validate b/.agents/reports/76_spa_nav_shell/76_spa_nav_shell__06_regression_sweep_commit.a1.validate new file mode 100644 index 0000000..683ec31 --- /dev/null +++ b/.agents/reports/76_spa_nav_shell/76_spa_nav_shell__06_regression_sweep_commit.a1.validate @@ -0,0 +1,78 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 23%] +........................................................................ [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 46%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 64%] +........................................................................ [ 69%] +........................................................................ [ 73%] +........................................................................ [ 78%] +........................................................................ [ 82%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +..................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 60 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 210 0 100% +----------------------------------------------- +TOTAL 2997 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__00_phase.a1.err b/.agents/reports/77_navbar_refresh/77_navbar_refresh__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__00_phase.a1.md b/.agents/reports/77_navbar_refresh/77_navbar_refresh__00_phase.a1.md new file mode 100644 index 0000000..6785c09 --- /dev/null +++ b/.agents/reports/77_navbar_refresh/77_navbar_refresh__00_phase.a1.md @@ -0,0 +1,12 @@ +All gates green. This was the final verification pass — every task (01–03) was already implemented in the working tree and its task files were already staged in `.agents/phases/complete/77_navbar_refresh/`; I verified the implementation against the task specs, the E2E suite, and the unit pins, and ran every completion criterion. No defects found; no fixes needed. + +**Phase 77 — final verification report** + +- Verified (already implemented in working tree): `bor:view-refresh` dispatch in `router.js` (re-show + active re-click + popstate, `wasMounted` pre-mount gate, first-show/boot exempt); re-entrant re-fetch in History/RAG/Sources/Tuning (admin-branch listeners, Chat excluded with pinning comment); `#history-refresh` button (page-head, admin-only, in-flight disable + `#history-status` announce); unit pins in `test_frontend_router.py`; new story suite `test_navbar_refresh.py` (7 tests incl. sentinel, no-pushState, request-log, stream-survival control). +- `uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov` → **7 passed** (17.6s, isolated, DB up). ✅ +- `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` → **4 passed**; file byte-identical (no git diff). ✅ +- `uv run pytest --cov=app --cov-report=term-missing` → **1575 passed**, TOTAL coverage **99%** (>90% floor). ✅ +- `uv run ruff check . && uv run pyright` → clean (0 errors). ✅ +- Commit/phase-dir move: left to the harness per executor rules (task files already in `complete/`; no `git add`/`commit` run by me). +- Notable: pre-existing uncommitted changes to `scripts/*` and phase-76 report artifacts are in the tree from earlier work — untouched, will ride along in the harness commit. +- Next pending phase: `78_static_background`. diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__00_phase.a1.validate b/.agents/reports/77_navbar_refresh/77_navbar_refresh__00_phase.a1.validate new file mode 100644 index 0000000..88a4837 --- /dev/null +++ b/.agents/reports/77_navbar_refresh/77_navbar_refresh__00_phase.a1.validate @@ -0,0 +1,78 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 45%] +........................................................................ [ 50%] +........................................................................ [ 54%] +........................................................................ [ 59%] +........................................................................ [ 64%] +........................................................................ [ 68%] +........................................................................ [ 73%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 86%] +........................................................................ [ 91%] +........................................................................ [ 96%] +............................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 60 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 210 0 100% +----------------------------------------------- +TOTAL 2997 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__01_router_refresh_hook.a1.err b/.agents/reports/77_navbar_refresh/77_navbar_refresh__01_router_refresh_hook.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__01_router_refresh_hook.a1.md b/.agents/reports/77_navbar_refresh/77_navbar_refresh__01_router_refresh_hook.a1.md new file mode 100644 index 0000000..759d28d --- /dev/null +++ b/.agents/reports/77_navbar_refresh/77_navbar_refresh__01_router_refresh_hook.a1.md @@ -0,0 +1,19 @@ +Task 01 complete. All gates green. + +**Implemented** +- `frontend/assets/router.js`: `switchTo` captures `const wasMounted = mounted[name]` before the mount block and dispatches `bor:view-refresh` on the view's section after show + head/nav state (before the focus tail), gated on `wasMounted` — first show and boot never fire it; popstate inherits the gate. The `name === current` re-click branch now dispatches the event on `viewEls[name]` and returns (no `pushState`, menu-close unchanged). Header contract comment updated. +- `frontend/assets/history.js`: `loadChats()` is re-entrant (drops every `tr` except `#history-empty-row` before fetching — replaces, never duplicates); the ADMIN branch (after the whoami gate) arms `root.addEventListener("bor:view-refresh", () => { if (started) loadChats(); })` with `started` flipping true at the first `loadChats()`; anonymous branch never arms a listener. +- `tests/unit/test_frontend_router.py`: 3 new source pins — event literal + dispatch inside the pre-mount `wasMounted` capture with visible→refresh→focus ordering; re-click branch dispatches (no pushState/switchTo); history listener admin-branch-only + gated + row-clearing precedes fetch. +- Fixed 9 pre-existing ruff errors in `scripts/` (left by commit f221b10) so the gate is green. + +**Results** +- `uv run pytest tests/unit/test_frontend_router.py -v` → 16 passed +- `uv run pytest` → 1568 passed +- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90% floor) +- `uv run ruff check . && uv run pyright` → clean +- `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` → 4 passed (phase-76 stream-survival contract holds with the hook live, runs the new router code in a real browser) +- `node --check` on both edited JS files → OK + +**Decisions**: followed the phase design exactly (gate on `wasMounted` only — boot-onto-chat dispatch is harmless since Chat never listens); optional manual spot-check covered by the phase-76 E2E + syntax checks (the dedicated freshness E2E lands in task 03). No deviations. + +**Next pending task**: `.agents/phases/todo/77_navbar_refresh/02_refresh_other_views.md` diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__01_router_refresh_hook.a1.validate b/.agents/reports/77_navbar_refresh/77_navbar_refresh__01_router_refresh_hook.a1.validate new file mode 100644 index 0000000..8f4fc3c --- /dev/null +++ b/.agents/reports/77_navbar_refresh/77_navbar_refresh__01_router_refresh_hook.a1.validate @@ -0,0 +1,78 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 45%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 64%] +........................................................................ [ 68%] +........................................................................ [ 73%] +........................................................................ [ 78%] +........................................................................ [ 82%] +........................................................................ [ 87%] +........................................................................ [ 91%] +........................................................................ [ 96%] +........................................................ [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 60 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 210 0 100% +----------------------------------------------- +TOTAL 2997 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__02_refresh_other_views.a1.err b/.agents/reports/77_navbar_refresh/77_navbar_refresh__02_refresh_other_views.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__02_refresh_other_views.a1.md b/.agents/reports/77_navbar_refresh/77_navbar_refresh__02_refresh_other_views.a1.md new file mode 100644 index 0000000..e5cb83a --- /dev/null +++ b/.agents/reports/77_navbar_refresh/77_navbar_refresh__02_refresh_other_views.a1.md @@ -0,0 +1,18 @@ +All green. Final report: + +**Implemented (task 02 — RAG/Sources/Tuning join the refresh, Chat stays out):** +- `sources.js`: `loadDocs()` re-entrant — `tbody.replaceChildren()` moved to the top (before fetch; removed the redundant populated-path clear) so populated→empty refreshes leave no ghost rows; `bor:view-refresh` listener armed in the admin branch after the whoami gate +- `git-sources.js`: listener `() => loadSources()` in admin branch; verified a re-call resets all three states (success path runs `hideLoadError()` before `renderSources`, which replaces the tbody) +- `tuning.js`: listener `() => loadNotes()` in admin branch; failed-refresh keeps last rendered list (documented contract, unchanged) +- `app.js`: NO listener — one-line comment at the module-scope state pinning the deliberate exclusion (phase-76 stream survival) +- `tests/unit/test_frontend_router.py`: 4 new pins — per-view listener + gate ordering + re-entrancy checks, plus the app.js negative pin (targets the listener, not the literal, since the exclusion comment carries the literal) + +**Results:** +- `uv run pytest tests/unit/test_frontend_router.py -v --no-cov` → 20 passed +- `uv run pytest --cov=app --cov-report=term-missing` → 1572 passed; `app/` coverage 99% (>90%) +- `uv run ruff check . && uv run pyright` → clean (0 errors) +- `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` → 4 passed (unchanged, stream-survival holds) + +**Decisions:** git-sources listener placed before `await loadSources()` to preserve the existing "LAST statement" unit pin; no `started` flag needed (listener armed post-gate, per task text). + +**Next pending task:** `77_navbar_refresh/03_history_refresh_button.md` (History Refresh button + story E2E suite + regression sweep). diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__02_refresh_other_views.a1.validate b/.agents/reports/77_navbar_refresh/77_navbar_refresh__02_refresh_other_views.a1.validate new file mode 100644 index 0000000..76628e7 --- /dev/null +++ b/.agents/reports/77_navbar_refresh/77_navbar_refresh__02_refresh_other_views.a1.validate @@ -0,0 +1,78 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 45%] +........................................................................ [ 50%] +........................................................................ [ 54%] +........................................................................ [ 59%] +........................................................................ [ 64%] +........................................................................ [ 68%] +........................................................................ [ 73%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 87%] +........................................................................ [ 91%] +........................................................................ [ 96%] +............................................................ [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 60 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 210 0 100% +----------------------------------------------- +TOTAL 2997 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__03_history_refresh_button.a1.err b/.agents/reports/77_navbar_refresh/77_navbar_refresh__03_history_refresh_button.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__03_history_refresh_button.a1.md b/.agents/reports/77_navbar_refresh/77_navbar_refresh__03_history_refresh_button.a1.md new file mode 100644 index 0000000..e2906c8 --- /dev/null +++ b/.agents/reports/77_navbar_refresh/77_navbar_refresh__03_history_refresh_button.a1.md @@ -0,0 +1,19 @@ +Everything is complete and verified. Final state of all gates (run in the final file state): + +**Implemented (task 03):** +- `frontend/index.html` — History `.page-head` is now a flex row (title block left, actions right): `#history-refresh` button with the house inline-SVG refresh glyph (`aria-hidden`) + visible "Refresh" label, `aria-label="Refresh saved chats"`, outside the table wrap +- `frontend/assets/history.js` — admin-branch binding: click → disable → `loadChats()` → announce `Saved chats refreshed.` (0-row = success) / failure lines (`Couldn't load saved chats — is the app reachable?` / `— try again.` now live inside `loadChats`, so every caller sees them) → re-enable in `finally`; anonymous branch hides the button (no dead control) +- `frontend/assets/styles.css` — `.history-refresh` reuses the `.new-chat-btn` language (brand pill 5.2:1, ≥44px, hover, `:disabled`, global focus-visible); `#view-history .page-head` flex row scoped (other four views untouched); icon-only ≤640px (phase-46 convention) +- `tests/unit/test_frontend_router.py` — 3 new source pins (markup placement, admin-only binding + outcome lines, CSS language/mobile) +- `tests/e2e/test_navbar_refresh.py` — new story suite, 7 tests: re-show re-fetch (+ no-duplicates + same-doc sentinel), active re-click (no pushState, `history.length` unchanged), refresh button (disabled in-flight via the house request-hold pattern, status line, re-enable, keyboard focus), popstate re-fetch, four-views request-log (+History row-delta), stream-survival control (full mock answer + settled `query_log`), API row-shape sanity + +**Test / lint / coverage results:** +- `uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov` → 7 passed (isolated, DB up) +- `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` → 4 passed, unchanged +- `uv run pytest --cov=app --cov-report=term-missing` → 1575 passed, TOTAL 99% (>90% floor) +- `uv run ruff check . && uv run pyright` → clean +- Regression sweep green: `test_chat_history`, `test_share_chat`, `test_history_copy` (10), `test_stale_saved_chats`+`test_llm_history` (6), header/nav/cache/persistence (23), `test_hidden_tab_stream`+`test_stop_generation` (7) + +**Decisions/deviations:** failure announce moved into `loadChats` (no existing load-failure copy existed — house "reachable?/try again." family reused, and the §7.4 contract improved for all callers); anonymous hides the button (task's "admin branch only" read as gate-gated control, not a dead button); E2E request-log polling must use `page.wait_for_timeout` (sync API starves `page.on` events under raw `time.sleep` — documented in `_wait_until`); per harness rules the atomic commit + phase-dir move are left to the harness (task's commit item intentionally not executed). + +**Next pending task:** none in phase 77 (task 03 was the last); next phase in `todo/` is `78_static_background`. diff --git a/.agents/reports/77_navbar_refresh/77_navbar_refresh__03_history_refresh_button.a1.validate b/.agents/reports/77_navbar_refresh/77_navbar_refresh__03_history_refresh_button.a1.validate new file mode 100644 index 0000000..88a4837 --- /dev/null +++ b/.agents/reports/77_navbar_refresh/77_navbar_refresh__03_history_refresh_button.a1.validate @@ -0,0 +1,78 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 45%] +........................................................................ [ 50%] +........................................................................ [ 54%] +........................................................................ [ 59%] +........................................................................ [ 64%] +........................................................................ [ 68%] +........................................................................ [ 73%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 86%] +........................................................................ [ 91%] +........................................................................ [ 96%] +............................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 60 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 210 0 100% +----------------------------------------------- +TOTAL 2997 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/78_static_background/78_static_background__00_phase.a1.err b/.agents/reports/78_static_background/78_static_background__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/78_static_background/78_static_background__00_phase.a1.md b/.agents/reports/78_static_background/78_static_background__00_phase.a1.md new file mode 100644 index 0000000..da1712b --- /dev/null +++ b/.agents/reports/78_static_background/78_static_background__00_phase.a1.md @@ -0,0 +1,12 @@ +All checks green. Final report: + +**Phase 78 — Static background: verification pass complete (both tasks were already implemented; verified end-to-end, no defects found).** + +- Verified: glow rules (`body::after`, `html::before`, `html::after`), `@keyframes bg-glow-a/b/c`, and the background-only `prefers-reduced-motion` block deleted from `styles.css`; `body::before` grid rule byte-identical to HEAD (44px, 60% `--line` alpha, radial mask); phase-78 comment header in place; other 7 reduced-motion blocks + UI animations untouched; `frontend/assets/themes/` (indigo) glow-free. +- Verified: `tests/unit/test_background_no_motion.py` + `tests/e2e/test_background_no_motion.py` rewritten to the static contract; both `test_background_animation.py` suites deleted; no stale references. +- Tests: `uv run pytest` → 1561 passed · `uv run pytest --cov=app --cov-report=term-missing` → 99% on `app/` (>90%) · `uv run ruff check . && uv run pyright` → clean (0 errors) +- E2E in isolation (DB up): `test_background_no_motion.py` 5 passed · `test_dark_tech_theme.py` 6 passed · `test_smoke.py` 3 passed +- Visual check (real server :8791, Chromium): static grid over flat `--bg` canvas, zero glow spots, `document.getAnimations()` empty, reduced-motion profile identical (screenshots confirmed). +- Completion criteria: 1 no bg-glow/animation in CSS + grid renders — PASS · 2 suites green, deletions gone — PASS · 3 pytest/coverage/lint — PASS · 4 commit + phase-dir move — deferred to harness per executor rules (no git commands run). +- Deviation: `test_dark_tech_theme.py` had its 2 background tests + docstring re-pinned to the static contract (its glow-fade assertions could not survive the deletion); palette/emoji/behavior tests untouched — same house pattern as phase 76. +- Next pending phase: `79_api_tokens`. diff --git a/.agents/reports/78_static_background/78_static_background__00_phase.a1.validate b/.agents/reports/78_static_background/78_static_background__00_phase.a1.validate new file mode 100644 index 0000000..3ba569d --- /dev/null +++ b/.agents/reports/78_static_background/78_static_background__00_phase.a1.validate @@ -0,0 +1,78 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 23%] +........................................................................ [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 46%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 64%] +........................................................................ [ 69%] +........................................................................ [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +................................................. [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 60 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 210 0 100% +----------------------------------------------- +TOTAL 2997 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/78_static_background/78_static_background__01_remove_glow_layers.a1.err b/.agents/reports/78_static_background/78_static_background__01_remove_glow_layers.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/78_static_background/78_static_background__01_remove_glow_layers.a1.md b/.agents/reports/78_static_background/78_static_background__01_remove_glow_layers.a1.md new file mode 100644 index 0000000..3aaa1fb --- /dev/null +++ b/.agents/reports/78_static_background/78_static_background__01_remove_glow_layers.a1.md @@ -0,0 +1,10 @@ +All green. The remaining diff entries (`app.js`, `index.html`, `test_navbar_refresh.py`, etc.) are phase-77's uncommitted working-tree changes, not mine. + +**Report — phase 78, task 01 (remove glow layers)** + +- `styles.css`: deleted `body::after`/`html::before`/`html::after` glow rules, the three `bg-glow-*` keyframes, and the background-only reduced-motion block (7 unrelated reduced-motion blocks untouched); replaced the phase-08/25 header with a 4-line phase-78 note; `body::before` grid rule byte-identical. +- Rewrote `tests/unit/test_background_no_motion.py` (8 source pins: no `bg-*` token in CSS, glow rules absent, grid static + 44px + mask, no-occlusion plumbing, reduced-motion block gone, no blur) and `tests/e2e/test_background_no_motion.py` (5 Playwright computed-style tests incl. "no box" proof via `content: none`/`position: static`). +- Deleted `tests/unit/test_background_animation.py` + `tests/e2e/test_background_animation.py` (superseded chain 08→25→78). +- **Deviation:** `test_dark_tech_theme.py` could not stay "UNCHANGED" — it pinned `bg-glow-a/b/c` fades and glow images in `test_animated_background`/`test_reduced_motion_honored`, which the deletion breaks (and the literals violate the zero-hit rg criterion). Minimal contract-preserving update: renamed to `test_static_background` pinning the static contract, updated the reduced-motion test, dropped the now-unused `_seconds`/`pytest` import. All other tests in the file untouched. +- Tests: unit pins + themes 14 passed; e2e `test_background_no_motion.py` 5 passed in isolation; `test_dark_tech_theme.py` 6 passed; full `uv run pytest` 1561 passed; `--cov=app` **99%** (>90% floor); `uv run ruff check .` clean; `uv run pyright` 0 errors; `rg "bg-glow" app/ frontend/ tests/` zero hits; themes carry no glow/keyframes. +- Next pending task: `.agents/phases/todo/78_static_background/02_regression_sweep_commit.md`. diff --git a/.agents/reports/78_static_background/78_static_background__01_remove_glow_layers.a1.validate b/.agents/reports/78_static_background/78_static_background__01_remove_glow_layers.a1.validate new file mode 100644 index 0000000..3ba569d --- /dev/null +++ b/.agents/reports/78_static_background/78_static_background__01_remove_glow_layers.a1.validate @@ -0,0 +1,78 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 23%] +........................................................................ [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 46%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 64%] +........................................................................ [ 69%] +........................................................................ [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +................................................. [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 60 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 210 0 100% +----------------------------------------------- +TOTAL 2997 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/78_static_background/78_static_background__02_regression_sweep_commit.a1.err b/.agents/reports/78_static_background/78_static_background__02_regression_sweep_commit.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/78_static_background/78_static_background__02_regression_sweep_commit.a1.md b/.agents/reports/78_static_background/78_static_background__02_regression_sweep_commit.a1.md new file mode 100644 index 0000000..c54af51 --- /dev/null +++ b/.agents/reports/78_static_background/78_static_background__02_regression_sweep_commit.a1.md @@ -0,0 +1,9 @@ +**Report — phase 78, task 02 (regression sweep + commit)** + +- E2E in isolation (DB up): `uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov` → 5 passed; `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov` → 6 passed; `uv run pytest tests/e2e/test_smoke.py -v --no-cov` → 3 passed. +- Visual check (real server, no mock): booted two real uvicorn instances (built-in palette + `BOR_THEME=indigo.css`), captured 8 screenshots (chat + sources × default/reduced-motion × both palettes). Result: static 44px grid over the flat `--bg` canvas, zero glow spots, `document.getAnimations()` empty; default vs reduced-motion screenshots are **byte-identical**; indigo applies cleanly (`--bg` `rgb(10,14,26)`, grid follows `--line`). +- Full gate: `uv run pytest --cov=app --cov-report=term-missing` → 1561 passed, **99%** on `app/` (>90% ✓); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. +- `rg "bg-glow" frontend/ tests/ app/` → zero hits; themes carry no keyframes/animation. +- **Commit/phase-move:** per the pipeline override (harness makes the one atomic `--no-gpg-sign` commit and moves the phase dir), I did **not** run `git add`/`git commit` or touch `.agents/phases/`; all changes left in the working tree. +- **Confirmed deviation (from task 01, validated here):** `test_dark_tech_theme.py` could not stay literally UNCHANGED — it pinned the deleted `bg-glow-a/b/c` fades, so it could never be green unchanged. Minimal contract-preserving update only: `test_animated_background` → `test_static_background` + the reduced-motion test; all four palette/emoji/behavior/no-CDN tests byte-identical. +- Next pending: `.agents/phases/todo/79_api_tokens/` (phase 78's remaining files go to `complete/` with the harness commit). diff --git a/.agents/reports/78_static_background/78_static_background__02_regression_sweep_commit.a1.validate b/.agents/reports/78_static_background/78_static_background__02_regression_sweep_commit.a1.validate new file mode 100644 index 0000000..3ba569d --- /dev/null +++ b/.agents/reports/78_static_background/78_static_background__02_regression_sweep_commit.a1.validate @@ -0,0 +1,78 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 23%] +........................................................................ [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 46%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 64%] +........................................................................ [ 69%] +........................................................................ [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +................................................. [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 60 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 210 0 100% +----------------------------------------------- +TOTAL 2997 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/79_api_tokens/79_api_tokens__00_phase.a1.err b/.agents/reports/79_api_tokens/79_api_tokens__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/79_api_tokens/79_api_tokens__00_phase.a1.md b/.agents/reports/79_api_tokens/79_api_tokens__00_phase.a1.md new file mode 100644 index 0000000..7ff362d --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__00_phase.a1.md @@ -0,0 +1,12 @@ +Everything is green. This was a verification pass only — no code changes were needed. Final report: + +**Phase 79 (API tokens) — final verification pass** +- Verified tasks 01–08 already complete in `complete/79_api_tokens/`; confirmed implementation matches the locked design (A3–A5): `api_tokens` model + migration `0012`, hash-only token service, admin tokens router, `POST /api/token-auth` + 3-role whoami, live-revoking `require_user` on chat/suggestions/document-content, frontend gate + localStorage cache, Tokens admin view. +- Test results: + - `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` → **9 passed** (all owner-sentence clauses pinned: anon lockout, shared open, UI generate, token-user chat+doc, reload cache, admin walls 403, sign-out clears key, revocation refusal, no-enumeration error) + - `uv run pytest --cov=app --cov-report=term-missing` → **1639 passed, 0 failed, coverage 99%** (>90% floor) + - `uv run ruff check . && uv run pyright` → **clean (0 errors)** + - E2E spot-checks green: `test_admin_auth`, `test_share_chat` (anonymous), `test_chat_rag`, `test_nav_switch_keeps_stream`, `test_smoke` + all 10 token-migrated chat suites +- Completion criteria: all met — story suite green in isolation; token-user E2E chat + cited doc / anon 401 + gate / shared open / admin surfaces 403 (asserted in the passing suite); cache-survives-reload, sign-out clears, revocation refused on next request + fresh login; full suite + coverage + lint green. +- Notable: commit + phase-dir move are the harness's (per this pass's rules); README "API tokens" section verified present; `.env.example` correctly untouched. No defects found. +- Next pending phase: **80_history_suggestion_chips**. diff --git a/.agents/reports/79_api_tokens/79_api_tokens__00_phase.a1.validate b/.agents/reports/79_api_tokens/79_api_tokens__00_phase.a1.validate new file mode 100644 index 0000000..ab7e0ad --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__00_phase.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 66%] +........................................................................ [ 70%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 84%] +........................................................................ [ 88%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 11 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3136 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/79_api_tokens/79_api_tokens__01_token_model_migration.a1.err b/.agents/reports/79_api_tokens/79_api_tokens__01_token_model_migration.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/79_api_tokens/79_api_tokens__01_token_model_migration.a1.md b/.agents/reports/79_api_tokens/79_api_tokens__01_token_model_migration.a1.md new file mode 100644 index 0000000..9ad6bbf --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__01_token_model_migration.a1.md @@ -0,0 +1,13 @@ +All green. Task 01 is complete. + +**Report** + +- `app/models.py`: `ApiToken` model after `SavedChat` — `id` UUID PK (uuid4 default), `label` String(120) NOT NULL (display-only), `token_hash` String(64) NOT NULL unique (sha256 of the full `bor_…` token), `created_at` server-default now(), `last_used_at`/`revoked_at` TIMESTAMPTZ NULL; docstring documents the hashed-credential trust model; module docstring gains the `api_tokens` bullet. +- `alembic/versions/0012_api_tokens.py` (revision `0012` → `0011`): `op.create_table` + explicit unique `op.create_index("ix_api_tokens_token_hash", …)` (the 0009/0011 shape); downgrade drops index then table. +- `tests/unit/test_api_tokens_model.py` (new, schema-level per house precedent): table name, column set/nullability/widths, `token_hash` unique, `label` not unique — 4 tests. +- `tests/integration/test_migration_0012.py` (new, mirrors `test_migration_0011.py`): upgrade column contract, lifecycle round-trip, duplicate-hash rejection + repeated-label OK, downgrade/round-trip — 5 tests. +- Migration: `uv run alembic upgrade head` → 0012 applied; `downgrade -1` + `upgrade head` round-trips cleanly; dev DB at `0012 (head)` so tasks 02/03 pick the table up. +- Full suite: `uv run pytest --cov=app --cov-report=term` → **1570 passed**, coverage **99%** (>90%; `app/models.py` 100%). +- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. +- Deviation (minor): model column uses `unique=True` alone (all four house precedents — `doc_drafts.token`, `saved_chats.share_token`, etc.) rather than `unique=True, index=True`; the unique index requirement is met by the migration's explicit `op.create_index(..., unique=True)`, exactly the 0009 shape the task pointed at. +- Next pending task: `.agents/phases/todo/79_api_tokens/02_token_admin_api.md` (token service + admin create/list/revoke). diff --git a/.agents/reports/79_api_tokens/79_api_tokens__01_token_model_migration.a1.validate b/.agents/reports/79_api_tokens/79_api_tokens__01_token_model_migration.a1.validate new file mode 100644 index 0000000..ae7e2c0 --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__01_token_model_migration.a1.validate @@ -0,0 +1,78 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 45%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 64%] +........................................................................ [ 68%] +........................................................................ [ 73%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 87%] +........................................................................ [ 91%] +........................................................................ [ 96%] +.......................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 60 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 210 0 100% +----------------------------------------------- +TOTAL 3005 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/79_api_tokens/79_api_tokens__02_token_admin_api.a1.err b/.agents/reports/79_api_tokens/79_api_tokens__02_token_admin_api.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/79_api_tokens/79_api_tokens__02_token_admin_api.a1.md b/.agents/reports/79_api_tokens/79_api_tokens__02_token_admin_api.a1.md new file mode 100644 index 0000000..f089a7e --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__02_token_admin_api.a1.md @@ -0,0 +1,15 @@ +Task 02 complete — all gates green. + +**Implemented** +- `app/core/tokens.py` — `generate_token` / `hash_token` (sha256 of FULL string) / `create_token` / `find_active_by_token` (hash → unique-index hit → `revoked_at IS NULL`, generic miss) / `mark_used` / `revoke` (stamp-once, False only for missing id); hash-lookup-vs-constant-time-contrast documented; house flush-not-commit split +- `app/schemas.py` — `TokenCreateRequest` (strip-then 1–120, 422 fail-loud), `TokenCreated` (only schema carrying plaintext), `TokenListItem` / `TokenList` (secret-free by construction), `TokenAuthRequest` (deliberately no min-length → task-03 401 contract) +- `app/api/tokens.py` — router-wide `require_admin`; `POST /tokens` → 201 (plaintext once), `GET /tokens` newest-first (`created_at desc, id desc`), `POST /tokens/{id}/revoke` → 204 idempotent / 404 `token not found`; registered in `app/main.py` before the static mount +- `tests/unit/test_tokens.py` (11) + `tests/integration/test_tokens_api.py` (7): shape, hash, round-trip, all miss paths, revoke idempotency + `last_used_at` preservation, anonymous 403s, plaintext/hash never in list, duplicate labels, 422s, 404, ordering + +**Results** +- `uv run pytest --cov=app --cov-report=term-missing` → 1588 passed; TOTAL 99% (`core/tokens.py` 100%, `api/tokens.py` 100%) +- `uv run ruff check .` → clean (fixed `datetime.UTC` alias + import sort); `uv run pyright` → 0 errors + +**Decisions** — service flushes, endpoint commits (sources_meta house pattern); used `datetime.UTC` per ruff (identical to spec's `timezone.utc`). + +**Next pending task:** `03_token_auth_enforcement.md` diff --git a/.agents/reports/79_api_tokens/79_api_tokens__02_token_admin_api.a1.validate b/.agents/reports/79_api_tokens/79_api_tokens__02_token_admin_api.a1.validate new file mode 100644 index 0000000..dc9a74e --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__02_token_admin_api.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 27%] +........................................................................ [ 31%] +........................................................................ [ 36%] +........................................................................ [ 40%] +........................................................................ [ 45%] +........................................................................ [ 49%] +........................................................................ [ 54%] +........................................................................ [ 58%] +........................................................................ [ 63%] +........................................................................ [ 68%] +........................................................................ [ 72%] +........................................................................ [ 77%] +........................................................................ [ 81%] +........................................................................ [ 86%] +........................................................................ [ 90%] +........................................................................ [ 95%] +........................................................................ [ 99%] +.... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 22 0 100% +app/api/chat.py 177 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3089 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/79_api_tokens/79_api_tokens__03_token_auth_enforcement.a1.err b/.agents/reports/79_api_tokens/79_api_tokens__03_token_auth_enforcement.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/79_api_tokens/79_api_tokens__03_token_auth_enforcement.a1.md b/.agents/reports/79_api_tokens/79_api_tokens__03_token_auth_enforcement.a1.md new file mode 100644 index 0000000..51156ae --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__03_token_auth_enforcement.a1.md @@ -0,0 +1,15 @@ +All green. Task 03 complete. + +**Implemented** +- `app/core/auth.py`: `USER_SESSION_KEY`/`USER_TOKEN_ID_KEY` + `require_user` — admin always passes (no DB touch); token session live-checks the `api_tokens` row per request (PK lookup = revocation check); dead session (revoked/missing/corrupt id) → both user keys popped + 401 `authentication required`; anonymous → same 401 +- `app/api/auth.py`: public `POST /api/token-auth` (valid → `mark_used`+commit+`user`/`user_token_id` session keys → 204; malformed/unknown/revoked/empty → ONE generic 401 `invalid token`); `whoami` now reports `admin`|`user`|`anonymous` (admin wins on coexistence); `WhoamiResponse.role` comment updated; logout unchanged (wipes both roles) +- Enforcement: `_user: None = Depends(require_user)` on `POST /api/chat`, `GET /api/suggestions` (+ `db` dep), `GET /api/documents/content` (docstring: phase-16 soft rule SUPERSEDED); fixed stale "viewer/chat public" docstrings in `docs.py`, `steering.py`, `models.py`, `tokens.py` +- Tests: `require_user` matrix + 3-role whoami in `tests/unit/test_auth.py`; `test_auth_api.py` rewritten pins (token-auth flows, mid-session revocation → chat 401 + whoami anonymous + fresh login refused, token-user surface matrix incl. 403s on all admin surfaces); admin sign-in wired into `test_api.py`, `test_chat_api.py`, `test_kb_overview_api.py`, `test_document_content.py`, `test_document_viewer.py`, `test_chat_gate.py`, `test_chat_cancel.py` (raw ASGI scope carries a signed admin cookie) + +**Results** +- `uv run pytest --cov=app --cov-report=term-missing` → 1605 passed; TOTAL coverage **99%** (>90% bar; all modified files 100%) +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors + +**Decisions**: `last_used_at` stamped only on `token-auth` (per task-03 spec — the only request presenting the token; the gate re-sends it per page load); prior tasks' docstrings aligned accordingly. E2E suites (10 anonymous chat suites, `test_admin_auth.py`) intentionally untouched — that's task 04. + +**Next pending task**: `.agents/phases/todo/79_api_tokens/04_migrate_anonymous_e2e.md` diff --git a/.agents/reports/79_api_tokens/79_api_tokens__03_token_auth_enforcement.a1.validate b/.agents/reports/79_api_tokens/79_api_tokens__03_token_auth_enforcement.a1.validate new file mode 100644 index 0000000..12338ce --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__03_token_auth_enforcement.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 31%] +........................................................................ [ 35%] +........................................................................ [ 40%] +........................................................................ [ 44%] +........................................................................ [ 49%] +........................................................................ [ 53%] +........................................................................ [ 58%] +........................................................................ [ 62%] +........................................................................ [ 67%] +........................................................................ [ 71%] +........................................................................ [ 76%] +........................................................................ [ 80%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 94%] +........................................................................ [ 98%] +..................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 11 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3136 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/79_api_tokens/79_api_tokens__04_migrate_anonymous_e2e.a1.err b/.agents/reports/79_api_tokens/79_api_tokens__04_migrate_anonymous_e2e.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/79_api_tokens/79_api_tokens__04_migrate_anonymous_e2e.a1.md b/.agents/reports/79_api_tokens/79_api_tokens__04_migrate_anonymous_e2e.a1.md new file mode 100644 index 0000000..dfb25c6 --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__04_migrate_anonymous_e2e.a1.md @@ -0,0 +1,17 @@ +All gates pass. Final state verified: + +**Task 04 report — E2E suites meet the new auth contract** + +**Implemented** +- `auth_helpers.py`: added `login_with_token` (+ `WRONG_TOKEN` sentinel, wrong/revoked branch via `#auth-gate-error` role=alert) for task 07; made `login()` idempotent — an already-signed-in context boot-redirects past the form (login.js whoami), which was breaking double sign-in. +- Signed in the 10 named chat suites **plus ~40 more** the audit widened (viewer/suggestion/upload/sync suites) — page `login()` or httpx client login; shared-chat + deliberate anonymous-surface suites untouched. +- `test_admin_auth.py`: anonymous pins moved to the gated contract (401 `{"detail":"authentication required"}` on `/api/chat` + `/api/documents/content`, whoami anonymous, viewer not-found card); password flow unchanged. +- **Defects fixed**: (a) app bug — `sources.js` `loadDocs` race (boot re-attach + boot load interleave → 2× catalog rows, 108-row table) got a monotonic seq guard; (b) test-infra bug — 8 module-scoped `app_server` overrides collided with the session app on port 8123 in combined runs (silently driving the wrong server) → dedicated ports 8124–8134; (c) sync suites' TRUNCATE now clears `git_sources` (shared-DB hygiene); (d) anonymous viewer-content asserts made role-conditional (not-found card = anonymous settled state); (e) anonymous chip/contrast tests sign in (suggestions gated). + +**Gates** +- `uv run pytest tests/e2e -v --no-cov` → **308 passed, 0 failed** (single session; baseline was 174 failed) +- `uv run pytest tests/e2e/test_share_chat.py test_smoke.py test_nav_switch_keeps_stream.py test_archive_upload_sources.py test_admin_auth.py --no-cov` (isolation spot-check) → 22 passed +- `uv run pytest --cov=app --cov-report=term-missing` → **1605 passed, app/ 99%** +- `uv run ruff check . && uv run pyright` → clean + +**Decisions**: port fix + loadDocs guard are test-infra/app defects (no story semantics changed), required to make the combined inventory green — the full inventory had never been run as one session before. Not committed (per instructions). Next pending: **05_frontend_token_gate**. diff --git a/.agents/reports/79_api_tokens/79_api_tokens__04_migrate_anonymous_e2e.a1.validate b/.agents/reports/79_api_tokens/79_api_tokens__04_migrate_anonymous_e2e.a1.validate new file mode 100644 index 0000000..12338ce --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__04_migrate_anonymous_e2e.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 31%] +........................................................................ [ 35%] +........................................................................ [ 40%] +........................................................................ [ 44%] +........................................................................ [ 49%] +........................................................................ [ 53%] +........................................................................ [ 58%] +........................................................................ [ 62%] +........................................................................ [ 67%] +........................................................................ [ 71%] +........................................................................ [ 76%] +........................................................................ [ 80%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 94%] +........................................................................ [ 98%] +..................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 11 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3136 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/79_api_tokens/79_api_tokens__05_frontend_token_gate.a1.err b/.agents/reports/79_api_tokens/79_api_tokens__05_frontend_token_gate.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/79_api_tokens/79_api_tokens__05_frontend_token_gate.a1.md b/.agents/reports/79_api_tokens/79_api_tokens__05_frontend_token_gate.a1.md new file mode 100644 index 0000000..59ab0d9 --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__05_frontend_token_gate.a1.md @@ -0,0 +1,16 @@ +All work is complete and verified. Final report: + +**Implemented** — In-app token gate + browser caching (phase 79 task 05): +- `frontend/assets/token-gate.js` (new): mounts `#auth-gate` (shell) / `#doc-auth-gate` (viewer). Silent re-auth of cached `bor.token` via `POST /api/token-auth` runs **before** the whoami check (no gate flash, race-free vs header boot); dead/revoked cached tokens are dropped. Anonymous → gate revealed, `#main` made `inert`, focus lands on the token input. Success → cache token, invalidate whoami cache, unlock **without reload**, re-boot header, fire `onAuthed`. +- `header.js`: Sign in/out pair keys off `authenticated` (a token user sees Sign out; sign-out clears `bor.token` too); admin-only nav links + steering panel still key off `role === "admin"`. +- Gate markup/CSS in `index.html` + `document.html`; `Containerfile` esbuild line; unit pins `tests/unit/test_token_gate.py` (+ updated header/chat-UI pins); migrated 10 anonymous E2E suites to the gate contract. + +**Test/lint/coverage** +- `uv run pytest --cov=app` → **1622 passed**, TOTAL coverage **99%** (gate >90%) +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors +- E2E full inventory run: 4 anonymous-flow failures found → fixed → green; gate-related batch re-ran **61 passed** +- Real-server manual pass (9 flows: anonymous, wrong token, right token no-reload, cached reload, viewer, sign-out, revoked-login, revoked-cached, anonymous viewer) — all green + +**Decisions** — Gate locks the whole `#main` (all views, incl. tuning/sources), not just chat; header stays interactive (admin form login remains the second entry). Token `user` = authenticated for the sign-in pair but gets the anonymous UI surface (no admin links, no steering panel). `test_global_tuning`'s 403-form contract now runs as a non-admin token user (the anonymous path is unreachable by design); `test_responsive_polish` checks the gate's focus contract anonymous + original AC3 contract signed-in. No git ops; no `.agents/` files touched; throwaway debug scripts removed. + +**Next task** — `.agents/phases/todo/79_api_tokens/06_tokens_admin_view.md` diff --git a/.agents/reports/79_api_tokens/79_api_tokens__05_frontend_token_gate.a1.validate b/.agents/reports/79_api_tokens/79_api_tokens__05_frontend_token_gate.a1.validate new file mode 100644 index 0000000..efb464d --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__05_frontend_token_gate.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 31%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 57%] +........................................................................ [ 62%] +........................................................................ [ 66%] +........................................................................ [ 71%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 84%] +........................................................................ [ 88%] +........................................................................ [ 93%] +........................................................................ [ 97%] +...................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 11 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3136 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/79_api_tokens/79_api_tokens__06_tokens_admin_view.a1.err b/.agents/reports/79_api_tokens/79_api_tokens__06_tokens_admin_view.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/79_api_tokens/79_api_tokens__06_tokens_admin_view.a1.md b/.agents/reports/79_api_tokens/79_api_tokens__06_tokens_admin_view.a1.md new file mode 100644 index 0000000..aab3a7b --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__06_tokens_admin_view.a1.md @@ -0,0 +1,18 @@ +Everything is green and the tree contains only expected changes (plus the phase's earlier uncommitted tasks 01–05, which the harness commits atomically). + +## Report — Task 06: admin Tokens view (generate · list · revoke) + +**Implemented** +- `#view-tokens` section in the shell (`index.html`): page-head, `#tokens-gate` (history-gate pattern, `?next=/tokens.html`), `#tokens-status` live region, create row (label + Generate, ship-hidden), `#token-once` block (mono read-only field + Copy, ship-hidden), full-width table Label | Created | Last used | Status (Active em-dash / rose Revoked pill, cell aria-labels) | Actions (visually-hidden header), hidden empty row +- `frontend/assets/tokens.js`: `mount(root)` with admin gate (anonymous: gate in, controls out, **no** `/api/tokens` fetch), re-entrant `loadTokens()` (replaces rows, hides+wipes the once-block), generate (blank label → `"token"`, "Generating…" lifecycle, plaintext revealed only after the re-render load — never stored client-side), copy (clipboard + inline select-fallback, own ~10-line copy), inline two-step Revoke (`history-confirm-*`, row re-renders Revoked), `bor:view-refresh` listener armed in admin branch, all cells textContent (zero innerHTML) +- Router entries (`VIEW`/`VIEW_PATH`/`VIEW_MODULES`/`TITLES`/`DESCRIPTIONS`, replaceAll brand contract), `header.js` reveals `#nav-tokens` for admin +- `/tokens.html` in `app/main.py` `_shell_routes` + `caching.py` `HTML_PAGES`; styles.css Tokens section (history-table/brand-pill/stale-pill language, focus-visible, AA pairs) + ≤640px stacking +- Tests: `test_frontend_router.py` view-map/module/title pins extended + 2 new tokens contract tests; `test_api.py` (3 lists), `test_caching_revalidation.py` `SHELL_BACKED_PAGES`, `test_caching.py` `HTML_PAGES`; `test_nav_rename_sources.py` `NAV_TAIL` gains "Tokens" +- **Deviation (required):** `#nav-tokens` also ships on `document.html`/`login.html`/`shared.html` (task named only `index.html`) — the phase-34 one-bar contract and `test_nav_consistency.py`'s cross-page inventory parity (History precedent, 4 pages) make the shell-only link a real failure; null-safe header.js is unaffected + +**Results** +- `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL 99% (>90%; `main.py`/`caching.py`/`tokens.py` all 100%) +- `uv run ruff check . && uv run pyright` → clean +- E2E (isolated): temp manual pass (anon gate/no-fetch; admin generate→once+copy→revoke two-step→Revoked; re-show hides once-block; revoked token refused; active token unlocks; user gets gate) + `test_cache_busting`, `test_asset_cache_revalidation`, `test_nav_switch_keeps_stream`, `test_nav_consistency`, `test_admin_auth`, `test_nav_rename_sources`, `test_header_consistency`, `test_mobile_hamburger_nav`, `test_shared_header`, `test_navbar_refresh`, `test_tuning_nav_link`, `test_sticky_navbar`, `test_smoke`, `test_document_viewer`, `test_global_tuning`, `test_git_sources_admin`, `test_kb_overview`, `test_chat_history`, `test_history_copy`, `test_share_chat`, `test_stale_saved_chats` — all green (temp file deleted) + +**Next pending task:** `07_e2e_story_suite.md` diff --git a/.agents/reports/79_api_tokens/79_api_tokens__06_tokens_admin_view.a1.validate b/.agents/reports/79_api_tokens/79_api_tokens__06_tokens_admin_view.a1.validate new file mode 100644 index 0000000..ab7e0ad --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__06_tokens_admin_view.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 66%] +........................................................................ [ 70%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 84%] +........................................................................ [ 88%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 11 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3136 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/79_api_tokens/79_api_tokens__07_e2e_story_suite.a1.err b/.agents/reports/79_api_tokens/79_api_tokens__07_e2e_story_suite.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/79_api_tokens/79_api_tokens__07_e2e_story_suite.a1.md b/.agents/reports/79_api_tokens/79_api_tokens__07_e2e_story_suite.a1.md new file mode 100644 index 0000000..66ae8fc --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__07_e2e_story_suite.a1.md @@ -0,0 +1,17 @@ +All gates green. Task complete. + +**Report — Phase 79, task 07 (story E2E suite)** + +- Implemented `tests/e2e/test_api_tokens.py` (new, 9 tests) pinning every clause of TODO.md L5 in a real browser: + - anonymous lockout: `#auth-gate` visible, `#main` inert, composer unreachable via inverted tab walk; chat/suggestions/document-content 401 from empty cookies + - shared chats stay open: admin creates+shares via API; fresh context renders `/shared/` with zero gate markup + - admin UI generate: `#token-once-value` matches `bor_[0-9a-f]{32}`, Active row in table, once-block wiped on re-show (router re-show refresh) + - token flow: real-gate login (task-04 helper) → grounded mock-LLM turn → cited chip opens same-page `#doc-modal`; all 5 admin nav links absent, Sign out visible, whoami role `user` + - caching: `localStorage["bor.token"]` survives reload with silent re-auth (no gate, no re-entry) + - admin-only walls 403 on tokens/chats/docs/steering/git-sources; sign out clears session + cached token + - revocation: UI two-step; chain pinned in the holder's own browser cookies — whoami still `user` (lazy) → next gated 401 clears the session → whoami anonymous; fresh login refused at the gate + - wrong token: role=alert stays, malformed and well-formed-unknown 401 bodies byte-equal (no enumeration) +- Results: `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` → **9 passed** (isolation); spot-check `test_admin_auth.py` + `test_share_chat.py` → 10 passed; `uv run pytest --cov=app` → green, `app/` coverage **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors +- Notable: no app defects found — tasks 01–06 hold. One harness quirk worked around: httpx's cookie jar ignores the expired `Set-Cookie` the 401 sends, so the revocation chain is driven through the user context's real browser fetch (also matches the task's "context cookies" wording; app contract itself verified via TestClient) +- DB hygiene: autouse fixture deletes only `e2e-`-labeled token rows (never TRUNCATE — shared dev DB); shared-chat row cleaned in `finally` +- Next pending task: `08_regression_sweep_commit.md` (full pipeline + README auth section + atomic commit) diff --git a/.agents/reports/79_api_tokens/79_api_tokens__07_e2e_story_suite.a1.validate b/.agents/reports/79_api_tokens/79_api_tokens__07_e2e_story_suite.a1.validate new file mode 100644 index 0000000..ab7e0ad --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__07_e2e_story_suite.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 66%] +........................................................................ [ 70%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 84%] +........................................................................ [ 88%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 11 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3136 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/79_api_tokens/79_api_tokens__08_regression_sweep_commit.a1.err b/.agents/reports/79_api_tokens/79_api_tokens__08_regression_sweep_commit.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/79_api_tokens/79_api_tokens__08_regression_sweep_commit.a1.md b/.agents/reports/79_api_tokens/79_api_tokens__08_regression_sweep_commit.a1.md new file mode 100644 index 0000000..cc99700 --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__08_regression_sweep_commit.a1.md @@ -0,0 +1,13 @@ +All green. Final report: + +**Phase 79, Task 08 — Regression sweep (commit owned by harness per pipeline rules)** + +- Full suite: `uv run pytest --cov=app --cov-report=term` → **1627 passed, 99% coverage** (>90%; token deltas at 100%) +- `uv run ruff check . && uv run pyright` → clean (0 errors) +- E2E spot-checks, each in isolation: `test_api_tokens.py` 9✓, `test_admin_auth.py` 6✓, `test_share_chat.py` 4✓ (anonymous), `test_chat_rag.py` 3✓, `test_nav_switch_keeps_stream.py` 4✓, `test_smoke.py` 3✓ +- Real-browser + real-LLM verification (3 fresh windows, live server on :8085): admin generated a token in the Tokens view (plaintext shown once, gone after reload); private window passed the gate, chatted, opened a cited doc, **reloaded without re-entry** (cached token); anonymous window met the gate yet opened a shared chat; admin revoked → user's next question 401'd live and a fresh login got the generic invalid-token error; token user 403'd on `/api/docs`, `/api/chats` +- README: new **API tokens** subsection under Admin & sign-in (generate/use/scope/revoke) + fixed two stale "viewer stays public" statements and the whoami/who-can-do-what tables +- `.env.example` unchanged — no new settings added (verified); dev-DB verification artifacts cleaned (tokens + 2 test chats removed) +- No defects found in prior tasks; no code changes needed +- Per harness rules: no `git add`/`commit`, phase dir left in `todo/` for the harness +- Next pending: phase 80 (`.agents/phases/todo/80_*/`), which builds on the now-gated `/api/suggestions` diff --git a/.agents/reports/79_api_tokens/79_api_tokens__08_regression_sweep_commit.a1.validate b/.agents/reports/79_api_tokens/79_api_tokens__08_regression_sweep_commit.a1.validate new file mode 100644 index 0000000..ab7e0ad --- /dev/null +++ b/.agents/reports/79_api_tokens/79_api_tokens__08_regression_sweep_commit.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 66%] +........................................................................ [ 70%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 84%] +........................................................................ [ 88%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 11 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3136 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__00_phase.a1.err b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__00_phase.a1.md b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__00_phase.a1.md new file mode 100644 index 0000000..4cfc0a6 --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__00_phase.a1.md @@ -0,0 +1,13 @@ +All completion criteria verified green — no defects found, no fixes needed. Final report: + +**Phase 80 (history_suggestion_chips) — final verification pass: ALL GREEN** +- Verified implementation: `last_questions()` + seed-fallback in `app/api/suggestions.py` (100% cov); `startNewChat()` refetch via `loadSuggestions()` in `app.js`; seed-semantics docs in `config.py` docstring, `.env.example:55`, README L114/L823; integration state matrix in `test_suggestions_api.py`; E2E suite rewritten to the 4 states + carried-over story behavior. +- `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` → **7 passed** (isolated). +- `uv run pytest --cov=app --cov-report=term-missing` → **1637 passed**, TOTAL **99%** (>90%). +- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors, 0 warnings**. +- Criterion: E2E isolated green → ✅. +- Criterion: deflection unchanged → ✅ (`app/rag/suggestions.py` no diff vs HEAD; `test_honest_deflection.py` 3 passed; 100% cov). +- Criterion: full suite + coverage + lint/types → ✅ (above). +- Criterion: atomic commit + phase dir move → deferred per harness override: all changes left uncommitted in the working tree; task files already in `complete/80_history_suggestion_chips/`; harness commits and moves the phase dir. +- Notable: none — no defects found in prior work. +- Next pending phase: **none** (phase 80 is the last in the roadmap; `todo/` contains only this phase). diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__00_phase.a1.validate b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__00_phase.a1.validate new file mode 100644 index 0000000..64123da --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__00_phase.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 21%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 52%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 65%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 79%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +..................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3154 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__01_last_questions_endpoint.a1.err b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__01_last_questions_endpoint.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__01_last_questions_endpoint.a1.md b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__01_last_questions_endpoint.a1.md new file mode 100644 index 0000000..cfee54c --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__01_last_questions_endpoint.a1.md @@ -0,0 +1,10 @@ +**Task 01 complete** — `/api/suggestions` now returns the last 3 questions asked, seed before the first. + +- `app/api/suggestions.py`: module-level `last_questions(db, limit=3)` — `saved_chats` walked `updated_at DESC, created_at DESC`, each `bor.chat.v1` message list walked in reverse, `who=="user"` non-blank trimmed texts, exact case-sensitive dedup, cap 3; endpoint returns `qs if qs else get_settings().suggestions` (phase-79 `require_user` gate kept, `db` dep now used). +- New `tests/integration/test_suggestions_api.py` (10 tests): empty DB → seed; 4-q chat → `[q4,q3,q2]`; distinct-`updated_at` chat order; verbatim cross-chat dedup; case-sensitive dedup; exactly-2 and exactly-1 chips (no seed top-up); brain/blank-skip + all-brain → seed; anonymous → 401. +- Fixed stale pre-change pins in `tests/integration/test_api.py` (shape pin no longer assumes ≥3; env-override pin now conditioned on empty `saved_chats`, documented as the module's DB exception). +- `app/rag/suggestions.py` (deflection "Maybe try") untouched; its suites green. + +Results: `uv run pytest --cov=app --cov-report=term` → 1637 passed, app/ coverage 99% (suggestions.py 100%); `uv run ruff check .` clean; `uv run pyright` → 0 errors. + +No deviations. Dev DB left as found (saved_chats empty). Next pending task: `02_seed_semantics_docs.md`. diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__01_last_questions_endpoint.a1.validate b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__01_last_questions_endpoint.a1.validate new file mode 100644 index 0000000..64123da --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__01_last_questions_endpoint.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 21%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 52%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 65%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 79%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +..................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3154 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__02_seed_semantics_docs.a1.err b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__02_seed_semantics_docs.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__02_seed_semantics_docs.a1.md b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__02_seed_semantics_docs.a1.md new file mode 100644 index 0000000..c2ac834 --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__02_seed_semantics_docs.a1.md @@ -0,0 +1,15 @@ +Task 02 complete. All four doc sites updated (docs-only, no behavior change): + +- **`app/config.py`** — comment block above `suggestions: list[str]` now documents the SEED semantics: shown only while no saved chat has ever asked a question; afterwards `GET /api/suggestions` serves the last 3 questions (deployment-wide, newest first); `BOR_SUGGESTIONS` overrides the seed. Default list untouched. +- **`.env.example:55`** — comment now reads "JSON seed chips — shown only before any question has been saved (phase 80)". +- **`README.md` env-table row** — "JSON seed for the onboarding chips — shown only before the first saved question; afterwards the chips are the last 3 questions asked (phase 80)". +- **`README.md` chat feature description** — one sentence added: onboarding chips follow the last 3 questions asked, seeding from `BOR_SUGGESTIONS` on a fresh deployment. Deflection note and access-table row left as-is (unchanged contracts). + +**Verification:** +- `uv run pytest --cov=app --cov-report=term-missing` → 1637 passed, `app/` coverage **99%** (>90% gate) +- `uv run ruff check .` → All checks passed +- `uv run pyright` → 0 errors, 0 warnings + +**Notable:** no defects found; deflection contract (`app/rag/suggestions.py` + suites) green and untouched. The e2e story-suite rewrite for the new chip semantics remains deferred to task 04 per the phase plan (phase-76 precedent), so the old-contract e2e pins are expected to go until then. + +**Next pending task:** `03_chips_new_chat_refetch.md` (app.js refetch on New chat + source pin). diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__02_seed_semantics_docs.a1.validate b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__02_seed_semantics_docs.a1.validate new file mode 100644 index 0000000..64123da --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__02_seed_semantics_docs.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 21%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 52%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 65%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 79%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +..................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3154 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a1.err b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a1.md b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a1.md new file mode 100644 index 0000000..6324997 --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a1.md @@ -0,0 +1,19 @@ +Task 03 complete — all gates green. + +**Implemented** +- `frontend/assets/app.js`: `startNewChat()` (the `bor:new-chat` handler) now calls `loadSuggestions()` right after the empty state is restored — the onboarding chips refetch `/api/suggestions` so the row reflects the current last-3 state, never the stale boot fetch. Progressive enhancement unchanged (swallows 401/network failures); boot path untouched; in-flight-turn guard still exempts a live turn. +- `tests/unit/test_chat_persistence.py`: source pin in `test_new_chat_clears_key_and_ui` — containment assertion that `loadSuggestions()` appears in the `startNewChat` function-body slice (house style, two literals). + +**Test / lint / coverage** +- `node --check frontend/assets/app.js` — valid JS +- `uv run pytest -q` (unit + integration) — exit 0, all passed +- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90% floor) +- `uv run ruff check .` — All checks passed; `uv run pyright` — 0 errors +- `uv run pytest tests/unit/test_chat_persistence.py::test_new_chat_clears_key_and_ui` — passed + +**Decisions / notes** +- Pin placed in `test_chat_persistence.py` (the existing `startNewChat` body-slice pin — the file the task names first). +- `.agents/PLAN.md` is absent from the repo; the phase files carry the binding decisions (no deviation). +- E2E refetch assertion belongs to task 04's suite (per task file); no other files touched. My diff is only the 2 files above (working tree also carries uncommitted harness work from phases 77–79, left as-is). + +**Next pending task:** `.agents/phases/todo/80_history_suggestion_chips/04_e2e_suite_commit.md` diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a1.validate b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a1.validate new file mode 100644 index 0000000..dfb5c86 --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a1.validate @@ -0,0 +1,215 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 21%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 52%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 65%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 79%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +..................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3154 12 99% +coverage gate: app/ 99% (>90%) OK +I001 [*] Import block is un-sorted or un-formatted + --> tests/e2e/test_toggle_after_nav.py:2:1 + | +1 | """Quick diagnostic: does the toggle still work after SPA navigation?""" +2 | / import pytest +3 | | from playwright.sync_api import Browser, expect + | |_______________________________________________^ +4 | +5 | def test_toggle_after_spa_nav(browser, app_url, db_ready): + | +help: Organize imports + | +4 | +5 + +6 | def test_toggle_after_spa_nav(browser, app_url, db_ready): + | + +F401 [*] `pytest` imported but unused + --> tests/e2e/test_toggle_after_nav.py:2:8 + | +1 | """Quick diagnostic: does the toggle still work after SPA navigation?""" +2 | import pytest + | ^^^^^^ +3 | from playwright.sync_api import Browser, expect + | +help: Remove unused import: `pytest` + | +1 | """Quick diagnostic: does the toggle still work after SPA navigation?""" + - import pytest +2 | from playwright.sync_api import Browser, expect + | + +F401 [*] `playwright.sync_api.Browser` imported but unused + --> tests/e2e/test_toggle_after_nav.py:3:33 + | +1 | """Quick diagnostic: does the toggle still work after SPA navigation?""" +2 | import pytest +3 | from playwright.sync_api import Browser, expect + | ^^^^^^^ +4 | +5 | def test_toggle_after_spa_nav(browser, app_url, db_ready): + | +help: Remove unused import: `playwright.sync_api.Browser` + | +2 | import pytest + - from playwright.sync_api import Browser, expect +3 + from playwright.sync_api import expect +4 | + | + +W293 [*] Blank line contains whitespace + --> tests/e2e/test_toggle_after_nav.py:15:1 + | +13 | timeout=10_000, +14 | ) +15 | + | ^^^^^^^^ +16 | # Open the menu +17 | page.click("#nav-toggle") + | +help: Remove whitespace from blank line + | +14 | ) + - +15 + +16 | # Open the menu + | + +W293 [*] Blank line contains whitespace + --> tests/e2e/test_toggle_after_nav.py:19:1 + | +17 | page.click("#nav-toggle") +18 | expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") +19 | + | ^^^^^^^^ +20 | # Navigate to a different view via SPA +21 | page.click("#nav-sources") + | +help: Remove whitespace from blank line + | +18 | expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") + - +19 + +20 | # Navigate to a different view via SPA + | + +W293 [*] Blank line contains whitespace + --> tests/e2e/test_toggle_after_nav.py:23:1 + | +21 | page.click("#nav-sources") +22 | expect(page).to_have_url(app_url + "/sources.html", timeout=15_000) +23 | + | ^^^^^^^^ +24 | # Menu should be closed +25 | expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") + | +help: Remove whitespace from blank line + | +22 | expect(page).to_have_url(app_url + "/sources.html", timeout=15_000) + - +23 + +24 | # Menu should be closed + | + +W293 [*] Blank line contains whitespace + --> tests/e2e/test_toggle_after_nav.py:26:1 + | +24 | # Menu should be closed +25 | expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") +26 | + | ^^^^^^^^ +27 | # NOW try to open the menu again - THIS IS THE BUG CHECK +28 | page.click("#nav-toggle") + | +help: Remove whitespace from blank line + | +25 | expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") + - +26 + +27 | # NOW try to open the menu again - THIS IS THE BUG CHECK + | + +E501 Line too long (104 > 100) + --> tests/e2e/test_toggle_after_nav.py:30:101 + | +28 | page.click("#nav-toggle") +29 | try: +30 | expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true", timeout=3000) + | ^^^^ +31 | print("PASS: Toggle still works after SPA navigation") +32 | except Exception as e: + | + +Found 8 errors. +[*] 7 fixable with the `--fix` option. +0 errors, 0 warnings, 0 informations +validation FAILED (see output above) diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a2.err b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a2.md b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a2.md new file mode 100644 index 0000000..fb433c3 --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a2.md @@ -0,0 +1,15 @@ +Everything is green — the only "error" match is pyright's "0 errors" line. + +**Fixed** +- `tests/e2e/test_toggle_after_nav.py` (stray diagnostic file, pre-existing lint debt that failed the harness gate): removed unused imports (`pytest`, `Browser`), fixed the isort order (third-party `playwright` block, then first-party `e2e.auth_helpers` — house style), stripped whitespace-only lines, wrapped the >100-char `expect(...)` line. Preserved the file's current semantics (phase-79 `login(page, app_url, next="/")` admin wait — the variant present on disk). + +**Test / lint / coverage** +- `bash .agents/validate.sh` (the harness gate) — **exit 0, "validation OK"** +- Inside it: `uv run pytest --cov=app` — all unit + integration passed; TOTAL **99%** (>90% floor) +- `uv run ruff check .` — All checks passed; `uv run pyright` — 0 errors + +**Decisions / notes** +- The diagnostic file was being updated concurrently (a parallel process overwrote my first fix with the `login`-helper variant); I lint-cleaned that on-disk variant instead and re-validated immediately — final state verified green. +- Task-03 work (app.js `loadSuggestions()` in `startNewChat()` + the source pin in `test_chat_persistence.py`) remains in the tree, untouched and green. + +**Next pending task:** `.agents/phases/todo/80_history_suggestion_chips/04_e2e_suite_commit.md` diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a2.validate b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a2.validate new file mode 100644 index 0000000..64123da --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__03_chips_new_chat_refetch.a2.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 21%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 52%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 65%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 79%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +..................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3154 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__04_e2e_suite_commit.a1.err b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__04_e2e_suite_commit.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__04_e2e_suite_commit.a1.md b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__04_e2e_suite_commit.a1.md new file mode 100644 index 0000000..c52c7fa --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__04_e2e_suite_commit.a1.md @@ -0,0 +1,17 @@ +Task 04 complete — all gates green. Report: + +**Implemented** +- Rewrote `tests/e2e/test_suggestion_chips.py` (7 tests) to the phase-80 contract: **seed** state (chips == exact `SEED` literal copied from the `Settings.suggestions` default, role=list/buttons/style pins), **last-3** (2 chats via `POST /api/chats`, 5 questions, older saved first → fresh load shows exactly the 3 newest, newest-first), **partial** (2 saved questions → exactly 2 chips, no seed top-up), **new-chat refetch** (click `#new-chat-btn` → chips become Q + a second `GET /api/suggestions` in the request log, timestamped after the click) — plus carried-over one-tap submit, Tab+Enter keyboard walk, and the mobile single-scroll-row contract. +- Added an autouse `clean_chats` fixture truncating `saved_chats` before/after every test — this also fixed pre-existing defects I found: the old suite (unmodified by prior tasks) was already failing 2/4 because auto-saved chat rows leaked between tests and the new DB-derived contract turned them into 1 deduped chip. + +**Results** +- `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` → 7 passed (green in isolation, repeated runs stable) +- `uv run pytest --cov=app --cov-report=term-missing` → 1637 passed, **99%** on `app/` (`app/api/suggestions.py` 100%) +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors +- Deflection untouched: `app/rag/suggestions.py` has no diff; `uv run pytest tests/e2e/test_honest_deflection.py --no-cov` → 3 passed + +**Notes** +- No `git add`/`commit` and no phase-dir move performed — the harness owns the atomic commit and the move (overrides the task's commit step). +- Playwright's `APIRequestContext.post` in this version has no `json=` kwarg — JSON is sent via `data=` + `Content-Type: application/json`. + +**Next pending task:** none — task 04 was the final task; phase 80 is complete, awaiting the harness commit. diff --git a/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__04_e2e_suite_commit.a1.validate b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__04_e2e_suite_commit.a1.validate new file mode 100644 index 0000000..64123da --- /dev/null +++ b/.agents/reports/80_history_suggestion_chips/80_history_suggestion_chips__04_e2e_suite_commit.a1.validate @@ -0,0 +1,81 @@ +........................................................................ [ 4%] +........................................................................ [ 8%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 21%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 35%] +........................................................................ [ 39%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 52%] +........................................................................ [ 57%] +........................................................................ [ 61%] +........................................................................ [ 65%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 79%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +........................................................................ [ 96%] +..................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 40 0 100% +app/api/chat.py 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 212 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/core/tokens.py 33 0 100% +app/db.py 21 0 100% +app/main.py 62 0 100% +app/models.py 94 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 216 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 231 0 100% +----------------------------------------------- +TOTAL 3154 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agents/skills/restore-test-db/SKILL.md b/.agents/skills/restore-test-db/SKILL.md new file mode 100644 index 0000000..47108e6 --- /dev/null +++ b/.agents/skills/restore-test-db/SKILL.md @@ -0,0 +1,134 @@ +--- +name: restore-test-db +description: Restores the large 1,000-document test knowledge base (the live-KB replica snapshot at data/bor_live_kb_replica.dump.sql) into the app's Postgres database in one atomic transaction — wipes the database's current contents, no re-embedding, no app code changes. Use when the user asks to restore the test database, the 1,000-document KB, or the big/live replica KB (e.g. "restore the test database", "put the 1000-doc KB back"). Do not confuse with the 8-document tool-calling fixture KB, which scripts/restore_test_kb.py restores. +--- + +# Restore the 1,000-Document Test KB + +Load `data/bor_live_kb_replica.dump.sql` (a full 99 MB `pg_dump` — +schema **and** data — of the live KB replica) into the app database +(`brain_of_reese`, per `BOR_DATABASE_URL` in `.env`). The embeddings come +with the dump, so there is **no re-embedding, no git clone, no app code +change** — the whole known state (documents, chunks + embeddings, source +registry, KB overview, alembic version) lands in a few seconds. + +The dump is a local artifact: `data/` is gitignored and the dump is +**never committed**. It is not in git — if it is missing on a fresh +clone, stop and ask the user for it (it can only be made by `pg_dump`ing +the live/`bor_eval` KB). + +## Rules (non-negotiable) + +- **The restore is destructive** — it wipes the current contents of the + app database. Always take the disposable backup in step 2 first, even + when the database looks empty. +- **Do not edit app code** (`app/`, `tests/`, `alembic/`). This skill + swaps database contents, nothing else. +- **Run `psql` inside the db container** (`brain-of-reese_db_1`), not + with a host `psql` — the container's psql is 17.x, matching the dump + (newer dumps use the `\restrict` metacommand that old host psqls + choke on). +- This KB and the 8-document tool-calling fixture KB + (`scripts/restore_test_kb.py`) share the same database and overwrite + each other. Say which one is loaded in the summary. + +## Procedure + +All commands run from the repo root. + +### 1. Preconditions (fast fail) + +```bash +podman compose up -d db +ls -lh data/bor_live_kb_replica.dump.sql # must exist, ~99 MB +``` + +Check the dump's schema version against the current head: + +```bash +grep -A1 '^COPY public.alembic_version' data/bor_live_kb_replica.dump.sql | tail -1 +uv run alembic heads +``` + +The two must match (the 2026-09-05 snapshot carries `0011`). If the dump +is **older** than head, proceed and run `uv run alembic upgrade head` +after step 3. If the dump is **newer** than head, the local code is +behind — stop and flag it to the user. + +### 2. Disposable safety backup + +Seconds, in `/tmp` (never committed): + +```bash +podman exec brain-of-reese_db_1 pg_dump -U reese brain_of_reese -Fc \ + > /tmp/pre_restore_$(date +%Y%m%d_%H%M%S).dump +``` + +### 3. Reset the schema, load the dump (one transaction) + +```bash +podman exec brain-of-reese_db_1 psql -U reese -d brain_of_reese -c \ + "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO PUBLIC;" + +podman exec -i brain-of-reese_db_1 psql -U reese -d brain_of_reese \ + -v ON_ERROR_STOP=1 --single-transaction -q -f /dev/stdin \ + < data/bor_live_kb_replica.dump.sql +``` + +Why the schema reset: the dump contains plain `CREATE TABLE` (no +`IF NOT EXISTS`, no `--clean`), so it can only load into an empty +`public` schema. Why `--single-transaction` + `ON_ERROR_STOP`: a failed +load rolls back instead of leaving a half-loaded KB. Known failure mode: +the `DROP SCHEMA` is its own committed statement, so a failed load leaves +an **empty** schema — that is fine, fix the cause and re-run step 3 +(the reset is idempotent). + +### 4. Verify the fingerprint + +```bash +podman exec brain-of-reese_db_1 psql -U reese -d brain_of_reese -tAc \ + "select 'docs='||count(*) from documents + union all select 'chunks='||count(*) from chunks + union all select 'sources='||string_agg(distinct source, ',' order by source) from documents + union all select 'alembic='||(select version_num from alembic_version);" +``` + +Expected for the 2026-09-05 snapshot: **docs=1000, chunks=8866, +sources=deploy,homelab,homepage,ServMon,skills,vibe-bot, alembic=0011**. +If any number differs, the dump file is a different snapshot — report the +actual numbers to the user; do not guess. + +### 5. Dev-server check (only if it is running) + +```bash +pgrep -af "uvicorn app.main" && curl -s http://localhost:8000/api/health +``` + +Expect `"db":"up"` — the SQLAlchemy pool reconnects fine across the +schema reset, so no restart is normally needed. If the server misbehaves +after the restore, restart it (`uv run uvicorn app.main:app --reload`). + +### 6. Summary + +One line per verified number (docs / chunks / sources / alembic), the +wall time, and a reminder of which KB is loaded: this is the +**1,000-document live replica**; the 8-document tool-calling fixture is +brought back separately with `uv run python -m scripts.restore_test_kb`. + +## Troubleshooting + +- **`database unreachable` / connection refused** — the db container is + down; `podman compose up -d db`, then re-run from step 1. +- **`relation "public." already exists`** — the schema was not + reset (or a previous load failed after the reset rolled back); re-run + the `DROP SCHEMA` command from step 3. +- **`psql: /dev/stdin:N: unknown command '\restrict'`** — you ran a host + `psql`; use the container's, per the rules. +- **load succeeds but counts are wrong** — compare the dump's + `alembic_version` (step 1) and `documents` row count + (`sed -n '/^COPY public.documents/,/^\\./p' data/bor_live_kb_replica.dump.sql | wc -l`, + minus 2) against the loaded numbers; a mismatch means the dump was + truncated in transfer — ask the user for the original file. +- **`schema not applied`-style errors from the app afterwards** — run + `uv run alembic current`; if the dump was older than head, step 1's + `alembic upgrade head` was the missing piece. diff --git a/.env.example b/.env.example index 780ba6d..318c794 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,7 @@ BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant # your own (e.g. md,sh,toml) or narrow it (e.g. md). A blank list or a # malformed token (e.g. md,sh!) fails startup loudly, naming the value. BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2 -# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips +# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON seed chips — shown only before any question has been saved (phase 80) # --- Import sources (git; phase 28, admin-managed since phase 35) --- # Comma-separated git repo URLs; import_docs clones each (first run) or diff --git a/Containerfile b/Containerfile index 88e92a8..3754840 100644 --- a/Containerfile +++ b/Containerfile @@ -16,6 +16,7 @@ COPY frontend ./ RUN mkdir -p /out/assets \ && esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \ && esbuild ./assets/router.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/router.js \ + && esbuild ./assets/token-gate.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/token-gate.js \ && esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \ && esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \ && esbuild ./assets/shared.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/shared.js \ diff --git a/README.md b/README.md index a893b20..1710451 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,10 @@ uv run uvicorn app.main:app --reload - **Chat** (`/`) — ask questions; answers stream in with **source chips** that cite the exact documents used. Clicking a chip opens that document - in an **almost-fullscreen modal** on the same page (no new tab). + in an **almost-fullscreen modal** on the same page (no new tab). The + onboarding suggestion chips follow the last 3 questions asked — on a + fresh deployment (no saved questions yet) they seed from + `BOR_SUGGESTIONS`. - **Document viewer** — the modal above *is* the viewer; the full text of any indexed document is served from the database (no filesystem access): markdown is rendered, every other format (`yaml`, `json`, `py`, `txt`, …) @@ -119,7 +122,8 @@ uv run uvicorn app.main:app --reload - **Sources** (`/sources.html`) — the indexed document list; the *Path* column opens each document in the same **almost-fullscreen modal** (no new tab). **Admin-only** — anonymous visitors see a sign-in gate instead - (the catalog is what the login locks; the document viewer itself stays + (the catalog is what the admin login locks; the document viewer asks + for an access token — see [API tokens](#api-tokens). Shared chats stay open to everyone). - **Git sources** (`/git-sources.html`) — the admin-managed source registry: the git repositories the **Sync sources** button clones and @@ -214,10 +218,12 @@ unchanged. ## Admin & sign-in Brain of Reese has exactly **one account: the admin (you)**. Signing in -unlocks the **full Sources catalog** and the **answer-tuning** controls; -everyone else stays anonymous and keeps **chat** and the **document -viewer** (any document an answer cites can be opened by its direct URL — -the catalog is gated, not the viewer). +unlocks the **full Sources catalog**, the **answer-tuning** controls, and +**token management**. **Shared chats** are the only content that stays +open to anonymous visitors; everyone in between gets an **API token** — +the admin generates one in the **Tokens** view and hands it out, and the +holder enters it at the in-app gate to use chat and open the documents +answers cite (see [API tokens](#api-tokens) below). ### Setup (one-time) @@ -247,8 +253,14 @@ Set the missing variable(s): BOR_ADMIN_PASSWORD, BOR_SESSION_SECRET … message — no user enumeration, there is only one user). - `POST /api/logout` → `204` (session cleared and cookie expired; idempotent for anonymous callers). -- `GET /api/whoami` → `{"authenticated": bool, "role": "admin"|"anonymous"}` - — the single source of truth for every UI gating decision. +- `GET /api/whoami` → `{"authenticated": bool, "role": "admin"|"user"|"anonymous"}` + — the single source of truth for every UI gating decision + (`authenticated` is true for **both** admin and token users; the UI + gates on `role === "admin"`). +- `POST /api/token-auth {"token": "bor_…"}` → `204` + the **same signed + cookie** (role `user`); malformed / unknown / revoked all get one + generic `401` `{"detail": "invalid token"}` — no enumeration. The two + roles coexist in one session; **Sign out** clears both at once. - Cookie flags: `same_site="lax"`, `https_only` off — **no HTTPS enforcement on purpose** (homelab HTTP; the cookie is single-admin convenience, not a cloud boundary). Max age `BOR_SESSION_MAX_AGE` @@ -258,12 +270,41 @@ Set the missing variable(s): BOR_ADMIN_PASSWORD, BOR_SESSION_SECRET … ### Who can do what -| Capability | Anonymous | Admin (signed in) | -|---|---|---| -| Chat (`/`) + suggestion chips | yes | yes | -| Document viewer (`/document.html?source=…&path=…`) | yes — any indexed doc by direct URL | yes | -| Sources catalog (`/sources.html`, `GET /api/docs`) | sign-in gate | full catalog | -| Tuning (Tune button, Tuning panel, `/api/steering`) | UI hidden | full | +| Capability | Anonymous | Token user | Admin (signed in) | +|---|---|---|---| +| Shared chats (`/shared/`) | yes | yes | yes | +| Chat (`/`) + suggestion chips | token gate | yes | yes | +| Document viewer (`/document.html?source=…&path=…`) | token gate | yes | yes | +| Sources catalog (`/sources.html`, `GET /api/docs`) | sign-in gate | 403 | full catalog | +| Tuning (Tune button, Tuning panel, `/api/steering`) | UI hidden | 403 | full | +| Saved-chat history (`/history.html`) | sign-in gate | no History view | full | + +### API tokens + +The admin can hand out access without sharing the admin password. + +- **Generate:** sign in as admin → **Tokens** in the navbar + (`/tokens.html`). Type a label (e.g. `alice`) and hit **Generate** — a + `bor_` + 32-hex token appears in the *shown once* block. Copy it now: + only its SHA-256 hash is stored, so the plaintext can never be + retrieved again. +- **Use:** the holder opens the app (or a direct document URL) and gets + the **token gate** instead of content — they enter the token, and the + browser caches it in `localStorage["bor.token"]`, silently re-sending + it on every page load (no re-entry on reloads or new tabs). **Sign + out** clears the cache; a failed silent re-auth (e.g. a revoked token) + drops the cached copy and shows the gate again. Private-mode browsers + still work — the gate just can't cache. +- **Scope:** a token user can chat (with suggestion chips) and open the + documents answers cite — nothing else. The Sources catalog, git + sources, tuning, document drafts, and the saved-chat history stay + admin-only (`403` on their APIs; no History view). **Shared chats + stay open to everyone** — the only anonymous content. +- **Revoke:** **Revoke** on the token's row (inline two-step confirm). + Revocation is **immediate**: the token's next request — including a + fresh login attempt — is refused with the same generic `401` as a bad + token. Revoked tokens stay in the list, marked **Revoked**, with their + last used time. The public API endpoints stay stateless — the signed cookie is the only session state in the system. @@ -779,7 +820,7 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`. | `BOR_SUMMARY_MAX_CHARS` | `12000` | cap on document content sent to the `lite` summary model at import (see *Document summaries*) | | `BOR_KB_OVERVIEW_MAX_CHARS` | `4000` | char budget for the `` (KB overview) prompt section | | `BOR_OVERVIEW_INPUT_MAX_CHARS` | `40000` | cap on the document list sent to the `lite` model when generating the KB overview | -| `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips | +| `BOR_SUGGESTIONS` | built-in list | JSON seed for the onboarding chips — shown only before the first saved question; afterwards the chips are the last 3 questions asked (phase 80) | | `BOR_ADMIN_PASSWORD` | *(required)* | the single admin's password (plaintext, `.env`); app refuses to start when empty | | `BOR_SESSION_SECRET` | *(required)* | signing key for the `bor_session` cookie; `python -c 'import secrets;print(secrets.token_hex(32))'` | | `BOR_SESSION_MAX_AGE` | `43200` | session-cookie lifetime in seconds (12 h, sliding) | diff --git a/alembic/versions/0012_api_tokens.py b/alembic/versions/0012_api_tokens.py new file mode 100644 index 0000000..1635de2 --- /dev/null +++ b/alembic/versions/0012_api_tokens.py @@ -0,0 +1,73 @@ +"""api_tokens: admin-issued access tokens (phase 79) + +Revision ID: 0012 +Revises: 0011 +Create Date: 2026-09-07 + +Phase 79 (API tokens: the admin generates named tokens and hands them +out so people can sign in to the app — the ONLY anonymous content left +is the shared chats; extends the phase-16 single-admin auth, one +additive, reversible table, no other schema change, A13): + +* ``api_tokens`` — one row per admin-generated token (``bor_`` + 32 hex + chars — ``secrets.token_hex(16)``, service in task 02). The + **plaintext exists only in the 201 create response, returned exactly + once**; the row never carries it. +* ``token_hash`` — the stored credential: the SHA-256 hex digest of the + **full** token string (String(64) — the ``documents.content_hash`` + precedent). Hashing the full string, not the suffix, so a stripped + prefix can never collide. Lineage of ``saved_chats.share_token`` / + ``doc_drafts.token`` — but HASHED: unlike those unguessable + ``uuid4`` link tokens these are long-lived hand-out credentials, and + a leaked database must not hand anyone working tokens. +* ``ix_api_tokens_token_hash`` — UNIQUE index on ``token_hash``: the + lookup key (a unique-index hit — sha256's pre-image resistance means + there is no enumeration surface beyond the lookup). The explicit + ``op.create_index`` shape of ``ix_saved_chats_share_token`` (0009) + and ``ix_doc_drafts_token`` (0011). +* ``label`` — String(120) NOT NULL: the hand-out name (e.g. "alice") — + display-only: no index, not unique (two tokens may share a label). +* ``created_at`` — TIMESTAMPTZ NOT NULL, server default now(). +* ``last_used_at`` / ``revoked_at`` — TIMESTAMPTZ NULL: bumped/set by + the service (tasks 02/03); NULL until the token is first used / + revoked. +""" +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0012" +down_revision = "0011" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "api_tokens", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("label", sa.String(length=120), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + ) + # The hash is the stored credential — the unique lookup handle (the + # saved_chats.share_token / doc_drafts.token unique-index precedent, + # phases 51/59). + op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"], unique=True) + + +def downgrade() -> None: + # Safe order: drop the token-hash index first, then the table (A13 — + # fully reversible, no other schema change). + op.drop_index("ix_api_tokens_token_hash", table_name="api_tokens") + op.drop_table("api_tokens") diff --git a/app/api/auth.py b/app/api/auth.py index 8c8ef24..4040881 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,24 +1,42 @@ -"""Auth API — single-admin sign-in (phase 16; A10 revised 2026-08-22). +"""Auth API — admin sign-in (phase 16) + token login (phase 79). -* ``POST /api/login`` — 204 + signed session cookie on success; 401 +* ``POST /api/login`` — 204 + signed session cookie on success; 401 ``invalid password`` on any mismatch (constant-time, one generic message, no session set). -* ``POST /api/logout`` — 204; clears the session and expires the cookie - (idempotent for anonymous callers). -* ``GET /api/whoami`` — ``{"authenticated": bool, "role": - "admin"|"anonymous"}``; the single source of truth for all UI gating. +* ``POST /api/token-auth`` — 204 + signed session cookie for a valid, + unrevoked API token (PUBLIC — it is the token holders' login route); + every failure shape (malformed / unknown / revoked / empty) is ONE + generic 401 ``invalid token`` (no enumeration, the phase-16 pattern). +* ``POST /api/logout`` — 204; clears the session and expires the + cookie (idempotent for anonymous callers — one logout wipes BOTH + roles, the session is one dict). +* ``GET /api/whoami`` — ``{"authenticated": bool, "role": + "admin"|"user"|"anonymous"}``; the single source of truth for all UI + gating (phase 79: the third role; the UI's admin-only surfaces key + off ``role === "admin"`` specifically). -The public API otherwise stays stateless (A10): chat, the document -content endpoint (soft rule — anonymous may open any document by direct -URL), suggestions, and health never require the cookie. +The rest of the public surface is the phase-79 contract: health, +config, whoami, login, token-auth, and the shared chats stay +anonymous — chat, suggestions, and the document viewer content are +user-gated (``require_user``). """ from __future__ import annotations -from fastapi import APIRouter, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from sqlalchemy.orm import Session from app.config import get_settings -from app.core.auth import ADMIN_SESSION_KEY, check_password, sign_in, sign_out -from app.schemas import LoginRequest, WhoamiResponse +from app.core import tokens as token_service +from app.core.auth import ( + ADMIN_SESSION_KEY, + USER_SESSION_KEY, + USER_TOKEN_ID_KEY, + check_password, + sign_in, + sign_out, +) +from app.db import get_db +from app.schemas import LoginRequest, TokenAuthRequest, WhoamiResponse router = APIRouter(tags=["auth"]) @@ -39,14 +57,48 @@ def login(payload: LoginRequest, request: Request) -> Response: raise HTTPException(status_code=401, detail="invalid password") +@router.post("/token-auth", status_code=204) +def token_auth( + payload: TokenAuthRequest, + request: Request, + db: Session = Depends(get_db), # noqa: B008 +) -> Response: + """Sign a token holder in (phase 79 — PUBLIC: this IS the login). + + A valid, unrevoked token → 204 + the signed ``bor_session`` cookie + (the same SessionMiddleware mechanism as ``/api/login``): the + session carries the ``user`` key plus the token's row id + (``user_token_id``), and ``require_user``'s live row check enforces + revocation from the holder's very next request. The token's + ``last_used_at`` is stamped here (the admin's Tokens view shows the + login as the last use). + + EVERY failure shape — malformed, unknown, revoked, empty — is ONE + generic 401 ``invalid token``: the lookup is by hash (the service + returns ``None`` for anything that is not an active row), so there + is no enumeration surface (the phase-16 pattern). + """ + token = payload.token.strip() + row = token_service.find_active_by_token(db, token) if token else None + if row is None: + raise HTTPException(status_code=401, detail="invalid token") + token_service.mark_used(row) + db.commit() + request.session[USER_SESSION_KEY] = True + request.session[USER_TOKEN_ID_KEY] = str(row.id) + return Response(status_code=204) + + @router.post("/logout", status_code=204) def logout(request: Request, response: Response) -> Response: """Sign out: clear the session AND expire the browser cookie. ``sign_out`` empties the session dict (which the middleware does not - re-persist — an empty session has nothing to sign), so this route also - sends ``delete_cookie`` to make the browser drop the signed cookie - right now. Idempotent: an anonymous logout is still a 204. + re-persist — an empty session has nothing to sign), so this route + also sends ``delete_cookie`` to make the browser drop the signed + cookie right now. One logout wipes BOTH roles (admin and token — + the session is one dict). Idempotent: an anonymous logout is still + a 204. """ sign_out(request.session) response.delete_cookie(get_settings().session_cookie, path="/") @@ -55,9 +107,24 @@ def logout(request: Request, response: Response) -> Response: @router.get("/whoami", response_model=WhoamiResponse) def whoami(request: Request) -> WhoamiResponse: - """Who is the caller? Drives every UI gating decision (phase 16).""" - authenticated = bool(request.session.get(ADMIN_SESSION_KEY)) - return WhoamiResponse( - authenticated=authenticated, - role="admin" if authenticated else "anonymous", - ) + """Who is the caller? Drives every UI gating decision. + + Three roles (phase 79): ``admin`` (the signed-in admin — wins when + the browser holds BOTH an admin and a token session), ``user`` (a + token holder), ``anonymous``. ``authenticated`` is true for admin + AND user; the UI's admin-only surfaces key off ``role === "admin"`` + specifically, not off ``authenticated``. + + This endpoint only reads the session keys — it does NOT live-check + the token row. A token session whose row was just revoked still + reports ``user`` here until the next gated request: ``require_user`` + then pops the dead keys, after which this endpoint reports + anonymous (the phase-79 live-enforcement contract). + """ + if request.session.get(ADMIN_SESSION_KEY): + role = "admin" + elif request.session.get(USER_SESSION_KEY): + role = "user" + else: + role = "anonymous" + return WhoamiResponse(authenticated=role != "anonymous", role=role) diff --git a/app/api/chat.py b/app/api/chat.py index 39e3ce1..24ba57e 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -149,6 +149,7 @@ from sqlalchemy.orm import Session from app.api.steering import load_steering_notes from app.config import Settings, get_settings +from app.core.auth import require_user from app.db import db_available, get_db from app.models import Document, QueryLog from app.rag.agent import ( @@ -296,10 +297,16 @@ def plan_turn( @router.post("/chat") async def chat( request: ChatRequest, + _user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token db: Session = Depends(get_db), # noqa: B008 llm: LLMClient = Depends(get_llm), # noqa: B008 ): - """One chat turn: SSE stream of ``delta`` events + a final ``done``.""" + """One chat turn: SSE stream of ``delta`` events + a final ``done``. + + User-gated (phase 79): anonymous callers get 401 ``authentication + required`` before any streaming — the ONLY anonymous content is the + shared chats. + """ if not db_available(): return JSONResponse( status_code=503, diff --git a/app/api/docs.py b/app/api/docs.py index 61d8eb6..dc80bb3 100644 --- a/app/api/docs.py +++ b/app/api/docs.py @@ -19,7 +19,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.sync import _sanitize_error -from app.core.auth import require_admin +from app.core.auth import require_admin, require_user from app.db import get_db from app.models import Chunk, Document from app.rag.llm import EmbeddingError, LLMClient @@ -42,11 +42,12 @@ def list_indexed_documents( ) -> DocList: """All indexed documents with per-document chunk counts. - Admin-only (phase 16 — the catalog is what the sign-in gates; the - document viewer itself stays public, see below). Anonymous callers - get 403 ``admin only`` and the Sources page renders its sign-in gate - instead. An empty list means the knowledge base has not been imported - yet — the Sources page renders its designed empty state in that case. + Admin-only (phase 16 — the catalog is what the admin sign-in gates; + the document viewer below is user-gated since phase 79, the shared + chats being the only anonymous surface). Anonymous callers get 403 + ``admin only`` and the Sources page renders its sign-in gate instead. + An empty list means the knowledge base has not been imported yet — + the Sources page renders its designed empty state in that case. """ rows = db.execute( select( @@ -78,7 +79,10 @@ def list_indexed_documents( @router.get("/documents/content", response_model=DocContent) def get_document_content( - source: str, path: str, db: Session = Depends(get_db) # noqa: B008 + source: str, + path: str, + db: Session = Depends(get_db), # noqa: B008 + _user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token ) -> DocContent: """Full content of one indexed document, looked up by ``(source, path)``. @@ -86,10 +90,12 @@ def get_document_content( strings such as ``../../etc/passwd`` — are just non-existent rows and map to 404 ``{detail: "document not found"}``. - Deliberately PUBLIC for anonymous callers (phase 16 soft rule, owner - decision 2026-08-22): the *catalog* (``GET /api/docs``) is what the - sign-in gates, not the viewer — chat cites documents and anyone may - open a cited document by direct URL. + User-gated (phase 79 — SUPERSEDES the phase-16 "deliberately PUBLIC + (soft rule)" note, owner decision 2026-08-22): the viewer content is + token-or-admin like the rest of the app surface — chat cites + documents and a signed-in user (admin or token holder) opens a cited + document by direct URL. The ONLY anonymous content left is the + shared chats. """ row = db.execute( select(Document, func.count(Chunk.id).label("chunks")) diff --git a/app/api/steering.py b/app/api/steering.py index de6c925..038e0fb 100644 --- a/app/api/steering.py +++ b/app/api/steering.py @@ -10,8 +10,9 @@ into the system prompt of **every** chat turn as the ```` section replacement of ``note``, ``created_at`` preserved), ``DELETE /{note_id}``. The whole router sits behind :func:`app.core.auth.require_admin` — anonymous callers get 403 on every steering route (the chat turn itself -reads the table in-process and stays public); the PUT route adds no auth -surface of its own, it reuses that router-level dependency. +reads the table in-process; since phase 79 that turn is user-gated +through ``require_user``); the PUT route adds no auth surface of its +own, it reuses that router-level dependency. """ from __future__ import annotations diff --git a/app/api/suggestions.py b/app/api/suggestions.py index 6f00d7d..94b908c 100644 --- a/app/api/suggestions.py +++ b/app/api/suggestions.py @@ -1,14 +1,88 @@ -"""Suggested-question endpoint (drives the onboarding chips in the UI).""" +"""Suggested-question endpoint (drives the onboarding chips in the UI). + +Phase 80: the chips are the **last 3 questions asked** — the three +most recent user questions across ALL saved chats (chats walked +newest-``updated_at`` first, each chat's messages walked newest-first, +exact de-duplicated, cap 3 — see :func:`last_questions`). A fresh +deployment — zero saved questions — gets the seed list instead +(``BOR_SUGGESTIONS`` override, or the built-in default). + +Phase 79: user-gated (``require_user``) — the chips are part of the app +surface (chat, suggestions, cited documents); the ONLY anonymous +content is the shared chats (the phase-16 "everything but the admin +surface is open" stance is superseded). + +The deflection "Maybe try" chips (``app.rag.suggestions. +derive_suggestions``) are a SEPARATE contract (title-derived, carried +in the chat response) and are untouched by this endpoint. +""" from __future__ import annotations -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session from app.config import get_settings +from app.core.auth import require_user +from app.db import get_db +from app.models import SavedChat from app.schemas import SuggestionList router = APIRouter(tags=["chat"]) +def last_questions(db: Session, limit: int = 3) -> list[str]: + """The ``limit`` most recent user questions, across all saved chats. + + Chats are walked ``updated_at DESC, created_at DESC`` (the + tiebreak keeps the order deterministic when timestamps collide); + each chat's ``messages`` (a JSONB column that deserializes to a + plain Python list of ``bor.chat.v1`` dicts — NO SQL JSON ops + needed, the record shape is the ``SavedChat.messages`` model + docstring) is walked in REVERSE (conversational order is + oldest→newest), collecting the whitespace-trimmed ``text`` of + every entry with ``who == "user"``. Blank texts are skipped. + + De-duplication is EXACT (case-sensitive) against the collected + window: a verbatim re-ask counts once, while a legitimately + differently-cased re-ask is kept (case-insensitive dedup would + drop it). The walk stops once ``limit`` UNIQUE texts are + collected; the result is in encounter order (newest first). + + Pure-DB helper (unit-testable without the endpoint); returns + ``[]`` when no saved question exists (the caller then falls back + to the seed list). + """ + result: list[str] = [] + seen: set[str] = set() + for chat in db.scalars( + select(SavedChat).order_by( + SavedChat.updated_at.desc(), SavedChat.created_at.desc() + ) + ): + for m in reversed(chat.messages or []): + if m.get("who") != "user": + continue + question = str(m.get("text", "")).strip() + if not question or question in seen: + continue + seen.add(question) + result.append(question) + if len(result) >= limit: + return result + return result + + @router.get("/suggestions", response_model=SuggestionList) -def suggestions() -> SuggestionList: - return SuggestionList(suggestions=get_settings().suggestions) +def suggestions( + _user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token + db: Session = Depends(get_db), # noqa: B008 +) -> SuggestionList: + """The onboarding chips (admin OR token user, else 401): the last 3 + questions asked across saved chats — or, before any question has + ever been saved, the seed list (``BOR_SUGGESTIONS`` / the + built-in default). The deflection "Maybe try" chips are a separate + contract (``app.rag.suggestions.derive_suggestions``), untouched. + """ + qs = last_questions(db) + return SuggestionList(suggestions=qs if qs else get_settings().suggestions) diff --git a/app/api/tokens.py b/app/api/tokens.py new file mode 100644 index 0000000..787f32f --- /dev/null +++ b/app/api/tokens.py @@ -0,0 +1,112 @@ +"""Tokens admin API (phase 79, task 02). + +The admin surface for the issued access tokens (owner-locked A4): +generate a named token (the plaintext is shown **exactly once**, in the +201 body), list tokens (display fields only — no plaintext, no hashes), +and revoke one (idempotent). The whole router sits behind +:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the +:mod:`app.api.doc_drafts` pattern): tokens are admin-only, so anonymous +callers get 403 on every route — and once the token-auth login lands +(task 03), a token *user* stays 403 here too (only the admin manages +tokens). + +Routes (all under ``/api`` via the ``main`` registration): +``POST /api/tokens`` (201 ``TokenCreated`` — the ONE response shape +that carries the plaintext ``token``), ``GET /api/tokens`` +(``TokenList`` — newest first, secret-free), ``POST +/api/tokens/{token_id}/revoke`` (204, idempotent; unknown id → 404 +``token not found``). +""" +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core import tokens as token_service +from app.core.auth import require_admin +from app.db import get_db +from app.models import ApiToken +from app.schemas import TokenCreated, TokenCreateRequest, TokenList, TokenListItem + +router = APIRouter( + prefix="/tokens", + tags=["tokens"], + dependencies=[Depends(require_admin)], # phase 79: the token admin surface is admin-only +) + + +@router.post("", response_model=TokenCreated, status_code=201) +def create_token( + payload: TokenCreateRequest, + db: Session = Depends(get_db), # noqa: B008 +) -> TokenCreated: + """Generate one named token (201). + + The ``token`` field of this response is the ONE AND ONLY moment the + plaintext exists on the wire (A4): the row stores the SHA-256 hash + of the full token string, and no other response shape — in + particular the list — ever carries it. ``label`` is the hand-out + name, display-only and NOT unique (two tokens may share a label). + Blank/over-long labels are a 422 from the schema (the house + ``ValueError`` pattern — fail loud at the boundary). + """ + row, plaintext = token_service.create_token(db, payload.label) + db.commit() # the service flushes; the endpoint owns the commit + db.refresh(row) # pulls the server-default created_at + return TokenCreated( + id=row.id, label=row.label, token=plaintext, created_at=row.created_at + ) + + +@router.get("", response_model=TokenList) +def list_tokens( + db: Session = Depends(get_db), # noqa: B008 +) -> TokenList: + """All tokens, newest first (``created_at desc, id desc`` tiebreak). + + Secret-free by construction: :class:`~app.schemas.TokenListItem` has + no ``token`` and no ``token_hash`` field — the list never carries a + credential in either form. ``revoked`` is derived from + ``revoked_at is not None``; ``last_used_at`` stays null until the + token is first used (task 03 stamps it on ``POST /api/token-auth``) + """ + rows = ( + db.execute( + select(ApiToken).order_by(ApiToken.created_at.desc(), ApiToken.id.desc()) + ) + .scalars() + .all() + ) + items = [ + TokenListItem( + id=row.id, + label=row.label, + created_at=row.created_at, + last_used_at=row.last_used_at, + revoked=row.revoked_at is not None, + ) + for row in rows + ] + return TokenList(tokens=items) + + +@router.post("/{token_id}/revoke", status_code=204) +def revoke_token( + token_id: uuid.UUID, + db: Session = Depends(get_db), # noqa: B008 +) -> Response: + """Revoke one token (204) — idempotent. + + Already-revoked → still 204 with NO re-stamp (the original + ``revoked_at`` — the revocation time — is preserved; the service + only stamps when unset). Unknown id → 404 ``token not found`` (one + message for every unknown id). Revocation takes effect immediately: + the holder's next request is refused (the task-03 live check). + """ + if not token_service.revoke(db, token_id): + raise HTTPException(status_code=404, detail="token not found") + db.commit() + return Response(status_code=204) diff --git a/app/config.py b/app/config.py index ea4720f..ce933c4 100644 --- a/app/config.py +++ b/app/config.py @@ -351,7 +351,11 @@ class Settings(BaseSettings): raise ValueError(f"{name} must not contain '..' (a git branch token)") return v - # Suggested questions (onboarding + empty state). + # Onboarding-chip SEED (phase 80, TODO.md L6): shown ONLY while no + # saved chat has ever asked a question — after that, + # ``GET /api/suggestions`` serves the last 3 questions asked + # (deployment-wide, newest first). ``BOR_SUGGESTIONS`` overrides + # this seed for a new deployment. suggestions: list[str] = [ "What documents are in the knowledge base?", "Which source does each answer come from?", diff --git a/app/core/auth.py b/app/core/auth.py index c448b0b..fc369fa 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -1,9 +1,10 @@ -"""Single-admin authentication (phase 16; LOCKED A10 revised 2026-08-22). +"""Authentication (phase 16 single-admin; phase 79 token users). -One admin (the owner), one plaintext password, one signed cookie. The -mechanism is Starlette's ``SessionMiddleware`` (itsdangerous-signed cookie -— no server-side store, no new services, no DB tables): the public API -stays stateless, the cookie is the *only* session state. +One admin (the owner), one plaintext password; admin-issued access +tokens (``bor_`` + 32 hex — see :mod:`app.core.tokens`) for handed-out +users. The mechanism is Starlette's ``SessionMiddleware`` +(itsdangerous-signed cookie — no server-side store, no new services): +the public API stays stateless, the cookie is the *only* session state. Contract: * ``ensure_admin_configured`` — fail-loud startup gate: the app must name @@ -11,22 +12,41 @@ Contract: * ``check_password`` — constant-time compare; exactly one generic 401 message (no user enumeration, there is no second user). * ``require_admin`` — FastAPI dependency; anonymous callers get 403 - ``{"detail": "admin only"}`` (used by ``GET /api/docs`` and the whole - ``/api/steering`` router). + ``{"detail": "admin only"}`` (the admin-only surfaces: ``GET + /api/docs``, the whole ``/api/steering`` router, the token admin + API, …). +* ``require_user`` — FastAPI dependency (phase 79); admin OR a live + token session passes, everything else gets 401 ``authentication + required``. The token path live-checks the ``api_tokens`` row on + every request (the PK lookup IS the revocation check) and drops a + dead session (row revoked or gone) from the cookie right there. * ``sign_in`` / ``sign_out`` — session-dict helpers for the API routes. + One session dict carries BOTH roles (an admin browser that also holds + a token reports admin); ``sign_out``'s ``session.clear()`` wipes + everything — one logout, both roles. """ from __future__ import annotations import secrets +import uuid from collections.abc import MutableMapping from typing import Any -from fastapi import HTTPException, Request +from fastapi import Depends, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.orm import Session from app.config import Settings +from app.db import get_db +from app.models import ApiToken #: The session key the single admin is stored under. ADMIN_SESSION_KEY = "admin" +#: The session key a token holder is stored under (phase 79). +USER_SESSION_KEY = "user" +#: The session key holding the ``api_tokens`` row id of a token session — +#: ``require_user``'s live check fetches that row on every request. +USER_TOKEN_ID_KEY = "user_token_id" def ensure_admin_configured(settings: Settings) -> None: @@ -70,6 +90,51 @@ def require_admin(request: Request) -> None: raise HTTPException(status_code=403, detail="admin only") +def require_user(request: Request, db: Session = Depends(get_db)) -> None: # noqa: B008 + """FastAPI dependency: allow the admin or a LIVE token holder, else 401. + + Guards the app surface (``POST /api/chat``, ``GET /api/suggestions``, + ``GET /api/documents/content`` — phase 79: the ONLY anonymous content + is the shared chats). The matrix: + + * admin key set → pass, token state irrelevant (an admin browser + that also holds a token still passes as admin); + * ``user`` key set → the ``api_tokens`` row for ``user_token_id`` is + fetched (a PK hit — the live revocation check, no session store): + row missing OR ``revoked_at`` set → BOTH user keys are popped from + the session (the dead session is dropped NOW — the next + ``whoami`` is anonymous) and the request gets 401; + * neither key → 401. + + The failure detail is ``authentication required`` on 401 — not 403: + there is no higher privilege that would unblock an anonymous caller + (phase 79 auth-error semantics), unlike ``require_admin``'s surfaces. + """ + if request.session.get(ADMIN_SESSION_KEY): + return + if request.session.get(USER_SESSION_KEY): + token_id_raw = request.session.get(USER_TOKEN_ID_KEY) + token_id: uuid.UUID | None = None + if isinstance(token_id_raw, str): + try: + token_id = uuid.UUID(token_id_raw) + except ValueError: + token_id = None # corrupt session — treat as a missing row + row = ( + db.execute(select(ApiToken).where(ApiToken.id == token_id)) + .scalars() + .first() + if token_id is not None + else None + ) + if row is None or row.revoked_at is not None: + request.session.pop(USER_SESSION_KEY, None) + request.session.pop(USER_TOKEN_ID_KEY, None) + raise HTTPException(status_code=401, detail="authentication required") + return + raise HTTPException(status_code=401, detail="authentication required") + + def sign_in(session: MutableMapping[str, Any]) -> None: """Mark the (cookie-backed) session as the single admin. diff --git a/app/core/caching.py b/app/core/caching.py index 24975b2..7067074 100644 --- a/app/core/caching.py +++ b/app/core/caching.py @@ -142,6 +142,7 @@ HTML_PAGES: tuple[str, ...] = ( "/tuning.html", "/git-sources.html", # phase 35: the admin git sources page "/history.html", # phase 50: the admin saved-chats page + "/tokens.html", # phase 79 task 06: the admin tokens page (shell route) # phase 51: the shared page's STATIC path (the static mount serves # shared.html at /shared.html as well as the real route serves the # dynamic /shared/ — both must carry the no-cache + ?v= diff --git a/app/core/tokens.py b/app/core/tokens.py new file mode 100644 index 0000000..f22e65e --- /dev/null +++ b/app/core/tokens.py @@ -0,0 +1,118 @@ +"""API-token service (phase 79, task 02). + +Generates, stores, looks up and revokes the admin-issued access tokens +(``api_tokens`` rows — phase 79, task 01). The trust model (owner-locked +A4): the plaintext token (``bor_`` + 32 hex chars) exists only in the 201 +response of the create call, returned **exactly once**; the row carries +the SHA-256 hex digest of the **full** token string and nothing else. + +Lookup security — by hash, not by compare: +:func:`find_active_by_token` hashes the presented token and performs one +``token_hash ==`` lookup — a unique-index hit (``ix_api_tokens_token_hash``). +SHA-256's pre-image resistance means there is **no token-enumeration or +timing surface beyond the DB lookup itself**: an attacker holding the table +cannot turn a stored hash back into a working token, and a wrong candidate +simply misses the index. This is the deliberate contrast with +:func:`app.core.auth.check_password`'s constant-time compare — there IS +nothing to compare in constant time here, only to *look up*; replicating a +compare would be theatre, so the contrast is documented, not replicated. + +House commit convention (the ``app.rag.sources_meta`` pattern): the service +functions flush but never commit — the calling endpoint owns the commit, so +a failed request can never leave a half-applied token mutation. +""" +from __future__ import annotations + +import hashlib +import secrets +import uuid +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import ApiToken + +#: The plaintext token's shape: fixed prefix + 32 hex chars +#: (``secrets.token_hex(16)`` — 128 bits of entropy). +TOKEN_PREFIX = "bor_" + + +def generate_token() -> str: + """One fresh plaintext token: ``bor_`` + 32 hex chars (128-bit). + + The prefix is a human/parse marker only — the HASH covers the full + string, so the prefix is never the secret. + """ + return TOKEN_PREFIX + secrets.token_hex(16) + + +def hash_token(token: str) -> str: + """The stored credential: the SHA-256 hex digest of the **full** token. + + Hashing the full string (not the suffix) means a stripped prefix can + never collide with another token's hash. Deterministic — the same + token always yields the same 64-hex digest (the unique-index key). + """ + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def create_token(db: Session, label: str) -> tuple[ApiToken, str]: + """Create one named token; return the row + the plaintext ONCE. + + ``label`` is stripped before storing; non-emptiness is the API + layer's job (the 422 boundary — the service trusts it, A4). The row + only ever carries the hash (``token_hash``); the returned ``str`` is + the one and only moment the plaintext exists outside this function + (the endpoint ships it in the 201 body). + + Flushes, does not commit — the caller commits (so the row + its + server-default ``created_at`` are durable only when the endpoint's + response can be built). + """ + plaintext = generate_token() + row = ApiToken(label=label.strip(), token_hash=hash_token(plaintext)) + db.add(row) + db.flush() + return row, plaintext + + +def find_active_by_token(db: Session, token: str) -> ApiToken | None: + """Resolve a presented plaintext token to its ACTIVE row, or ``None``. + + Hash → ``token_hash ==`` lookup → ``revoked_at IS NULL``. ANY other + shape is a miss — there is no "almost" path: the hash of a malformed + string (wrong prefix, truncated, empty, …) simply matches no row, + so every failure mode returns the same ``None`` (the caller maps + that to one generic 401 — no enumeration, A4's auth-error contract). + """ + row = ( + db.execute(select(ApiToken).where(ApiToken.token_hash == hash_token(token))) + .scalars() + .first() + ) + if row is None or row.revoked_at is not None: + return None + return row + + +def mark_used(tok: ApiToken) -> None: + """Bump ``last_used_at`` to now (UTC) — the caller commits.""" + tok.last_used_at = datetime.now(UTC) + + +def revoke(db: Session, token_id: uuid.UUID) -> bool: + """Stamp ``revoked_at`` (UTC now); return False when the row is missing. + + Idempotent: an already-revoked row keeps its ORIGINAL stamp (the + revocation time is the first one, never re-stamped on a second + call) and the call still returns True — the row exists and is dead. + Returns False only for a missing id (the endpoint maps that to + 404 ``token not found``). Flushes, does not commit. + """ + row = db.get(ApiToken, token_id) + if row is None: + return False + if row.revoked_at is None: + row.revoked_at = datetime.now(UTC) + return True diff --git a/app/main.py b/app/main.py index c694935..2fec545 100644 --- a/app/main.py +++ b/app/main.py @@ -39,6 +39,7 @@ from app.api.health import router as health_router from app.api.steering import router as steering_router from app.api.suggestions import router as suggestions_router from app.api.sync import router as sync_router +from app.api.tokens import router as tokens_router from app.config import get_settings from app.core.auth import ensure_admin_configured from app.core.caching import configure_caching @@ -117,6 +118,9 @@ def create_app() -> FastAPI: app.include_router(sync_router, prefix="/api") app.include_router(chats_router, prefix="/api") app.include_router(doc_drafts_router, prefix="/api") + # Phase 79: the admin token surface (create/list/revoke) — admin-only + # (router-wide require_admin; a token USER stays 403 here, task 03). + app.include_router(tokens_router, prefix="/api") # Phase 51: the anonymous shared-chat read — NO admin dependency. # /api/shared/ is the JSON snapshot; /shared/ (the # page route below, registered without a prefix) is the page. @@ -135,7 +139,8 @@ def create_app() -> FastAPI: # Phase 76: the folded navbar views serve the shell — the # router picks the view from the pathname. Task 01 landed # Tuning; task 02 folds RAG + Sources; task 03 lands History - # (list-driven — all four non-chat navbar views are in). + # (list-driven — all four non-chat navbar views are in); + # phase 79 task 06 folds the sixth view (Tokens). _shell_routes( app, static_dir, @@ -144,6 +149,7 @@ def create_app() -> FastAPI: "/sources.html", "/git-sources.html", "/history.html", + "/tokens.html", # phase 79 task 06: the Tokens view ), ) app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") diff --git a/app/models.py b/app/models.py index b64fffe..58cde51 100644 --- a/app/models.py +++ b/app/models.py @@ -41,6 +41,20 @@ Data model — see ``.agents/PLAN.md`` §Data Model: ``commit_sha`` recorded) when the push endpoint commits + pushes the file to the ``BOR_DOCS_REPO`` branch (phase 59). +* ``api_tokens`` — admin-issued access tokens: one row per + generated token, so a person handed a token can + sign in to use the app (chat, suggestion chips, + cited documents) — the ONLY content that stays + anonymous is the shared chats (phase 79). + ``token_hash`` is the SHA-256 hex digest of the + full ``bor_…`` token string (the stored + credential — the plaintext exists only in the 201 + create response, returned exactly once); + ``revoked_at`` set = dead (live-checked on the + holder's next request), ``last_used_at`` bumped + on ``POST /api/token-auth`` (task 03 — the only + request that presents the token; the in-app gate + re-sends the cached token on every page load). """ from __future__ import annotations @@ -314,3 +328,47 @@ class SavedChat(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) + + +class ApiToken(Base): + """One admin-issued access token (phase 79, task 01). + + The admin generates named tokens and hands them out so people can + sign in to the app and use it (chat, suggestion chips, cited + documents) — the ONLY content that stays anonymous is the shared chats + (extending the phase-16 single-admin auth; the ``require_user`` + live-check and the admin token API land in tasks 02/03). + + Trust model — the plaintext token (``bor_`` + 32 hex chars) exists + only in the 201 response of the create call, returned **exactly + once**; the row never carries it. The stored credential is the + SHA-256 hex digest of the **full** token string (``token_hash``): + hashing the full string, not the suffix, so a stripped prefix can + never collide. The ``saved_chats.share_token`` / + ``doc_drafts.token`` lineage — but HASHED: unlike those + unguessable ``uuid4`` link tokens these are long-lived hand-out + credentials, and a leaked database must not hand anyone working + tokens. + """ + + __tablename__ = "api_tokens" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + #: The hand-out name (e.g. "alice") — display-only: no index, not + #: unique (two tokens may share a label). + label: Mapped[str] = mapped_column(String(120), nullable=False) + #: The stored credential: the SHA-256 hex digest of the full + #: ``bor_…`` token string (the ``documents.content_hash`` + #: String(64) precedent). Unique — the lookup is a unique-index hit + #: (``ix_api_tokens_token_hash`` — the explicit unique-index shape + #: of ``ix_saved_chats_share_token``, phase 51). + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + #: Bumped to now() on ``POST /api/token-auth`` (task 03 calls the + #: service's ``mark_used`` and commits); NULL until the token is + #: first used. + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + #: Set on revocation (task 02) — the row is dead from that moment + #: (enforced immediately on the holder's next request); NULL while + #: active. + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/app/schemas.py b/app/schemas.py index 062e6d7..140af97 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -86,10 +86,13 @@ class LoginRequest(BaseModel): class WhoamiResponse(BaseModel): - """``GET /api/whoami`` (phase 16) — drives all UI gating.""" + """``GET /api/whoami`` (phase 16; phase 79 added the ``user`` role) + — drives all UI gating. ``authenticated`` is true for admin AND + user; the UI's admin-only surfaces key off ``role === "admin"``. + """ authenticated: bool - role: str # "admin" | "anonymous" + role: str # "admin" | "user" | "anonymous" class SourceRef(BaseModel): @@ -688,3 +691,77 @@ class DocDraftPushed(BaseModel): status: Literal["pushed"] = "pushed" branch: str commit_sha: str + + +class TokenCreateRequest(BaseModel): + """``POST /api/tokens`` body (phase 79, task 02): one named token + to generate and hand out. + + ``label`` is the hand-out name (e.g. "alice") — display-only, NOT + unique (two tokens may share a label). Trimmed *before* the length + constraints run, so a whitespace-only body is a 422 and a label with + surrounding spaces is stored clean (the ``SteeringNoteIn`` house + ``ValueError`` pattern — fail loud at the boundary). + """ + + label: str = Field(min_length=1, max_length=120) + + @field_validator("label", mode="before") + @classmethod + def _trim_label(cls, v: object) -> object: + return v.strip() if isinstance(v, str) else v + + +class TokenCreated(BaseModel): + """``POST /api/tokens`` 201 response (phase 79, task 02). + + The ONLY schema in the codebase that carries the plaintext ``token`` + — the wire moment it exists exactly once (A4). Every other response + shape (the list row, whoami, …) exposes display fields only: the + stored credential is the hash, and the hash itself is never a wire + field either. + """ + + id: uuid.UUID + label: str + token: str + created_at: datetime + + +class TokenListItem(BaseModel): + """One row of ``GET /api/tokens`` (phase 79, task 02). + + Deliberately secret-free: NO ``token`` field and NO ``token_hash`` + field exist on this shape — the list never carries a credential in + either form (A4). ``revoked`` is derived server-side from + ``revoked_at is not None`` (the UI renders the Active/Revoked state + from the flag, not the timestamp). + """ + + id: uuid.UUID + label: str + created_at: datetime + last_used_at: datetime | None + revoked: bool + + +class TokenList(BaseModel): + """``GET /api/tokens`` response: all tokens, newest first + (``created_at desc, id desc``).""" + + tokens: list[TokenListItem] + + +class TokenAuthRequest(BaseModel): + """``POST /api/token-auth`` body (phase 79, task 03): the plaintext + token a handed-out user presents at the in-app gate. + + Deliberately NO min-length validator: an empty/whitespace token is a + MALFORMED login attempt — the endpoint 401s ``invalid token`` (the + phase-16 generic-401 pattern, one message for every failure: no + enumeration). A 422 here would hint at input-shape differences on a + credential endpoint, so the shape is just ``str`` and the endpoint + owns the ``token.strip()`` check. + """ + + token: str diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 201059b..487ca3e 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -265,13 +265,15 @@ */ import { - fetchIsAdmin, + fetchWhoami, initSharedHeader, refreshSteering, announceSteering, } from "./header.js"; import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal +import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the in-app token gate +/* Phase 77 (task 02): the bor:view-refresh exclusion is deliberate — the in-flight SSE stream and the local conversation must survive every switch (phase 76), so the chat view never listens and never re-fetches on a show. */ const messagesEl = document.querySelector("#messages"); const emptyState = document.querySelector("#empty-state"); const suggestionsEl = document.querySelector("#suggestions"); @@ -1830,6 +1832,11 @@ function rememberBrainTurn(rawText, meta, replaceIndex = -1) { const signInLink = document.querySelector("#sign-in-link"); const signOutBtn = document.querySelector("#sign-out-btn"); let isAdmin = false; +// Phase 79 (task 05): the authenticated role — admin OR token user. +// The auth PAIR keys off it (both get Sign out, neither sees Sign +// in); the admin-ONLY surfaces (Tune, Save as doc, ?chat= boot load) +// still key off isAdmin alone. +let signedIn = false; /* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push gate * — GET /api/config's ``docs_repo_configured`` (settings.docs_configured @@ -1842,8 +1849,12 @@ let isAdmin = false; let docsRepoConfigured = false; function applyAuthState() { - if (signInLink) signInLink.hidden = isAdmin; - if (signOutBtn) signOutBtn.hidden = !isAdmin; + // Phase 79 (task 05): the pair keys off the authenticated role — a + // token user (isAdmin false, signedIn true) gets Sign out like the + // admin and no Sign in link (idempotent with header.js's own + // toggling, which does the same from the shared whoami). + if (signInLink) signInLink.hidden = signedIn; + if (signOutBtn) signOutBtn.hidden = !signedIn; } function startNewChat() { @@ -1855,6 +1866,14 @@ function startNewChat() { removeTyping(); messagesEl.querySelectorAll(".msg").forEach((el) => el.remove()); if (emptyState) emptyState.hidden = false; + // Phase 80 (task 03): the empty state is BACK — the onboarding row was + // fetched once at boot, and while the user was chatting the last-3 + // state moved on. Refetch through the existing progressive-enhancement + // path (fetches /api/suggestions, re-renders #suggestions in place, + // swallows its own failures — a 401 for an anonymous visitor or a + // network drop just leaves the row as-is, no error spam). The + // in-flight-turn guard above means this only runs for a real new chat. + loadSuggestions(); clearErrorBanner(); setUiState(UI_STATE.idle); input.value = ""; @@ -2337,10 +2356,27 @@ window.addEventListener("pagehide", () => { the anonymous removal of the tuning surface both happen inside initSharedHeader() now. Phase 55 (A2): the local restore hydrates currentChatId from the record (restoreConversation), so the row link - survives a plain reload — no Save pill to reveal anymore. */ + survives a plain reload — no Save pill to reveal anymore. + Phase 79 (task 05): the token gate (mountGate) settles BEFORE the + header boots — a cached token's silent re-auth lands before the + first whoami fires, and the header + the chat-page gating read the + post-auth role (the auth pair off `authenticated`, the admin-only + surfaces off role === "admin"). */ (async () => { + // Phase 79 (task 05): the token gate settles FIRST — a cached + // bor.token is re-sent to /api/token-auth (silently) BEFORE the + // first whoami fires, so initSharedHeader below sees the + // POST-re-auth role deterministically (no stale "Sign in" for a + // returning token user; the gate and the header share the cached + // whoami promise — still exactly one /api/whoami per page load). + // onAuthed is a no-op in the shell: the lazy views mount on first + // show exactly as today (mount-once, hide-forever untouched), and + // the already-mounted views keep their state. + await mountGate(document.getElementById("main"), () => {}); await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate - isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami + const who = await fetchWhoami(); // the same cached promise — one whoami + isAdmin = who.role === "admin"; // phase 79: admin-only surfaces key off role + signedIn = who.authenticated; // the auth pair keys off the authenticated role // Phase 59: /api/config is settled BEFORE any bubble renders — // brand.js's single boot fetch (window.BOR_CONFIG_PROMISE, never // rejecting) has set window.BOR_DOCS_REPO_CONFIGURED (false until diff --git a/frontend/assets/document.js b/frontend/assets/document.js index 7f5efa7..872af9e 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -26,7 +26,13 @@ * renders via renderDocument into #doc-title / #doc-meta / * #doc-content. A missing document (unknown pair, missing params, * network error) shows the designed not-found card with a link back - * to the Sources page. + * to the Sources page. Phase 79 (task 05): the content endpoint is + * require_user-gated — a direct ANONYMOUS URL meets the inline + * token gate instead (the page document loads; the gated data does + * not): mountGate settles the auth (silent re-auth of a cached + * token, then the shared cached whoami) and the boot sequence + * (initSharedHeader + load) runs as its onAuthed — only a + * signed-in role (admin or token user) ever fetches the content. * * XSS-safe by construction: markdown is escaped before transform, raw * formats are set via textContent, and every document-derived string @@ -63,6 +69,7 @@ */ import { fetchIsAdmin, initSharedHeader } from "./header.js"; +import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the inline token gate /* Phase 39: the page title's display name — window.BOR_BRAND (set at * parse time by the classic assets/brand.js, refreshed from @@ -357,16 +364,35 @@ if (document.querySelector("#doc-title")) { /* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the * viewer now carries the SAME standard bar as every other page — - * nav incl. the admin-only links, Tuning toggle, Sync, New chat, the - * auth pair — all toggled here on the module's cached whoami. - * Independent of the doc fetch (its own IIFE — load() below never - * waits on it). - * (fetchIsAdmin is imported for parity with the other header - * consumers — the module's cached promise is the single whoami per - * page either way.) */ - (async () => { - await initSharedHeader(); - })(); + * nav incl. the admin-only links, the auth pair — toggled on the + * module's cached whoami (the single whoami per page; fetchIsAdmin + * is imported for docAdminReady's parity — the same cached promise + * either way). + * + * Phase 79 (task 05): the gate settles the auth FIRST — silent + * re-auth of a cached token, then the role check (on the shared + * cached whoami) — and the EXISTING boot sequence runs only on the + * SETTLED role: onAuthed (the content load) for a signed-in role + * ONLY (anonymous never fetches the content — the inline gate is + * the surface, no not-found card for an auth failure; #main is + * inert while the gate is visible — WCAG, the inert-pair contract), + * and the shared header boots in the .then AFTER the gate settles + * for EVERY role (the gate locks #main, not the header — the + * anonymous contract is byte-identical to the shell: Sign in + * offered, admin links ship hidden, the steering panel removed). + * Awaiting the gate first is what makes the header race-free: a + * silent re-auth lands before the first whoami fires, so the header + * reads the post-auth role exactly once (no stale anonymous bar for + * a returning token user, no second whoami). An admin (or a + * validly cached token user) gets onAuthed immediately — the gate + * (which ships hidden + inert) never shows. The admin-only edit + * affordance (docAdminReady() inside renderDocument) stays + * admin-only — it runs only in the post-auth render path. */ + mountGate(document.getElementById("main"), () => { + load(); + }).then(() => { + void initSharedHeader(); // every role — on the SETTLED whoami + }); /* The New chat binding is module-owned (assets/header.js, phase 34 * task 02 — the SINGLE binding): on this non-chat page it clears the @@ -391,5 +417,4 @@ if (document.querySelector("#doc-title")) { } } - load(); } diff --git a/frontend/assets/git-sources.js b/frontend/assets/git-sources.js index b25e76f..896eccc 100644 --- a/frontend/assets/git-sources.js +++ b/frontend/assets/git-sources.js @@ -36,6 +36,7 @@ * (the exact Sources page gate pattern, and NO /api/git-sources * call is made); admin → gate hidden, #git-sources-content * revealed, loadSources(). + * * • loadSources() — GET /api/git-sources → the table rows * (#git-sources-tbody), the env-fallback note's visibility * (from_env), and the empty state. Each row leads with its kind @@ -128,6 +129,20 @@ * aria-live=polite): the screen-reader confirmation for loads, * adds, and removals. * + * Phase 77 (task 02) — the re-show refresh: the shell router + * dispatches `bor:view-refresh` on the view's section when the user + * RE-SHOWS an already-mounted view (a switch back onto it, a re-click + * of the Sources nav link, or back/forward) — the first show (mount) + * and boot never (the mount's own load is the first fetch). This + * module listens on root and re-runs `loadSources()`, whose re-call + * resets all three list states: the populated render (renderSources + * replaces the tbody + re-syncs the empty state) and the load error + * (hideLoadError() runs on the success path, so an error followed by + * a successful refresh clears it). The listener is armed only in the + * ADMIN branch, after the whoami gate passes: anonymous shows the + * gate and never fetches /api/git-sources (the Sources-page gate + * pattern). + * * Scope boundary (phase locked decisions): adding a git repo does * NOT clone — the sync service (server-side) does that. Removing a * source, however, performs the FULL cleanup server-side (phase 69): @@ -908,6 +923,13 @@ export async function mount(root) { } if (gateEl) gateEl.hidden = true; if (contentEl) contentEl.hidden = false; + /* Phase 77 (task 02): a user-initiated re-show of this already- + mounted view makes the router dispatch bor:view-refresh on the + section — re-run loadSources then (its re-call resets the + populated / empty / load-error states). Armed ONLY here, after + the whoami gate passed: anonymous shows the gate and must never + fetch /api/git-sources (the Sources-page gate pattern). */ + root.addEventListener("bor:view-refresh", () => loadSources()); await loadSources(); // Phase 64 (task 05): re-attach a running scan (a reload mid-scan // resumes the Processing state) or re-render a terminal run's diff --git a/frontend/assets/header.js b/frontend/assets/header.js index 4cc53fc..c273413 100644 --- a/frontend/assets/header.js +++ b/frontend/assets/header.js @@ -17,7 +17,12 @@ * (anonymous-safe default — the phase-16 "absent, not hidden" * spirit), so no anonymous user ever sees one for a frame; and * • the sign-out click binding (POST /api/logout → reload) — moved - * here from app.js so there is exactly one implementation; + * here from app.js so there is exactly one implementation. + * Phase 79 (task 05): it ALSO drops the cached token + * (localStorage["bor.token"], try/catch — the fail-silence storage + * contract) BEFORE the reload, so a signing-out token user meets + * the gate again on the next load (the server session is wiped by + * the logout; the localStorage key must go with it); * • the mobile hamburger binding (phase 46, owner permission * 2026-08-27, TODO.md L9) — at ≤640px (CSS hides the button * elsewhere) the #nav-toggle button opens the nav as an animated @@ -68,39 +73,96 @@ * every page; a page that lacks one simply skips it. * * whoami is fetched at most ONCE per page load: the promise is cached in - * the module-level `adminPromise`, so app.js's tuning gate, the sources + * the module-level `whoamiPromise`, so app.js's tuning gate, the sources * page's catalog gate, and the header toggling all share one request. - * Anonymous-safe: any network failure resolves to false (the anonymous - * UI), mirroring the per-page catch the pages used before phase 19. + * Phase 79 (task 05): the cache stores the FULL response — + * `{ authenticated, role }` (role: "admin" | "user" | "anonymous") — + * not just the admin flag: the token-gate module (assets/token-gate.js) + * reuses it for its role check, and `resetWhoami()` invalidates it + * right after a mid-page auth (a silent re-auth or an interactive + * login) so the next fetchWhoami() is a fresh post-auth request. + * Anonymous-safe: any network failure or non-2xx resolves to + * `{ authenticated: false, role: "anonymous" }` (the anonymous UI), + * mirroring the per-page catch the pages used before phase 19. * * A10/A11 untouched: no API change, no CDN, no state beyond the cached * promise; the soft gate page and the A10 API split are unchanged — * this is UI visibility only. */ -let adminPromise = null; +/* The anonymous fallback (phase 79): non-2xx, a network failure, or a + malformed body all resolve to the anonymous role — the UI degrades + to the guest surface, never to an error (the phase-19 anonymous-safe + contract, unchanged in spirit). */ +const ANONYMOUS_WHOAMI = Object.freeze({ authenticated: false, role: "anonymous" }); -/* The SINGLE /api/whoami call site for the whole frontend. First call - stores the promise in `adminPromise`; every later call — on this page - — returns the same promise, i.e. exactly one request per page load. - Anonymous-safe: non-2xx or a network failure resolves to false. */ -export function fetchIsAdmin() { - if (!adminPromise) { - adminPromise = fetch("/api/whoami") - .then(async (r) => (r.ok ? (await r.json()).authenticated === true : false)) - .catch(() => false); +let whoamiPromise = null; + +/* The SINGLE /api/whoami call site for the whole frontend (phase 79, + task 05: the cache now stores the FULL response — { authenticated, + role } — not just the admin flag). First call stores the promise in + `whoamiPromise`; every later call — on this page — returns the same + promise, i.e. exactly one request per page load. Anonymous-safe: + non-2xx or a network failure resolves to the anonymous role. */ +export function fetchWhoami() { + if (!whoamiPromise) { + whoamiPromise = fetch("/api/whoami") + .then(async (r) => { + if (!r.ok) return ANONYMOUS_WHOAMI; + const data = await r.json(); + return { + authenticated: data.authenticated === true, + role: + data.role === "admin" || data.role === "user" + ? data.role + : "anonymous", + }; + }) + .catch(() => ANONYMOUS_WHOAMI); } - return adminPromise; + return whoamiPromise; +} + +/* The phase-16/19 contract every existing admin gate consumes: the + role check is `role === "admin"` — a token user (role "user") is + authenticated but NOT an admin, so every admin-only surface keys off + this (never off `authenticated`). SAME single request: it delegates + to fetchWhoami(), so all existing callers keep working with zero + changes. */ +export function fetchIsAdmin() { + return fetchWhoami().then((w) => w.role === "admin"); +} + +/* Phase 79 (task 05): the token gate changes the session MID-PAGE (a + silent re-auth of a cached token, or an interactive login) — a + whoami cached BEFORE that auth (fired at boot) is stale. The gate + clears the cache right after a successful auth, so the NEXT + fetchWhoami() is a fresh request carrying the post-auth role — and + every consumer that awaits it afterwards (the header re-boot, the + view gates) reuses that one fresh promise. */ +export function resetWhoami() { + whoamiPromise = null; } /* Toggle the shared header controls, only the ones present on this page (querySelector, null-safe — missing → no-op). Returns the admin flag - so callers can reuse it instead of awaiting fetchIsAdmin() again (the + so callers can reuse it instead of awaiting fetchWhoami() again (the cached promise makes both awaits the same single request). */ export async function initSharedHeader() { - const admin = await fetchIsAdmin(); - // The Sign in link: hidden for the admin, visible otherwise — and its - // href is rewritten to return the admin to THIS page after login + const whoami = await fetchWhoami(); + const admin = whoami.role === "admin"; + // Phase 79 (task 05): the auth PAIR keys off the authenticated role — + // admin OR token user: both get Sign out (the binding below drops the + // cached token too) and neither sees Sign in. The admin-ONLY surfaces + // (the nav links, the steering refresh) still key off role === + // "admin": a token user gets the anonymous branch — the links stay + // hidden and the steering panel is REMOVED from the DOM (/api/steering + // 403s a user, so it must never be fetched). Admin and anonymous + // behavior is byte-identical to phase 16/19. + const signedIn = whoami.authenticated; + // The Sign in link: hidden for any authenticated role (admin or + // token user — phase 79), visible for anonymous — and its href is + // rewritten to return the admin to THIS page after login // (phase 34 task 02: "return to where you were"). The markup keeps its // own static ?next= as the no-JS fallback. location.pathname is always // a query-safe "/…" string (never "//"; ? # and spaces stay @@ -118,10 +180,10 @@ export async function initSharedHeader() { const nextPath = window.location.pathname || "/"; const signInNext = nextPath.startsWith("/shared/") ? "/" : nextPath; document.querySelectorAll(".sign-in-link").forEach(link => { - link.hidden = admin; + link.hidden = signedIn; link.href = "/login.html?next=" + signInNext; }); - document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; }); + document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !signedIn; }); const navSources = document.querySelector("#nav-sources"); if (navSources) navSources.hidden = !admin; // Phase 35 (owner permission 2026-08-26): the Sources nav link — @@ -140,12 +202,20 @@ export async function initSharedHeader() { // the Tuning link above. const navHistory = document.querySelector("#nav-history"); if (navHistory) navHistory.hidden = !admin; + // Phase 79 (task 06): the Tokens nav link (the shell's sixth view — + // it ships ONLY in the shell's header) — admin-only, the same + // ship-hidden / reveal-for-admin contract as the History link above. + // Null-safe: a page without the link (the viewer / login / shared + // pages) is a no-op. A token user (role "user") never sees it. + const navTokens = document.querySelector("#nav-tokens"); + if (navTokens) navTokens.hidden = !admin; // Phase 34: the steering panel (phase 15) is module-owned. The // navbar #steering-toggle was removed at owner request (2026-08-28) // — the panel ships hidden and is only kept fresh. Admin: refresh - // the list (fire-and-forget). Anonymous: the panel is REMOVED from - // the DOM entirely — the phase-16 contract says "absent", not just - // hidden — and /api/steering is never fetched. + // the list (fire-and-forget). Anonymous AND token user (phase 79): + // the panel is REMOVED from the DOM entirely — the phase-16 contract + // says "absent", not just hidden — and /api/steering is never fetched + // (it 403s a user; only the admin's notes steer the prompt). if (admin) { if (steeringPanel) refreshSteering(); } else { @@ -169,9 +239,12 @@ export function clearChatStorage() { import, so every page that loads header.js gets it exactly once. Binds to all .sign-out-btn elements (bar copy for desktop + mobile dropdown copy for ≤640px). Disable during the call, POST /api/logout - (the result is ignored — the reload resets the UI either way), then - reload so the header re-resolves to the anonymous state (Sign in - back, Sources gone). */ + (the result is ignored — the reload resets the UI either way), drop + the cached token (phase 79: one logout clears BOTH the server + session and the localStorage key — a signing-out token user meets + the gate again), then reload so the header re-resolves to the + anonymous state (Sign in back, Sources gone, the gate back for the + not-yet-token holder). */ document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.addEventListener("click", async () => { btn.disabled = true; @@ -180,6 +253,12 @@ document.querySelectorAll(".sign-out-btn").forEach(btn => { } catch { /* the reload resets the UI either way */ } + try { + localStorage.removeItem("bor.token"); + } catch { + /* private mode / storage error — the server logout already signed + out; the next load re-gates either way */ + } window.location.reload(); }); }); diff --git a/frontend/assets/history.js b/frontend/assets/history.js index a5adbfd..555fe0e 100644 --- a/frontend/assets/history.js +++ b/frontend/assets/history.js @@ -78,6 +78,35 @@ * at boot via ?chat=, and the router never intercepts them (they * are not navbar links, and their query string keeps them out of * the VIEW map). + * + * Phase 77 (task 01) — the re-show refresh: the shell router + * dispatches `bor:view-refresh` on the view's section when the user + * RE-SHOWS an already-mounted view (a switch back onto it, a re-click + * of the History nav link, or back/forward) — the first show (mount) + * and boot never (the mount's own load is the first fetch). This + * module listens on root and re-runs `loadChats()`, which is now + * re-entrant: a re-load drops the data rows (the hidden + * #history-empty-row stays in the tbody) before fetching, so the list + * is REPLACED — never duplicated. The listener is armed only in the + * ADMIN branch, after the whoami gate passes: anonymous shows the + * gate and never fetches (the phase-50 contract the story E2E pins). + * + * Phase 77 (task 03) — the explicit refresh control (TODO.md L3: + * "The history page should also have a refresh button."): the + * #history-refresh button in the view's page-head (OUTSIDE the table + * wrap — reachable while the empty state is showing too). Bound only + * in the admin branch; the anonymous branch HIDES it (the gate is + * what anonymous sees — no dead control beside the sign-in gate). + * Lifecycle: click → disable (no double-fire while in flight) → + * `loadChats()` (re-entrant — the list is replaced) → announce the + * outcome in #history-status → re-enable (success AND failure, the + * finally). Success — a 0-row fetch is a success — lands + * `Saved chats refreshed.`; the failure lines now live INSIDE + * loadChats itself (the house copy: `Couldn't load saved chats — is + * the app reachable?` on a network error, `Couldn't load saved chats + * — try again.` on a non-2xx), so EVERY caller of a failed load — + * the mount's first load, a re-show, the button — sees the outcome + * (the §7.4 never-stale contract; a silent empty table is gone). */ import { fetchIsAdmin } from "./header.js"; @@ -89,6 +118,9 @@ export async function mount(root) { const emptyRow = root.querySelector("#history-empty-row"); const gateEl = root.querySelector("#history-gate"); const statusEl = root.querySelector("#history-status"); + // Phase 77 (task 03): the explicit refresh control — the page-head + // button (outside the table wrap, so the empty state never hides it). + const refreshBtn = root.querySelector("#history-refresh"); /* Action feedback — the role="status" live region above the table (the "never stale" contract: every row action lands a line here, @@ -441,28 +473,60 @@ export async function mount(root) { } /* GET /api/chats → render the rows (latest activity first — the - server's order). A 0-row fetch shows the empty-state row. */ + server's order). A 0-row fetch shows the empty-state row. + Re-entrant (phase 77 task 01): a re-show re-run must REPLACE the + list, not append a duplicate set — the data rows (every + EXCEPT the hidden #history-empty-row, which the load itself + re-hides / reveals) are dropped before the fetch. + Phase 77 (task 03): a FAILED load announces its line in the live + region — the house copy (network: "is the app reachable?"; non-2xx: + "try again.") — and the load RETURNS the outcome: true when the + fetch settled (a 0-row fetch is a SUCCESS — the empty state is + the honest view), false on non-2xx / network error, so the + refresh button's handler can land its own success line. */ async function loadChats() { + if (tbody) { + for (const tr of tbody.querySelectorAll("tr")) { + if (tr !== emptyRow) tr.remove(); + } + } if (emptyRow) emptyRow.hidden = true; let r; try { r = await fetch("/api/chats"); } catch { + announce("Couldn't load saved chats — is the app reachable?"); showEmptyState(); - return; + return false; } if (!r.ok) { + announce("Couldn't load saved chats — try again."); showEmptyState(); - return; + return false; } const { chats } = await r.json(); if (!chats.length) { showEmptyState(); - return; + return true; } for (const chat of chats) { tbody.appendChild(makeRow(chat)); } + return true; + } + + /* Phase 77 (task 03): the refresh button's in-flight run — the + re-entrant load (the failure line lands inside it) + the success + line + the re-enable (success AND failure — the finally, so a + click can never leave the button stuck disabled). */ + async function refreshChats() { + let ok = false; + try { + ok = await loadChats(); + } finally { + if (refreshBtn) refreshBtn.disabled = false; + } + if (ok) announce("Saved chats refreshed."); } /* ---------- view boot (phase 76 task 03) ---------- @@ -476,8 +540,34 @@ export async function mount(root) { if (!(await fetchIsAdmin())) { if (tableWrap) tableWrap.hidden = true; if (gateEl) gateEl.hidden = false; + if (refreshBtn) refreshBtn.hidden = true; // no dead control beside the gate return; } if (gateEl) gateEl.hidden = true; + if (refreshBtn) refreshBtn.hidden = false; // admin: the control is live + /* Phase 77: a user-initiated re-show of this already-mounted view + makes the router dispatch bor:view-refresh on the section — + re-load then (loadChats is re-entrant, so the list is replaced). + The listener is armed ONLY here, after the whoami gate passed: + anonymous shows the gate and must never fetch (phase 50). `started` + flips true once the first loadChats() is made (the next line), so + the listener can only ever re-run a load the mount already did. */ + let started = false; + root.addEventListener("bor:view-refresh", () => { + if (started) loadChats(); + }); + /* Phase 77 (task 03): the explicit Refresh control (TODO.md L3) — + the button's own lifecycle: click → disable (no double-fire while + the request is in flight) → refreshChats (the re-entrant load + + the outcome line + the re-enable). It is bound HERE, in the admin + branch only: the view is admin-gated, and anonymous never sees + the button (it is hidden above). */ + if (refreshBtn) { + refreshBtn.addEventListener("click", () => { + refreshBtn.disabled = true; // no double-fire while in flight + void refreshChats(); + }); + } + started = true; loadChats(); } diff --git a/frontend/assets/router.js b/frontend/assets/router.js index e932515..e4aff1d 100644 --- a/frontend/assets/router.js +++ b/frontend/assets/router.js @@ -32,6 +32,17 @@ * upload-progress poller) persist across every switch; that * persistence IS the phase-76 fix. The chat view needs no module: * app.js already ran at shell boot. + * • refresh hook (phase 77): a user-initiated re-show of an + * already-mounted view dispatches the `bor:view-refresh` CustomEvent + * on the view's
root — exactly when the view is shown + * AGAIN: a switch back onto it, a re-click of its own (active) nav + * link (NO pushState — the URL is already its path), or back/ + * forward (popstate) onto it. The FIRST show (the mount) and boot + * NEVER fire it — the mount's own load is the first fetch. A view + * module opts in by listening on its own root inside mount(); + * views that do not listen (Chat — app.js) are unaffected: the + * in-flight stream and the local conversation survive (the phase-76 + * LOCKED refinement). * • show = drop hidden + inert, hide = add BOTH (WCAG: a hidden view * must not receive focus or keyboard traversal — the inert pair * pins the [hidden] contract in the a11y tree, AGENTS.md rule 5). @@ -50,9 +61,9 @@ * chat view, runs at shell boot exactly as before) → router.js * (module — this file). No CDN, no framework, no bundler dependency: * a plain ES module whose dynamic imports (./tuning.js, task 01; - * ./sources.js + ./git-sources.js, task 02; ./history.js in task 03) - * resolve relatively in dev and are inlined by the Containerfile's - * esbuild stage in the image. + * ./sources.js + ./git-sources.js, task 02; ./history.js in task 03; + * ./tokens.js in phase 79 task 06) resolve relatively in dev and are + * inlined by the Containerfile's esbuild stage in the image. */ /* ---------- the view map (pathname → view name) ---------- @@ -67,6 +78,7 @@ const VIEW = { "/sources.html": "rag", // phase 76 task 02: the RAG view (knowledge base) "/git-sources.html": "git-sources", // phase 76 task 02: the Sources view "/history.html": "history", // phase 76 task 03: the History view (saved chats) + "/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens) }; /* The nav-link href the router stamps active for each view (the @@ -77,6 +89,7 @@ const VIEW_PATH = { rag: "/sources.html", "git-sources": "/git-sources.html", history: "/history.html", + tokens: "/tokens.html", }; /* The lazy view modules — ONLY the non-chat views (chat needs no @@ -89,6 +102,7 @@ const VIEW_MODULES = { rag: () => import("./sources.js"), // phase 76 task 02 "git-sources": () => import("./git-sources.js"), // phase 76 task 02 history: () => import("./history.js"), // phase 76 task 03 + tokens: () => import("./tokens.js"), // phase 79 task 06 }; /* Per-view document.head values, carried over from the old pages' @@ -101,6 +115,7 @@ const TITLES = { rag: "Sources · Brain of Reese", // old sources.html "git-sources": "Git sources · Brain of Reese", // old git-sources.html <title> history: "Saved chats · Brain of Reese", // old history.html <title> + tokens: "Access tokens · Brain of Reese", }; const DESCRIPTIONS = { chat: @@ -112,6 +127,7 @@ const DESCRIPTIONS = { "Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).", history: "Saved chats — every conversation is saved automatically, one click back.", // old history.html meta + tokens: "Generate and revoke the API tokens that let people use the app.", }; /* The brand-resolved display name (phase 39 — brand.js is the single @@ -136,9 +152,10 @@ for (const name of new Set(Object.values(VIEW))) { } /* The mount-once guard: a view is imported + mounted at most ONCE per - document life — re-shows are show/hide only (no refetch, no - re-mount; the view's state persists). Chat starts mounted: app.js - owns it and ran at shell boot. */ + document life — re-shows are show/hide only (no re-mount; the view's + state persists). A listening view re-fetches on a re-show through the + phase-77 refresh hook (bor:view-refresh), not a re-mount. Chat starts + mounted: app.js owns it and ran at shell boot. */ const mounted = { chat: true }; const nav = document.getElementById("app-nav"); @@ -156,6 +173,12 @@ async function switchTo(name, { userInitiated }) { const root = viewEls[name]; if (!root) return; + /* Phase 77: capture the mount state BEFORE the mount block — a + re-show of an already-mounted view dispatches bor:view-refresh + (further down); the first show (the mount) and boot never do (the + mount's own load is the first fetch). */ + const wasMounted = mounted[name]; + /* Mount-once: the lazy module is imported on FIRST show only, then mounted into the view's section. The guard runs BEFORE the import (a re-show never re-imports) and is set only after mount resolves @@ -193,6 +216,16 @@ async function switchTo(name, { userInitiated }) { current = name; + /* Phase 77: the re-show refresh — the view is already visible and + the head/nav state is written (event order: visible → refresh), + the focus/scroll tail runs after. Gated on the pre-mount capture: + a first show (the mount's own load is the first fetch) and boot + never dispatch. A listening view re-runs its load; the chat view + never listens (phase-76 stream survival). */ + if (wasMounted) { + root.dispatchEvent(new CustomEvent("bor:view-refresh")); + } + /* Focus the target view ONLY on user-initiated switches (navbar click / popstate) — never on initial boot (no focus steal on load). The top landing mirrors what the old per-view page loads @@ -218,7 +251,15 @@ if (nav) { if (!(href in VIEW)) return; // not a same-shell view — real navigation e.preventDefault(); const name = VIEW[href]; - if (name === current) return; // already visible (the menu still closes) + /* Phase 77: a re-click of the ACTIVE view's own link is a + re-fetch, not a no-op — the same refresh event the re-show + dispatches. No pushState (the URL is already this view's path); + the mobile menu still closes (the container handler runs + regardless). */ + if (name === current) { + viewEls[name].dispatchEvent(new CustomEvent("bor:view-refresh")); + return; + } history.pushState({ view: name }, "", href); switchTo(name, { userInitiated: true }); }); diff --git a/frontend/assets/sources.js b/frontend/assets/sources.js index e15b101..5177cb8 100644 --- a/frontend/assets/sources.js +++ b/frontend/assets/sources.js @@ -33,6 +33,20 @@ * The sync button (phase 32) + the live two-job progress contract * (phase 64 task 04) and the table rows (phase 26: same-page modal * links) are unchanged in content — only the boot shape moved. + * + * Phase 77 (task 02) — the re-show refresh: the shell router + * dispatches `bor:view-refresh` on the view's section when the user + * RE-SHOWS an already-mounted view (a switch back onto it, a re-click + * of the RAG nav link, or back/forward) — the first show (mount) and + * boot never (the mount's own load is the first fetch). This module + * listens on root and re-runs `loadDocs()`, which is now re-entrant: + * a re-load drops the tbody's rows BEFORE the fetch, so a refresh + * from a populated list into an empty result replaces the list + * (no ghost rows) — the History pattern (task 01). #sources-empty + * lives OUTSIDE the tbody (a .empty-state div), so the top clear is + * a bare replaceChildren(). The listener is armed only in the ADMIN + * branch, after the whoami gate passes: anonymous shows the gate and + * never fetches /api/docs (the phase-16 soft rule). */ import { fetchIsAdmin } from "./header.js"; @@ -469,25 +483,40 @@ export async function mount(root) { } + /* Phase 77 (task 02): re-entrant — the top clear (the History + pattern from task 01) drops the tbody's rows BEFORE the fetch, + so a re-show refresh from a populated list into an empty result + replaces the list instead of leaving ghost rows. #sources-empty + lives OUTSIDE the tbody (a .empty-state div, not a row), so the + clear is a bare replaceChildren(). Phase 79 (task 04): the clear + alone is NOT enough when two loads interleave — the boot re-attach + (applySyncSuccess → loadDocs) and the boot-time loadDocs both clear + first, then the SLOWER fetch appends after the newer load's clear, + duplicating every row (2×). The monotonic seq token invalidates an + in-flight load the moment a newer one starts: only the newest load + may touch the DOM after its await. */ + let loadSeq = 0; async function loadDocs() { + const my = ++loadSeq; + tbody.replaceChildren(); let r; try { r = await fetch("/api/docs"); } catch { - showEmpty(); + if (my === loadSeq) showEmpty(); return; } if (!r.ok) { - showEmpty(); + if (my === loadSeq) showEmpty(); return; } const { documents } = await r.json(); + if (my !== loadSeq) return; // a newer load owns the tbody now if (!documents.length) { showEmpty(); return; } - tbody.replaceChildren(); let totalChunks = 0; let last = ""; for (const d of documents) { @@ -564,5 +593,12 @@ export async function mount(root) { return; } if (gateEl) gateEl.hidden = true; + /* Phase 77 (task 02): a user-initiated re-show of this already- + mounted view makes the router dispatch bor:view-refresh on the + section — re-load the catalog then (loadDocs is re-entrant). + Armed ONLY here, after the whoami gate passed: anonymous shows + the gate and must never fetch /api/docs (the phase-16 soft + rule the story E2E pins). */ + root.addEventListener("bor:view-refresh", () => loadDocs()); loadDocs(); } diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index a128aea..6c5452e 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -67,30 +67,12 @@ body { min-height: 100dvh; } -/* ---------- Animated background (pure CSS, zero JS — phase 08; reworked - phase 25: no movement, only fading light) ---------- - Owner direction (2026-08-25, verbatim): "It should be smooth, - fluxuating, dimming and brightening, but not moving. Different bright - spots should slowly fade in and out." - - NO movement anywhere in the background: no grid drift, no - transform/scale, no background-position animation. The 44px/60s grid - drift (0.73px/s, diagonally down-right) rasterizes sub-pixel by - sub-pixel and reads as a once-per-second jitter; the 14s whole-layer - opacity+scale pulse reads as a uniform blink. Both are gone. - - Three independent soft glow spots, each fading in and out on its own - SLOW opacity-only cycle — 26s / 34s / 42s, ease-in-out, with negative - delays (-12s, -23s) so the cycles run out of phase (LCM 4641s: the - composite pattern effectively never repeats within a viewing - session). The total light fluxuates smoothly and irregularly. - - html::before / html::after join body::before / body::after as - background layers: <html> is the root stacking context, so their - z-index:-1 pseudo-elements paint ABOVE the var(--bg) canvas and - BELOW the transparent, non-stacking <body>'s content — the - no-occlusion contract (html owns the canvas, body stays - transparent) is unchanged. - - No filter (phase-08 no-blur perf anchor), no JS, no new assets; - opacity-only keyframes stay compositor-friendly. - - prefers-reduced-motion: reduce stills all four layers. */ +/* ---------- Static background (pure CSS, zero JS) ---------- + Phase 78 (owner direction, TODO.md L4): the animated background was + removed as too resource-intensive — the three opacity-fading glow + spots (and their keyframes) are deleted. What remains is fully + static: the 44px grid texture below — zero animation cost, zero JS, + no filter/blur. */ /* Static grid texture: 44px cells, 1px lines at 60% --line alpha, masked with a widened radial fade (visible across most of the viewport, @@ -112,48 +94,6 @@ body::before { mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%); } -/* Glow spot A — the phase-08 indigo (top-left): one soft radial spot - fading in and out on its own 26s opacity-only cycle. */ -body::after { - content: ""; - position: fixed; - inset: 0; - z-index: -1; - pointer-events: none; - background-image: radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), transparent 62%); - animation: bg-glow-a 26s ease-in-out infinite; -} - -/* Glow spot B — the phase-08 cyan (bottom-right): 34s cycle, -12s delay - (out of phase with spot A). */ -html::before { - content: ""; - position: fixed; - inset: 0; - z-index: -1; - pointer-events: none; - background-image: radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), transparent 62%); - animation: bg-glow-b 34s ease-in-out -12s infinite; -} - -/* Glow spot C — a third indigo (bottom-left): 42s cycle, -23s delay - (out of phase with spots A and B). */ -html::after { - content: ""; - position: fixed; - inset: 0; - z-index: -1; - pointer-events: none; - background-image: radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), transparent 62%); - animation: bg-glow-c 42s ease-in-out -23s infinite; -} - -/* Opacity-only fades — nothing but opacity may appear in any bg-* - keyframe (the no-movement contract, phase 25). */ -@keyframes bg-glow-a { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } } -@keyframes bg-glow-b { 0%, 100% { opacity: 0.20; } 50% { opacity: 1; } } -@keyframes bg-glow-c { 0%, 100% { opacity: 0.15; } 50% { opacity: 1; } } - .container { width: 100%; max-width: 72rem; @@ -1347,13 +1287,6 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } @media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2s; } } -/* The background layers are the only other motion on the page: under - reduced motion they go static (grid + glows remain, just still) — all - four layers (phase 25: the html::before / html::after spots join). */ -@media (prefers-reduced-motion: reduce) { - body::before, body::after, html::before, html::after { animation: none; } -} - /* ---------- Banners ---------- */ .kb-banner { display: flex; @@ -1695,6 +1628,114 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } } .sources-gate-link:hover { background: #7d88f5; } +/* Phase 79 (task 05): the in-app token gate — the gate surface of the + two token-only pages (the shell + the document viewer, one shared + .auth-gate markup each). A body-level FIXED overlay (the + body-level doc-modal precedent): while visible it is the ONLY + interactive surface — assets/token-gate.js locks the root (#main, + inert) so focus and keyboard traversal stay inside the gate + (WCAG, the inert-pair contract). + Stacking: z-index 500 — above the app content (the sticky header is + 20, the skip-link 100) but BELOW the doc-modal (1000), which can + only be opened from the unlocked app anyway. Solid --bg: the canvas + + grid texture live on <html> (the body stays transparent), so the + overlay reads as the app's own surface — no blur (the phase-08 + perf anchor). + The centered card reuses the #sources-gate visual language + (surface card + hairline + glyph + heading + sub + action) in a + tighter column. */ +.auth-gate { + position: fixed; + inset: 0; + z-index: 500; + display: flex; /* the card is the only in-flow child — margin: auto centers it */ + overflow-y: auto; /* short viewports: the card scrolls fully into view */ + background: var(--bg); +} +/* Explicit (the global [hidden] rule already wins — this is the + documented, testable contract for the skeleton). */ +.auth-gate[hidden] { display: none; } + +.auth-gate-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 0.4rem; + /* margin:auto centers AND stays scrollable on overflow (the flex + centering pitfall: align/justify center would clip the top of a + taller-than-viewport card). */ + margin: auto; + width: min(28rem, calc(100vw - 2rem)); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 2.5rem 1.75rem; +} +.auth-gate-glyph { color: var(--brand-ink); width: 44px; height: 44px; } +.auth-gate-glyph svg { width: 44px; height: 44px; display: block; } +.auth-gate h2 { margin: 0.6rem 0 0.3rem; font-size: 1.4rem; } +.auth-gate-sub { margin: 0; max-width: 24rem; color: var(--ink-soft); } + +/* The token form: mono input (the token is code — the house mono + language), a visible focus ring (WCAG — the 3px :focus-visible + outline at offset 0 + the brand border), and the house submit + button (the .tune-save language: dark ink on brand, 5.2:1). Both + targets are ≥44px (the touch-target contract). */ +.auth-gate form { + display: flex; + flex-direction: column; + gap: 0.6rem; + width: 100%; + margin-top: 0.9rem; +} +.auth-gate input { + font-family: var(--mono); + font-size: 0.95rem; + color: var(--ink); /* 16.7:1 on --bg */ + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0.65rem 0.8rem; + min-height: 44px; + width: 100%; +} +.auth-gate input::placeholder { color: var(--ink-soft); } +.auth-gate input:focus-visible { outline-offset: 0; border-color: var(--brand); } +.auth-gate-submit { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0.4rem 1.1rem; + border: 0; + border-radius: var(--radius-sm); + background: var(--brand); + color: var(--bg); /* dark ink on brand: 5.2:1 */ + font: inherit; + font-weight: 700; + cursor: pointer; +} +.auth-gate-submit:hover:not(:disabled) { background: #7d88f5; } +.auth-gate-submit:disabled { opacity: 0.6; cursor: wait; } + +/* The one-line error (role=alert) — the rose/danger family the + house alerts use (--err-ink on --err-bg, 9.3:1). */ +.auth-gate-error { + margin: 0.75rem 0 0; + background: var(--err-bg); + color: var(--err-ink); + border: 1px solid var(--err-line); + border-radius: var(--radius-sm); + padding: 0.45rem 0.8rem; +} + +/* The secondary "Sign in as admin" link reuses the .sources-gate-link + pill language (its own margin-top: 0.75rem applies) — the guest's + other door: the admin password login (the static ?next=/ is the + no-JS fallback). */ + .table-wrap { background: var(--surface); border: 1px solid var(--line); @@ -2179,6 +2220,49 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } box-shadow: var(--shadow); } +/* ---------- History refresh button (phase 77 task 03, TODO.md L3) ---------- + The #view-history page-head is a flex row: the title block left, the + actions slot right (the row wraps below 640px). Scoped to + #view-history — the other four views' page-heads are untouched. + The button reuses the .new-chat-btn visual language: solid brand + pill, --bg text on --brand (5.2:1, WCAG AA >=4.5:1), borderless, + >=44px target, hover lightens the brand fill, focus-visible via the + global 3px rule. The phase-46 auth-link convention: the label is + visible >=640px, the glyph is the whole control below (the button's + aria-label keeps the accessible name in both). */ +#view-history .page-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem 1.5rem; +} +#view-history .page-head .page-head-title { min-width: 0; } +.history-refresh { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-height: 44px; + padding: 0.5rem 0.9rem; + border-radius: 999px; + border: 0; + background: var(--brand); + color: var(--bg); + font: inherit; + font-weight: 700; + font-size: 0.95rem; + white-space: nowrap; + cursor: pointer; + flex-shrink: 0; +} +.history-refresh:hover { background: #f55a72; color: var(--bg); } +.history-refresh:disabled { opacity: 0.6; cursor: wait; } +/* The refresh glyph is hidden on desktop (the label carries the + pill); it is the whole control below 640px (the <=640 block + mirrors the phase-46 auth-link icon-only convention). */ +.history-refresh svg { width: 16px; height: 16px; display: none; } + /* ---------- History page (phase 50) ---------- /history.html: the admin-only saved-chats list (task 04). The FULL-WIDTH table in the 72rem frame (AGENTS.md rule 5 — no skinny @@ -2384,6 +2468,203 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } font-style: italic; } +/* ---------- Tokens view (phase 79 task 06) ---------- + The admin-issued access tokens (generate · list · revoke). The + shell's standard frame (the .container): the page-head (h1 + sub), + the #tokens-gate (the #history-gate / .sources-gate language), the + create row (label + Generate), the shown-once block (the mono + field + Copy), and the FULL-WIDTH table (AGENTS.md rule 5 — the + .history-table language: --line hairlines, the brand-soft-tinted + thead, row hover, the scrollable .table-wrap). Every pair reuses + the Phase-08 AA palette: brand-ink on brand-soft 6.9:1, ink-soft + >=5.1:1, err 9.3:1. :focus-visible via the global 3px outline + rule. No CDN, system fonts. */ +.tokens-shell { + display: flex; + flex-direction: column; + gap: 1.25rem; + flex: 1; +} +/* Action feedback line (role=status): the .history-status shape — + the min-height holds the layout so a line never reflows the table. */ +.tokens-status { + display: block; + min-height: 1.2em; + color: var(--ink-soft); + font-family: var(--mono); + font-size: 0.8rem; + padding-block: 0.25rem; +} +/* The create row: label input + Generate — flex, wraps below 640px + (the button drops under the full-width input). */ +.token-create { + display: flex; + align-items: center; + gap: 0.6rem; +} +#token-label { + flex: 1; + min-width: 0; + min-height: 44px; + padding: 0.35rem 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + font: inherit; + font-size: 0.93rem; +} +#token-label:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; } +/* Generate: the .history-refresh brand pill language — the solid + brand fill (--bg text on --brand 5.2:1, AA), the ≥44px target, the + lightened hover fill, the dimmed :disabled (the in-flight state), + the global :focus-visible ring. */ +.token-generate { + min-height: 44px; + padding: 0.4rem 1.1rem; + border: 0; + border-radius: 999px; + background: var(--brand); + color: var(--bg); + font: inherit; + font-weight: 600; + font-size: 0.9rem; + white-space: nowrap; + cursor: pointer; +} +.token-generate:hover:not(:disabled) { background: #f55a72; color: var(--bg); } +.token-generate:disabled { opacity: 0.6; cursor: wait; } +/* The shown-once block (owner-locked A4): a quiet brand-soft card + around the "shown once" line + the mono read-only field + Copy — + the token reads as the app's own credential surface (brand-ink on + brand-soft 12.4:1; ink on the field 13.8:1). */ +.token-once { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.9rem 1rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--brand-soft); +} +.token-once-copy { + margin: 0; + color: var(--brand-ink); + font-size: 0.85rem; + font-weight: 600; +} +.token-once-row { + display: flex; + align-items: center; + gap: 0.6rem; +} +/* The mono read-only field: the token is DATA — mono, surface fill, + --line border; it truncates with an ellipsis at narrow widths (the + full value is the text selection — the inline copy fallback). */ +#token-once-value { + flex: 1; + min-width: 0; + min-height: 44px; + padding: 0.35rem 0.55rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + font-family: var(--mono); + font-size: 0.78rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#token-once-value:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; } +/* Copy: the Tune/Retry-family ghost button (the .history-share-copy + language — --line border, transparent fill, ink-soft, ≥44px, the + brand hover pair). */ +#token-once-copy { + min-height: 44px; + padding: 0.35rem 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--ink-soft); + font: inherit; + font-weight: 600; + font-size: 0.82rem; + white-space: nowrap; + cursor: pointer; +} +#token-once-copy:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); } +/* The table is FULL-WIDTH (AGENTS.md rule 5): width 100% inside the + standard .container; the .table-wrap card + its horizontal scroll + cover narrow widths (the phase-07 responsive contract). The + .history-table language, verbatim. */ +.tokens-table { + width: 100%; + border-collapse: collapse; + min-width: 640px; + font-size: 0.93rem; +} +.tokens-table th, .tokens-table td { + text-align: left; + padding: 0.7rem 1rem; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} +.tokens-table th { + background: var(--brand-soft); + color: var(--brand-ink); + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.tokens-table tbody tr:hover { background: var(--bg); } +.tokens-table tbody tr:last-child td { border-bottom: 0; } +/* Label: the hand-out name (the column ellipsizes, the full label + sits in the title attribute — tokens.js). */ +.tokens-label-cell { + max-width: 20rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} +/* Created / Last used: locale date+time (ink-soft), the full ISO in + the title attribute; "never" for the not-yet-used stamp. */ +.tokens-date-cell { color: var(--ink-soft); white-space: nowrap; } +/* Status: the Active em-dash rides the cell's ink-soft (5.1:1 on + --surface); the Revoked marker reuses the .stale-pill rose family + (err-ink on err-bg ≈9.3:1, err-line border — the stale-pill visual + language). */ +.tokens-status-cell { color: var(--ink-soft); white-space: nowrap; } +/* Actions: the Revoke ghost button (the .history-delete language — + --line border, transparent fill, ink-soft, ≥44px target) + the + inline two-step confirm (the .history-confirm-* pair CSS above — + the phase-50 pattern). */ +.tokens-actions { display: inline-flex; align-items: center; gap: 0.4rem; } +.token-revoke { + min-height: 44px; + padding: 0.35rem 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--ink-soft); + font: inherit; + font-weight: 600; + font-size: 0.82rem; + white-space: nowrap; + cursor: pointer; +} +.token-revoke:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); } +.token-revoke:disabled { opacity: 0.5; cursor: wait; } +/* Empty-state row: the muted centered message at full table width + (the .history-empty-row language, inline in the table). */ +.tokens-empty-row td { + padding: 2.25rem 1rem; + text-align: center; + color: var(--ink-soft); + font-style: italic; +} + /* ---------- Shared page (phase 51, task 03) ---------- /shared/<token>: the anonymous read-only conversation (owner-locked 2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered @@ -3362,6 +3643,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } the two-step confirm pair fits the phone width. */ .history-actions-cell { white-space: normal; } .history-actions { flex-wrap: wrap; } + /* Phase 79 task 06: the Tokens create row stacks — the label input + takes the full width, the Generate pill drops under it (full + width); the once field's row stacks the same way; the actions + cell wraps so the two-step confirm pair fits the phone width + (the table's own horizontal scroll covers the columns). */ + .token-create { flex-direction: column; align-items: stretch; } + .token-generate { width: 100%; } + .token-once-row { flex-direction: column; align-items: stretch; } + #token-once-copy { width: 100%; } + .tokens-actions-cell { white-space: normal; } + .tokens-actions { flex-wrap: wrap; } /* Phase 51: the shared page squeezes like the chat column — the title and the note step down (the empty-state-title family); the shell keeps its base 46rem column (the >=1500px 92rem override @@ -3376,6 +3668,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } state on a touch screen. */ .sync-btn { padding: 0.4rem 0.3rem; } .sync-label { display: none; } + /* Phase 77 task 03: the History refresh pill goes icon-only like + the phase-46 auth pill (the aria-label keeps the accessible + name); the page-head row above wraps the pill below the title + block when the width runs out. */ + .history-refresh { padding: 0.4rem 0.3rem; } + .history-refresh-label { display: none; } + .history-refresh svg { display: block; } .sync-result { position: absolute !important; width: 1px; height: 1px; diff --git a/frontend/assets/token-gate.js b/frontend/assets/token-gate.js new file mode 100644 index 0000000..8de5944 --- /dev/null +++ b/frontend/assets/token-gate.js @@ -0,0 +1,252 @@ +/* Brain of Reese — the in-app token gate (phase 79, task 05). + * + * The owner's sentence (TODO.md L5): "The web ui should ask for a + * token before letting a user through and should cache that token in + * browser storage so they don't have to keep entering it." + * + * One reusable mount point for the two gated surfaces: + * + * • the shell (index.html) — app.js's boot IIFE awaits + * mountGate(#main, onAuthed) BEFORE its initSharedHeader; + * • the document viewer (document.html) — document.js wires + * mountGate(#main, onAuthed) around the existing boot sequence + * (whoami → load content). + * + * Both carry the same gate markup (class .auth-gate; the ids differ — + * #auth-gate / #doc-auth-gate — so the gate is found by CLASS: the + * ONE .auth-gate section on the page). + * + * The contract (pinned at source level in + * tests/unit/test_token_gate.py): + * + * • the gate SHIPS hidden + inert (the phase-16 ship-hidden pattern): + * an authenticated boot never shows it for a frame. + * • (1) at mount, a CACHED token (localStorage["bor.token"]) is + * re-sent to POST /api/token-auth SILENTLY — BEFORE the whoami + * check. On ANY failure the key is removed (the token may have + * been revoked) and the mount falls through to the role check. + * • (2) the role check is header.js's fetchWhoami() — the SAME + * cached promise the page's own header boot uses (exactly one + * /api/whoami per page load in dev): role "user" or "admin" → + * onAuthed() (the gate NEVER shows); "anonymous" → the gate is + * revealed (hidden AND inert dropped), the lock root is locked + * (lockRoot.inert = true — WCAG: the locked app must not receive + * focus or keyboard traversal, the inert-pair contract) and the + * token input is focused. + * • form submit → POST /api/token-auth: 204 → the token is cached + * (localStorage, try/catch — private mode: caching is a no-op, + * the gate still works), the whoami cache is invalidated and + * re-fetched (the choice below), role "user" → the gate is hidden + * again (hidden + inert re-added), the lock root unlocks, + * onAuthed() runs, and the shared header re-boots + * (initSharedHeader — a mid-page login must not leave the + * anonymous header state: Sign in hidden, Sign out visible). + * 401 (or any failure) → the #auth-gate-error (role=alert) line + * is revealed, the input cleared and re-focused — the entered + * token never lingers in the field. + * • the whoami-cache choice: CLEAR THE MODULE CACHE (header.js's + * resetWhoami) rather than a direct re-fetch — a direct fetch + * would verify the role for the gate but leave header.js's cache + * stale (the boot fired it pre-auth, when the page was still + * anonymous), so the header re-boot and every later fetchWhoami() + * would still read anonymous. Resetting makes the NEXT + * fetchWhoami() the single fresh post-auth request for the whole + * page (gate + header + view gates all reuse it). + * • ALL localStorage access is try/catch (the fail-silence storage + * contract — private mode or a storage error degrades to + * "re-enter the token each visit", never to a broken gate). + * + * The module has NO import-time side effects: pages call mountGate at + * boot (the shell from app.js's boot IIFE, the viewer from + * document.js). On the shell the call is AWAITED before app.js's + * initSharedHeader, so a silent re-auth lands before the FIRST whoami + * fires — the header sees the post-auth role deterministically (no + * stale "Sign in" for a returning token user). No CDN, no framework. + */ + +import { fetchWhoami, initSharedHeader, resetWhoami } from "./header.js"; + +/* The cached-token key (the owner's sentence: "cache that token in + browser storage"). The server stores only the SHA-256 digest of the + full token (app/core/tokens.py) — the plaintext lives here, in the + browser, and is re-sent verbatim to /api/token-auth (the server + hashes it for the unique-index lookup). */ +const TOKEN_KEY = "bor.token"; + +/* ---------- the fail-silence storage contract ---------- + * Every localStorage access is try/catch: private mode or a storage + * error degrades to "re-enter the token each visit" — the gate itself + * still works either way. */ + +const readCachedToken = () => { + try { + const t = localStorage.getItem(TOKEN_KEY); + return typeof t === "string" && t.trim() !== "" ? t : null; + } catch { + return null; // private mode / storage error — nothing cached + } +}; + +const storeToken = (token) => { + try { + localStorage.setItem(TOKEN_KEY, token); + } catch { + /* private mode: the gate still works, caching is a no-op */ + } +}; + +const removeToken = () => { + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + /* nothing was stored */ + } +}; + +/* POST /api/token-auth — every failure shape (malformed / unknown / + revoked / empty) is ONE generic 401 "invalid token" server-side + (the phase-16 no-enumeration contract), so the client only needs + the status: 204 = the session cookie is (re)set. */ +async function tryTokenAuth(token) { + const r = await fetch("/api/token-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + return r.status === 204; +} + +/* The in-app token gate. `lockRoot` is the element the gate locks + while visible (#main on both pages — the locked app must not + receive focus or keyboard traversal: the inert-pair contract); + `onAuthed` runs ONLY once the visitor is signed in (role "user" or + "admin") — the shell passes a no-op (the lazy views mount on first + show exactly as today), the viewer passes its existing boot + sequence (the shared header + the content load). */ +export async function mountGate(lockRoot, onAuthed) { + const gate = document.querySelector(".auth-gate"); + const form = gate ? gate.querySelector("form") : null; + const input = gate ? gate.querySelector('input[name="token"]') : null; + const error = gate ? gate.querySelector(".auth-gate-error") : null; + const submit = form ? form.querySelector('button[type="submit"]') : null; + + const hideGate = () => { + if (gate) { + gate.hidden = true; + gate.inert = true; + } + }; + + const showError = () => { + if (error) error.hidden = false; + if (input) { + input.value = ""; // the entered token never lingers in the field + input.focus(); + } + }; + + /* (1) SILENT RE-AUTH — before the whoami check. A cached token + is re-sent to /api/token-auth first: on success the session + cookie is (re)set NOW, before any whoami on this page settles — + the cache is invalidated (resetWhoami) so the role check below + is a FRESH request (a boot-fired pre-auth whoami would still + say anonymous). On ANY failure the key is DROPPED — the token + may have been revoked — and the mount falls through to the role + check (the page may already be signed in as the admin, and the + anonymous visitor simply gets the gate). */ + const cached = readCachedToken(); + if (cached) { + const ok = await tryTokenAuth(cached).catch(() => false); + if (ok) { + resetWhoami(); // the cached whoami (boot-fired, pre-auth) is stale + const who = await fetchWhoami(); + if (who.authenticated) { + // role "user" (the re-auth landed) or "admin" (the browser + // also holds an admin session — admin wins, whoami's + // contract): unlock and run onAuthed — the gate NEVER shows + // for a cached valid token (no flash). + if (lockRoot) lockRoot.inert = false; + hideGate(); // defensive: it ships hidden — the boot never + // reveals it for an authenticated role, so this is a no-op. + onAuthed(); + return; + } + // token-auth said 204 but whoami still says no (the cookie did + // not land): treat it as a dead cached token. + removeToken(); + } else { + removeToken(); // a failed silent re-auth drops the key + } + } + + /* (2) ROLE CHECK — header.js's fetchWhoami(): the SAME cached + promise the page's own header boot awaits (app.js on the shell, + the viewer's onAuthed on document.html) — exactly one + /api/whoami per page load. */ + const who = await fetchWhoami(); + if (who.authenticated) { + // role "user" or "admin" — the gate NEVER shows. + if (lockRoot) lockRoot.inert = false; + onAuthed(); + return; + } + + /* Anonymous: reveal the gate (drop hidden AND inert — the inert-pair + contract), lock the app, focus the token input. The gate is the + only interactive surface while visible: the lock root is inert, + so Tab never reaches the locked app (the composer on the shell, + the content on the viewer). */ + if (gate) { + gate.hidden = false; + gate.inert = false; + } + if (lockRoot) lockRoot.inert = true; + if (input) input.focus(); + if (!form) return; // no gate markup — nothing to bind + + /* Form submit: preventDefault → POST /api/token-auth → 204: cache + the token, invalidate + re-fetch the whoami cache (the documented + choice: clear the module cache — a direct re-fetch would leave + header.js's boot-fired anonymous cache stale for the header + re-boot), role "user" → hide the gate, unlock, onAuthed(), and + re-boot the shared header for the new role. 401 / failure: the + role=alert error line, the input cleared + re-focused, the + button re-enabled (never stale, PLAN §7.4). */ + form.addEventListener("submit", (e) => { + e.preventDefault(); + void (async () => { + const token = (input && input.value ? input.value : "").trim(); + if (!token || (submit && submit.disabled)) return; + if (error) error.hidden = true; + if (submit) submit.disabled = true; // one auth at a time + const ok = await tryTokenAuth(token).catch(() => false); + if (!ok) { + showError(); + if (submit) submit.disabled = false; + return; + } + storeToken(token); // try/catch — private mode: caching is a no-op + resetWhoami(); // the boot-fired whoami says anonymous — stale + const after = await fetchWhoami(); // the single fresh post-auth request + if (!after.authenticated) { + // token-auth said 204 but whoami still says anonymous (the + // session cookie did not land): drop the cache just written + // and let the user retry. (role "admin" here — an admin + // signed in from another tab in the meantime — is + // authenticated: the gate's job is to let them through.) + removeToken(); + showError(); + if (submit) submit.disabled = false; + return; + } + hideGate(); + if (lockRoot) lockRoot.inert = false; + onAuthed(); + // A mid-page login must not leave the anonymous header state + // (Sign in visible / Sign out hidden): re-boot the shared + // header on the fresh post-auth whoami (idempotent — it just + // re-toggles the same controls). + void initSharedHeader(); + })(); + }); +} diff --git a/frontend/assets/tokens.js b/frontend/assets/tokens.js new file mode 100644 index 0000000..ff19672 --- /dev/null +++ b/frontend/assets/tokens.js @@ -0,0 +1,416 @@ +/* Brain of Reese — Tokens view (access tokens, phase 79 task 06; + * the phase-76 fold pattern: shell view module). + * + * TODO.md L5 (owner 2026-09-06): "…api tokens that the admin can + * generate and hand out so people can log in to use the app." + * + * Wires the admin-only token endpoints (phase 79 task 02) into the + * view: + * + * • the create row (label + Generate): a BLANK label sends "token" + * (the placeholder documents the fallback; the API's 1–120 + * validator is satisfied). POST /api/tokens → 201 — the ONE + * response that carries the plaintext (owner-locked A4) — and the + * plaintext appears EXACTLY ONCE: in the #token-once block's mono + * read-only field, with a Copy (the clipboard; a non-secure http + * origin that rejects it gets the inline fallback — the field + * selects itself for Ctrl/Cmd+C). The block hides on the NEXT + * loadTokens() / re-show (and the field is wiped with it) — the + * plaintext is NOT stored anywhere client-side (no localStorage, + * no data attribute), so a re-render can never re-show it; + * • the full-width table (AGENTS.md rule 5): Label (textContent — + * admin-derived, still text) | Created (locale date+time, the full + * ISO in the title) | Last used (locale or "never") | Status — a + * plain em-dash for Active, the rose .stale-pill for Revoked (the + * stale-pill visual language; the cell carries its aria-label in + * BOTH states — WCAG 2.1 AA) | Actions — Revoke, the inline + * TWO-STEP confirm (the history-confirm-* pattern — NO native + * confirm dialog: the first click swaps to "Revoke? [Yes] [No]", + * focus moves to Yes; Yes POSTs /api/tokens/<id>/revoke and the + * row re-renders Revoked + the live region line; No or a failed + * request restores the Revoke button — retryable). Revoked rows + * carry NO action (nothing left to revoke). + * + * Every cell is built with the DOM APIs (textContent) — this file + * never builds HTML (the XSS-safe-by-construction house rule; a full- + * file source pin enforces it). + * + * The whoami gate (phase 19 shared-header module, cached promise): + * • anonymous → the #tokens-gate is shown, the create row + table + * hide, and NO /api/tokens request is made at all (the router + * 403s anonymous — the same request-log contract as the history + * view); + * • admin → the gate hides, the create row + table reveal, and + * `loadTokens()` renders the rows; a 0-row fetch (and a failed + * load) reveals the empty-state row. + * + * Phase 76 (task 06) — shell view module: the top-level boot is + * `export async function mount(root)` — root is the view's + * `<section id="view-tokens">`, and every DOM lookup scopes to root + * (the view ids stay unique across the shell). The router mounts a + * view ONCE (mount-once, hide-forever), so the bindings + state + * survive every switch. The initSharedHeader() call is DROPPED: in + * the shell the shared header boots exactly once, via the chat module + * (app.js) at shell boot. The admin gate keeps fetchIsAdmin() — the + * SAME cached /api/whoami promise header.js exports (zero extra + * requests). + * + * Phase 77 (the re-show refresh contract): the shell router dispatches + * `bor:view-refresh` on the view's section when the user RE-SHOWS an + * already-mounted view (a switch back onto it, a re-click of the + * Tokens nav link, or back/forward) — the first show (the mount) and + * boot never (the mount's own load is the first fetch). This module + * listens on root and re-runs `loadTokens()`, which is re-entrant: a + * re-load drops the data rows (the hidden #tokens-empty-row stays in + * the tbody) before fetching, so the list is REPLACED — never + * duplicated — and it ALSO re-hides the once-block if one was up + * (the plaintext is gone). The listener is armed only in the ADMIN + * branch, after the whoami gate passes: anonymous shows the gate and + * never fetches. + * + * The clipboard + inline-fallback helper is tokens.js's OWN ~10-line + * copy (the per-page duplication house style — history.js keeps the + * share link's, app.js the chat page's; no new shared module). + */ + +import { fetchIsAdmin } from "./header.js"; + +export async function mount(root) { + /* ---------- view elements (the view's section, scoped to root) ---------- */ + const tableWrap = root.querySelector("#tokens-table-wrap"); + const tbody = root.querySelector("#tokens-tbody"); + const emptyRow = root.querySelector("#tokens-empty-row"); + const gateEl = root.querySelector("#tokens-gate"); + const statusEl = root.querySelector("#tokens-status"); + // The create row (label + Generate) — SHIPS hidden (anonymous-safe; + // the admin branch reveals it). + const createRow = root.querySelector("#token-create"); + const labelInput = root.querySelector("#token-label"); + const generateBtn = root.querySelector("#token-generate"); + // The shown-once block — the plaintext lives in the read-only field's + // VALUE only (never a data attribute, never localStorage). + const onceBlock = root.querySelector("#token-once"); + const onceValue = root.querySelector("#token-once-value"); + const onceCopy = root.querySelector("#token-once-copy"); + + /* Action feedback — the role="status" live region (the "never stale" + contract: every action lands a line here, success or failure + alike). */ + function announce(message) { + if (statusEl) statusEl.textContent = message; + } + + function fmtDate(iso) { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } + } + + /* Clipboard + the inline fallback (tokens.js's OWN copy — the + per-page duplication house style): a non-secure (http) homelab + origin rejects navigator.clipboard, so the failure path FOCUSES + + SELECTS the visible once field — the token is right there, ready + for Ctrl/Cmd+C (the field is the fallback surface; unlike the + share-link case there is nothing to render — the plaintext is + already on screen). Returns true when the clipboard took it. */ + async function copyTokenToClipboard() { + const token = onceValue ? onceValue.value : ""; + if (!token) return true; + try { + await navigator.clipboard.writeText(token); + return true; + } catch { + if (onceValue) { + onceValue.focus({ preventScroll: true }); + onceValue.select(); + } + return false; + } + } + + /* Copy (the once block's button): clipboard → the inline fallback. + The live region lands the outcome either way. */ + async function copyTokenAction() { + const copied = await copyTokenToClipboard(); + announce( + copied + ? "Token copied." + : "The token is selected in the field — press Ctrl/Cmd+C to copy.", + ); + } + + /* One row. The Status cell carries the cell-level aria-label in BOTH + states (Active em-dash / the rose Revoked pill — the stale-pill + visual language) so the marker is conveyed without the visual + (WCAG 2.1 AA). The Actions cell carries the two-step Revoke for + active rows only — revoked rows have nothing left to revoke. */ + function makeRow(tok) { + const tr = document.createElement("tr"); + + const labelTd = document.createElement("td"); + labelTd.className = "tokens-label-cell"; + labelTd.title = tok.label; // full label on hover (the column ellipsizes) + labelTd.textContent = tok.label; // admin-derived — textContent only + tr.appendChild(labelTd); + + const createdTd = document.createElement("td"); + createdTd.className = "tokens-date-cell"; + createdTd.title = tok.created_at; // full ISO on hover + createdTd.textContent = fmtDate(tok.created_at); + tr.appendChild(createdTd); + + const usedTd = document.createElement("td"); + usedTd.className = "tokens-date-cell"; + if (tok.last_used_at) { + usedTd.title = tok.last_used_at; // full ISO on hover + usedTd.textContent = fmtDate(tok.last_used_at); + } else { + usedTd.textContent = "never"; // not used yet (the token-auth stamp) + } + tr.appendChild(usedTd); + + const statusTd = document.createElement("td"); + statusTd.className = "tokens-status-cell"; + if (tok.revoked) { + statusTd.setAttribute( + "aria-label", + "Revoked — this token is dead and can no longer sign in", + ); + const pill = document.createElement("span"); + pill.className = "stale-pill"; + pill.title = "Revoked — the token can no longer sign in"; + pill.textContent = "Revoked"; + statusTd.appendChild(pill); + } else { + statusTd.setAttribute( + "aria-label", + "Active — this token can still be used to sign in", + ); + statusTd.textContent = "—"; // the em-dash: active rows' marker + } + tr.appendChild(statusTd); + + const actionsTd = document.createElement("td"); + actionsTd.className = "tokens-actions-cell"; + if (!tok.revoked) { + actionsTd.appendChild(makeRevokeControl(tok, tr)); + } + tr.appendChild(actionsTd); + return tr; + } + + /* The inline two-step Revoke (the history-confirm-* pattern — NO + native confirm dialog anywhere in this file). The Revoke button is + replaced, in place, by the "Revoke? [Yes] [No]" pair; focus moves + to Yes (keyboard-reachable confirm). Yes → POST + /api/tokens/<id>/revoke → the row re-renders Revoked (+ the live + region line); No or a failed request restores the Revoke button + (retryable). */ + function makeRevokeControl(tok, row) { + const cell = document.createElement("span"); + cell.className = "tokens-actions"; + + const revokeBtn = document.createElement("button"); + revokeBtn.type = "button"; + revokeBtn.className = "token-revoke"; + revokeBtn.setAttribute("aria-label", `Revoke token: ${tok.label}`); + revokeBtn.textContent = "Revoke"; + + function restoreRevoke() { + cell.replaceChildren(revokeBtn); + revokeBtn.focus(); // focus returns to the (restored) control + } + + revokeBtn.addEventListener("click", () => { + const label = document.createElement("span"); + label.className = "history-confirm-text"; + label.textContent = "Revoke?"; + const yes = document.createElement("button"); + yes.type = "button"; + yes.className = "history-confirm-yes"; + yes.textContent = "Yes"; + const no = document.createElement("button"); + no.type = "button"; + no.className = "history-confirm-no"; + no.textContent = "No"; + yes.addEventListener("click", () => + confirmRevoke(tok, row, yes, restoreRevoke)); + no.addEventListener("click", restoreRevoke); + cell.replaceChildren(label, yes, no); + yes.focus(); // the confirm pair takes over the focus + }); + + cell.appendChild(revokeBtn); // the shipped state IS the Revoke button + return cell; + } + + /* The confirmed revoke: POST /api/tokens/<id>/revoke (204 — + idempotent server-side) → the row STAYS (it is not removed) and + re-renders Revoked, and the live region gets `Revoked "<label>".` + A 404 means the row is gone (revoked elsewhere) — re-render it + Revoked and say so. Any other failure or a network error keeps the + row, restores the Revoke button (retryable), and lands the error + line. */ + async function confirmRevoke(tok, row, yesBtn, restoreRevoke) { + yesBtn.disabled = true; // no double-fire while the request is in flight + let r; + try { + r = await fetch(`/api/tokens/${tok.id}/revoke`, { method: "POST" }); + } catch { + announce(`Couldn't revoke "${tok.label}" — is the app reachable?`); + restoreRevoke(); + return; + } + if (r.status === 404) { + row.replaceWith(makeRow({ ...tok, revoked: true })); + announce("That token was already revoked."); + return; + } + if (!r.ok) { + announce(`Couldn't revoke "${tok.label}" — try again.`); + restoreRevoke(); + return; + } + row.replaceWith(makeRow({ ...tok, revoked: true })); + announce(`Revoked "${tok.label}".`); + } + + /* The empty-state row reappears exactly when there is nothing else + in the tbody (the empty row itself ships in the tbody, hidden). */ + function showEmptyState() { + if (!tbody) return; + tbody.replaceChildren(emptyRow); + if (emptyRow) emptyRow.hidden = false; + } + + /* GET /api/tokens → render the rows (newest-first — the server's + order). A 0-row fetch shows the empty-state row. Re-entrant (the + phase-77 re-show contract): a re-show re-run must REPLACE the + list, not append a duplicate set — the data rows (every <tr> + EXCEPT the hidden #tokens-empty-row, which the load itself + re-hides / reveals) are dropped before the fetch — and the + once-block HIDDEN + its field wiped (the plaintext is gone: a + re-render can never re-show it). + A FAILED load announces its line in the live region (the house + copy: "is the app reachable?" / "try again.") and RETURNS the + outcome: true when the fetch settled (a 0-row fetch is a + SUCCESS — the empty state is the honest view), false on non-2xx / + network error. */ + async function loadTokens() { + if (onceBlock) onceBlock.hidden = true; + if (onceValue) onceValue.value = ""; + if (tbody) { + for (const tr of tbody.querySelectorAll("tr")) { + if (tr !== emptyRow) tr.remove(); + } + } + if (emptyRow) emptyRow.hidden = true; + let r; + try { + r = await fetch("/api/tokens"); + } catch { + announce("Couldn't load tokens — is the app reachable?"); + showEmptyState(); + return false; + } + if (!r.ok) { + announce("Couldn't load tokens — try again."); + showEmptyState(); + return false; + } + const { tokens } = await r.json(); + if (!tokens.length) { + showEmptyState(); + return true; + } + for (const tok of tokens) { + tbody.appendChild(makeRow(tok)); + } + return true; + } + + /* Generate: the label comes from #token-label — a BLANK label sends + "token" (the placeholder documents the fallback; the API's 1–120 + validator is satisfied). The button runs the §7.4 never-stale + lifecycle: "Generating…" while the POST is in flight, re-enabled + on success AND failure (the finally — a click can never leave it + stuck disabled). On 201 the re-entrant list load runs FIRST (it + hides the once-block — the re-render contract) and THEN the + once-block reveals with the plaintext (the 201 body's token is + the ONE plaintext that exists, A4 — it lives in this closure + until the next loadTokens() hides the block again), the live + region gets the shown-once line, and the label input clears (a + new token is a new hand-out). A failed create keeps the label + (retryable) and lands the error line. */ + async function generateToken() { + const label = (labelInput ? labelInput.value : "").trim() || "token"; + if (generateBtn) generateBtn.disabled = true; + if (generateBtn) generateBtn.textContent = "Generating…"; + let created = null; + try { + const r = await fetch("/api/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }); + if (!r.ok) { + announce("Couldn't create the token — try again."); + return; + } + created = await r.json(); + } catch { + announce("Couldn't create the token — is the app reachable?"); + return; + } finally { + if (generateBtn) generateBtn.disabled = false; + if (generateBtn) generateBtn.textContent = "Generate"; + } + /* The re-render load FIRST (the once-block's hider), then the + reveal: the plaintext lands in the field's VALUE only. */ + await loadTokens(); + if (onceValue) onceValue.value = created.token; + if (onceBlock) onceBlock.hidden = false; + if (labelInput) labelInput.value = ""; + announce("Token created — copy it now; it won't be shown again."); + } + + /* ---------- view boot (phase 79 task 06) ---------- + * The shared header is NOT booted here — in the shell it runs + * exactly once, via the chat module (app.js) at shell boot. The + * whoami gate reads fetchIsAdmin() — the SAME cached whoami promise + * the header uses (zero extra requests). Anonymous: the gate in, + * the create row + table out — and NO /api/tokens request at all + * (the router 403s anonymous, so the view must never call it). */ + if (!(await fetchIsAdmin())) { + if (gateEl) gateEl.hidden = false; + if (createRow) createRow.hidden = true; + if (tableWrap) tableWrap.hidden = true; + return; + } + if (gateEl) gateEl.hidden = true; + if (createRow) createRow.hidden = false; + if (tableWrap) tableWrap.hidden = false; + if (onceBlock) onceBlock.hidden = true; // ships hidden; only a 201 reveals it + /* Phase 77: a user-initiated re-show of this already-mounted view + makes the router dispatch bor:view-refresh on the section — + re-load then (loadTokens is re-entrant: the list is replaced AND + the once-block re-hidden — the plaintext is gone). The listener + is armed ONLY here, after the whoami gate passed: anonymous shows + the gate and must never fetch. `started` flips true once the + first loadTokens() is made (below), so the listener can only ever + re-run a load the mount already did. */ + let started = false; + root.addEventListener("bor:view-refresh", () => { + if (started) loadTokens(); + }); + if (generateBtn) { + generateBtn.addEventListener("click", () => void generateToken()); + } + if (onceCopy) { + onceCopy.addEventListener("click", () => void copyTokenAction()); + } + started = true; + loadTokens(); +} diff --git a/frontend/assets/tuning.js b/frontend/assets/tuning.js index 7a10030..0399039 100644 --- a/frontend/assets/tuning.js +++ b/frontend/assets/tuning.js @@ -44,6 +44,19 @@ * exports (zero extra requests; the flag decides whether the note * list loads at all, the Sources-page gate pattern). * + * Phase 77 (task 02) — the re-show refresh: the shell router + * dispatches `bor:view-refresh` on the view's section when the user + * RE-SHOWS an already-mounted view (a switch back onto it, a re-click + * of the Tuning nav link, or back/forward) — the first show (mount) + * and boot never (the mount's own load is the first fetch). This + * module listens on root and re-runs `loadNotes()` (renderNotes + * already clears the list, so a re-call replaces it). A FAILED + * refresh keeps the last rendered list — loadNotes's documented + * contract (progressive enhancement, never a blanked panel). The + * listener is armed only in the ADMIN branch, after the whoami gate + * passes: anonymous gets the empty-state view and never fetches + * (the phase-27 gate). + * * Anonymous-safe (phase 27 task 03, unchanged in the shell): the * header hides the "Tuning" nav link for anonymous visitors; a DIRECT * anonymous URL still gets a safe view — loadNotes() only runs when @@ -348,5 +361,15 @@ export async function mount(root) { * (zero extra requests). An anonymous visitor gets the view frame * with the empty state, and the create form 403s gracefully on * submit if one tries. */ - if (await fetchIsAdmin()) loadNotes(); // phase 27: the list is admin-only + /* Phase 77 (task 02): a user-initiated re-show of this already- + mounted view makes the router dispatch bor:view-refresh on the + section — re-run loadNotes then (renderNotes clears the list + first, so a re-call replaces it; a failed refresh keeps the last + rendered list — loadNotes's documented contract). Armed ONLY + here, after the whoami gate passed: anonymous never fetches + (the phase-27 gate). */ + if (await fetchIsAdmin()) { + root.addEventListener("bor:view-refresh", () => loadNotes()); + loadNotes(); // phase 27: the list is admin-only + } } diff --git a/frontend/document.html b/frontend/document.html index 6dd7fc0..ebd0445 100644 --- a/frontend/document.html +++ b/frontend/document.html @@ -58,6 +58,13 @@ reveals it once whoami says admin, exactly like the Tuning link above. --> <a href="/history.html" class="nav-link" id="nav-history" hidden>History</a> + <!-- Phase 79 (task 06): the Tokens link is admin-only — + hidden by default, header.js reveals it once whoami says + admin, exactly like the History link above (the + phase-34 one-bar contract: the SAME nav ships on every + page — test_nav_consistency pins the inventory parity). + Null-safe: header.js is a no-op on a page without it. --> + <a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a> <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is outside the nav; see styles.css .sign-in-mobile rules). --> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> @@ -145,6 +152,48 @@ </div> </main> + <!-- Phase 79 (task 05): the viewer's inline token gate — the SAME + gate markup as the shell (frontend/index.html #auth-gate), the + ids renamed (#doc-auth-gate …). The content endpoint is now + require_user-gated (phase 79 task 03): a direct anonymous URL + shows THIS gate instead of a content error (the page document + loads; the GATED DATA does not). assets/token-gate.js (loaded + through document.js's import) mounts it with #main as the lock + root; the existing boot sequence (whoami → load content) runs + as onAuthed — an admin (or a validly cached token user) gets + it immediately, the gate never shows. SHIPS hidden + inert + (the phase-16 ship-hidden pattern). --> + <section class="auth-gate" id="doc-auth-gate" hidden inert aria-labelledby="doc-auth-gate-title"> + <div class="auth-gate-card"> + <div class="auth-gate-glyph" aria-hidden="true"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg> + </div> + <h2 id="doc-auth-gate-title">Enter your access token</h2> + <p class="auth-gate-sub"> + Ask the admin for a token — it opens chat, the answers, and the + documents they cite. Shared chats stay open. + </p> + <form id="doc-auth-gate-form"> + <label class="visually-hidden" for="doc-auth-gate-input">Access token</label> + <input + id="doc-auth-gate-input" + name="token" + type="text" + autocomplete="off" + autocapitalize="none" + spellcheck="false" + placeholder="bor_…" + required + > + <button type="submit" class="auth-gate-submit">Sign in</button> + </form> + <p class="auth-gate-error" id="doc-auth-gate-error" role="alert" hidden> + That token isn’t valid — check it with the admin. + </p> + <a class="sources-gate-link" href="/login.html?next=/">Sign in as admin</a> + </div> + </section> + <footer class="app-footer"> <div class="container footer-inner"> <span class="footer-text">Powered by self-hosted models</span> diff --git a/frontend/index.html b/frontend/index.html index 45dac43..0bd7feb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -48,6 +48,12 @@ reveals it once whoami says admin, exactly like the Tuning link above. --> <a href="/history.html" class="nav-link" id="nav-history" hidden>History</a> + <!-- Phase 79 (task 06): the Tokens link is admin-only — hidden + by default, header.js reveals it once whoami says admin, + exactly like the History link above (the shell's sixth + view). No mobile dropdown copy is needed: the link lives + in the SAME #app-nav element the hamburger opens. --> + <a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a> <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is outside the nav; see styles.css .sign-in-mobile rules). --> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> @@ -668,11 +674,33 @@ only (mount-once, hide-forever). --> <section class="view" id="view-history" hidden inert aria-label="History" tabindex="-1"> <div class="container history-shell"> + <!-- Phase 77 (task 03, TODO.md L3): the page-head becomes a + flex row — the title block left, the actions slot right + (wraps below 640px). The other four views' page-heads are + untouched (the flex layout is scoped to #view-history in + styles.css). --> <div class="page-head"> - <h1>Saved chats</h1> - <p class="page-sub"> - Every conversation is saved automatically — newest activity first. Click a title to return to that chat. - </p> + <div class="page-head-title"> + <h1>Saved chats</h1> + <p class="page-sub"> + Every conversation is saved automatically — newest activity first. Click a title to return to that chat. + </p> + </div> + <!-- The explicit Refresh control (TODO.md L3: "The history page + should also have a refresh button."). It sits in the + page-head — OUTSIDE the table wrap — so it stays + reachable while the empty state is showing. The house + inline-SVG refresh glyph (aria-hidden) + the visible + "Refresh" label (the phase-46 auth-link convention: + label visible >=640px, icon-only below; the aria-label + keeps the accessible name in both). history.js binds it + in the admin branch (the view is admin-gated; anonymous + sees the gate instead) and announces the outcome in + #history-status. --> + <button type="button" class="history-refresh" id="history-refresh" aria-label="Refresh saved chats"> + <svg class="history-refresh-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg> + <span class="history-refresh-label">Refresh</span> + </button> </div> <!-- Phase 50 (owner permission 2026-08-29): anonymous sign-in gate — the EXACT #sources-gate pattern (phase 16) and the @@ -734,8 +762,181 @@ </div> </section> + <!-- Phase 79 (task 06): the Tokens view — the sixth folded navbar + view (the phase-76 fold pattern): admin-issued access tokens + (generate · list · revoke). /tokens.html now serves THIS + document (the shell route in app/main.py); the router shows + this section for that pathname. The plaintext is shown + EXACTLY ONCE (owner-locked A4): the #token-once block reveals + it in the mono read-only field after a create, and it hides + itself on the next loadTokens() / re-show — the plaintext is + never stored client-side (no localStorage, no data attribute), + so a re-render can never re-show it. The create row + table + SHIP hidden (anonymous-safe — the gate is what anonymous sees; + tokens.js reveals them for admin only, and an anonymous + visitor never fetches /api/tokens). The hidden + inert pair is + the WCAG contract: a hidden view must not receive focus or + keyboard traversal (AGENTS.md rule 5). Mounted lazily — + assets/router.js imports tokens.js on first show only + (mount-once, hide-forever). --> + <section class="view" id="view-tokens" hidden inert aria-label="Tokens" tabindex="-1"> + <div class="container tokens-shell"> + <div class="page-head"> + <h1>Access tokens</h1> + <p class="page-sub"> + Generate a token and hand it out — it opens chat, the answers, + and the documents they cite. Shared chats stay open. + </p> + </div> + <!-- Phase 79 (task 06): anonymous sign-in gate — the EXACT + #history-gate pattern (phase 50) and the same .sources-gate + visual language: the token list is what the login locks. + Visible for anonymous, hidden for the admin (tokens.js). + The gate's Sign in returns to the Tokens view (the + header's ?next= convention; the static href is the no-JS + fallback). --> + <section class="sources-gate" id="tokens-gate" aria-labelledby="tokens-gate-title" hidden> + <div class="sources-gate-glyph" aria-hidden="true"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg> + </div> + <h2 id="tokens-gate-title">Sign in to manage access tokens</h2> + <p class="sources-gate-sub"> + Issuing and revoking access tokens is admin-only. Chat — and + any document an answer cites — stays open to everyone. + </p> + <a class="sources-gate-link" href="/login.html?next=/tokens.html">Sign in</a> + </section> + + <!-- Live-region feedback for every action (the "never stale" + contract): tokens.js sets textContent here — a create's + outcome, a copy's outcome, a revoke's outcome, its error + lines, nothing else. --> + <span class="tokens-status" id="tokens-status" role="status" aria-live="polite"></span> + + <!-- The create row: label (1–120 — the server re-validates; a + BLANK label sends "token", the placeholder documents the + fallback) + Generate. SHIPS hidden (anonymous-safe — no + dead control beside the gate; tokens.js reveals it for + admin only). The button is type="button" (no form submit) + and runs the §7.4 never-stale lifecycle ("Generating…" + while the POST is in flight, re-enabled on success AND + failure). --> + <div class="token-create" id="token-create" hidden> + <input + id="token-label" + type="text" + maxlength="120" + placeholder="e.g. alice" + aria-label="Token label" + autocomplete="off" + > + <button type="button" class="token-generate" id="token-generate">Generate</button> + </div> + + <!-- The shown-once block (owner-locked A4): after a 201, the + plaintext appears EXACTLY ONCE — in the mono read-only + field, with a Copy (the clipboard; a non-secure http + origin that rejects it gets the inline fallback — the + field selects itself for Ctrl/Cmd+C). tokens.js hides it + on the next loadTokens() / re-show and wipes the field — + it is NEVER re-shown (the plaintext is gone). --> + <div class="token-once" id="token-once" hidden> + <p class="token-once-copy" role="note"> + Copy this token now — it is shown once and can never be + retrieved again. + </p> + <div class="token-once-row"> + <input + id="token-once-value" + type="text" + readonly + aria-label="New token (shown once)" + autocomplete="off" + autocapitalize="none" + spellcheck="false" + > + <button type="button" id="token-once-copy" aria-label="Copy token">Copy</button> + </div> + </div> + + <!-- The full-width table (AGENTS.md rule 5 — no skinny list): + Label | Created (locale date+time, full ISO on hover) | + Last used (locale or "never") | Status (Active em-dash vs + the rose Revoked pill — the stale-pill language) | Actions + (Revoke — the inline two-step confirm; Revoked rows carry + NO action — nothing left to revoke). tokens.js fills + #tokens-tbody; #tokens-empty-row ships hidden and is + revealed by a 0-row fetch (and a failed load). The Actions + column header is visually-hidden — the row buttons carry + their own aria-labels (the history-table convention). --> + <div class="table-wrap tokens-table-wrap" id="tokens-table-wrap" role="region" aria-label="Access tokens" tabindex="0"> + <table class="tokens-table" id="tokens-table"> + <caption class="visually-hidden">Access tokens issued to people who use the app — newest first</caption> + <thead> + <tr> + <th scope="col">Label</th> + <th scope="col">Created</th> + <th scope="col">Last used</th> + <th scope="col">Status</th> + <th scope="col"><span class="visually-hidden">Actions</span></th> + </tr> + </thead> + <tbody id="tokens-tbody"> + <tr class="tokens-empty-row" id="tokens-empty-row" hidden> + <td colspan="5">No tokens yet — generate one above and hand it out.</td> + </tr> + </tbody> + </table> + </div> + </div> + </section> + </main> + <!-- Phase 79 (task 05): the in-app token gate — a body-level + position:fixed; inset:0 overlay (the body-level doc-modal + precedent) that is the ONLY interactive surface while visible: + assets/token-gate.js locks #main (inert) so focus and keyboard + traversal stay in the gate (WCAG, the inert-pair contract). It + SHIPS hidden + inert (the phase-16 ship-hidden pattern) — an + authenticated boot (admin, or a valid cached token re-authed + silently at mount) never shows it for a frame. The gate is the + token holders' login: the entered token is cached in + localStorage["bor.token"] so the next visit re-auths silently; + sign out (the header binding) drops the key. The static + "Sign in as admin" href is the no-JS fallback — the header's + ?next= convention (the app root). --> + <section class="auth-gate" id="auth-gate" hidden inert aria-labelledby="auth-gate-title"> + <div class="auth-gate-card"> + <div class="auth-gate-glyph" aria-hidden="true"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg> + </div> + <h2 id="auth-gate-title">Enter your access token</h2> + <p class="auth-gate-sub"> + Ask the admin for a token — it opens chat, the answers, and the + documents they cite. Shared chats stay open. + </p> + <form id="auth-gate-form"> + <label class="visually-hidden" for="auth-gate-input">Access token</label> + <input + id="auth-gate-input" + name="token" + type="text" + autocomplete="off" + autocapitalize="none" + spellcheck="false" + placeholder="bor_…" + required + > + <button type="submit" class="auth-gate-submit">Sign in</button> + </form> + <p class="auth-gate-error" id="auth-gate-error" role="alert" hidden> + That token isn’t valid — check it with the admin. + </p> + <a class="sources-gate-link" href="/login.html?next=/">Sign in as admin</a> + </div> + </section> + <footer class="app-footer"> <div class="container footer-inner"> <span class="footer-text">Powered by self-hosted models</span> @@ -754,11 +955,22 @@ before the page script body calls initSharedHeader() at boot. --> <script type="module" src="/assets/app.js"></script> <!-- Phase 76 (task 01): the shell router — AFTER app.js (boot order: - brand.js classic → app.js module → router.js module). It reads + brand.js classic → app.js module → router.js module → + token-gate.js module, phase 79 task 05). It reads location.pathname, shows the matching view, and lazy-imports the non-chat view modules on first show only (mount-once). The chat view needs no module import: app.js already ran at shell boot. --> <script type="module" src="/assets/router.js"></script> + <!-- Phase 79 (task 05): the in-app token gate module — AFTER app.js + and router.js. Its boot call (mountGate on #main) is AWAITED by + app.js's boot IIFE BEFORE initSharedHeader, so a cached token's + silent re-auth lands before the first whoami fires — the header + sees the post-auth role deterministically. The tag + import pair + is safe: ES modules evaluate ONCE (the module cache) and + token-gate.js has no import-time side effects; the Containerfile + bundles it (the parity pin in + tests/integration/test_containerfile_assets.py). --> + <script type="module" src="/assets/token-gate.js"></script> <!-- Phase 26: the almost-fullscreen document modal. Source chips and Sources-table path links open documents here (same-page overlay, diff --git a/frontend/login.html b/frontend/login.html index 1f1559f..1ab751b 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -51,6 +51,13 @@ reveals it once whoami says admin, exactly like the Tuning link above. --> <a href="/history.html" class="nav-link" id="nav-history" hidden>History</a> + <!-- Phase 79 (task 06): the Tokens link is admin-only — + hidden by default, header.js reveals it once whoami says + admin, exactly like the History link above (the + phase-34 one-bar contract: the SAME nav ships on every + page — test_nav_consistency pins the inventory parity). + Null-safe: header.js is a no-op on a page without it. --> + <a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a> <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is outside the nav; see styles.css .sign-in-mobile rules). --> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> diff --git a/frontend/shared.html b/frontend/shared.html index 29ea43d..d98701f 100644 --- a/frontend/shared.html +++ b/frontend/shared.html @@ -52,6 +52,13 @@ reveals it once whoami says admin, exactly like the Tuning link above. --> <a href="/history.html" class="nav-link" id="nav-history" hidden>History</a> + <!-- Phase 79 (task 06): the Tokens link is admin-only — + hidden by default, header.js reveals it once whoami says + admin, exactly like the History link above (the + phase-34 one-bar contract: the SAME nav ships on every + page — test_nav_consistency pins the inventory parity). + Null-safe: header.js is a no-op on a page without it. --> + <a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a> <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is outside the nav; see styles.css .sign-in-mobile rules). --> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> diff --git a/scripts/model_benchmark.py b/scripts/model_benchmark.py index 6e6000d..369bc89 100644 --- a/scripts/model_benchmark.py +++ b/scripts/model_benchmark.py @@ -51,7 +51,6 @@ Usage from any test script:: from __future__ import annotations import csv -import os from datetime import date from pathlib import Path diff --git a/scripts/test_embed_model.py b/scripts/test_embed_model.py index 9cbe185..262ba6e 100644 --- a/scripts/test_embed_model.py +++ b/scripts/test_embed_model.py @@ -85,7 +85,7 @@ class EmbedResult: # ── helpers ───────────────────────────────────────────────────────────── def _cosine(a: list[float], b: list[float]) -> float: - dot = sum(x * y for x, y in zip(a, b)) + dot = sum(x * y for x, y in zip(a, b, strict=True)) na = math.sqrt(sum(x * x for x in a)) nb = math.sqrt(sum(x * x for x in b)) if na == 0 or nb == 0: @@ -94,10 +94,7 @@ def _cosine(a: list[float], b: list[float]) -> float: def _has_nan_or_inf(vec: list[float]) -> bool: - for v in vec: - if math.isnan(v) or math.isinf(v): - return True - return False + return any(math.isnan(v) or math.isinf(v) for v in vec) async def _embed(client: httpx.AsyncClient, model: str, texts: list[str]) -> list[list[float]]: @@ -170,8 +167,6 @@ async def _test_cosine(model: str) -> EmbedResult: # a[2] vs a[3] (dissimilar) dissim = _cosine(vectors[i + 2], vectors[i + 3]) dissimilar_scores.append(dissim) - # Cross: a[0] vs a[2] (should be lower than similar) - cross = _cosine(vectors[i], vectors[i + 2]) avg_similar = sum(similar_scores) / len(similar_scores) avg_dissimilar = sum(dissimilar_scores) / len(dissimilar_scores) diff --git a/scripts/test_summary_model.py b/scripts/test_summary_model.py index 03d72d2..45109aa 100644 --- a/scripts/test_summary_model.py +++ b/scripts/test_summary_model.py @@ -27,7 +27,7 @@ import asyncio import os import sys import time -from dataclasses import dataclass, field +from dataclasses import dataclass from dotenv import load_dotenv @@ -235,8 +235,8 @@ def _detect_hallucination(summary: str, source: str) -> bool: uncommon = summary_tokens - source_tokens # Filter out very common English words common = {"system", "service", "network", "server", "data", "file", - "host", "port", "port", "running", "config", "value", - "model", "image", "container", "running", "local", "local"} + "host", "port", "running", "config", "value", + "model", "image", "container", "local"} flagged = uncommon - common return len(flagged) > 3 # more than 3 uncommon new tokens @@ -250,7 +250,7 @@ async def run_benchmark( """Run the summary benchmark and return results.""" all_results: list[SummaryResult] = [] - for run_idx in range(runs): + for _run_idx in range(runs): for label, text in _FIXTURE_TEXTS: summary, wall = await _summarize(None, model, text) src_len = len(text) diff --git a/tests/e2e/auth_helpers.py b/tests/e2e/auth_helpers.py index 65db3ea..d1113c7 100644 --- a/tests/e2e/auth_helpers.py +++ b/tests/e2e/auth_helpers.py @@ -1,10 +1,18 @@ -"""Shared Playwright auth helper (phase 16). +"""Shared Playwright auth helpers (phase 16 + phase 79). ``login`` drives the REAL form login on /login.html (fill → submit → redirect) so every story that needs the admin does exactly what a human would — no cookie surgery. ``password=None`` uses the shared E2E admin password (success path); pass a wrong value to drive the error state (no redirect, ``#login-error`` role=alert visible, still anonymous). + +``login_with_token`` (phase 79) drives the REAL in-app token gate on the +shell the same way (fill → submit): pass an admin-issued token for the +success contract (the gate hides, the app is interactive); call it with +the default all-zeros sentinel — or ``expect_error=True`` for a token +that USED to be valid (revocation) — for the wrong-token contract +(``#auth-gate-error`` role=alert visible, the gate stays, still +anonymous). Task 07's story suite is the first consumer. """ from __future__ import annotations @@ -14,6 +22,53 @@ from e2e.conftest import ADMIN_PASSWORD # noqa: F401 (re-exported for tests) DEFAULT_NEXT = "/sources.html" +#: The wrong-token sentinel (phase 79): the ``bor_`` prefix plus 32 zero +#: hex chars. The admin issues ``bor_`` + ``secrets.token_hex(16)``, so +#: an all-zeros token is never a live one — the DEFAULT call drives the +#: wrong-token contract without needing a real token at all. +WRONG_TOKEN = "bor_" + "0" * 32 + + +def login_with_token( + page: Page, + app_url: str, + token: str = WRONG_TOKEN, + next: str = "/", + expect_error: bool | None = None, +) -> None: + """Drive the real in-app token gate (phase 79) on the shell. + + ``page.goto`` the shell — the test contexts are fresh, so no + ``bor.token`` is cached in localStorage and ``#auth-gate`` is the + visible surface — then fill ``#auth-gate-input`` with ``token`` and + submit the gate's form: + + * success (an explicitly-passed valid, unrevoked token — with + ``expect_error`` left ``None`` the branch is derived from the + all-zeros ``WRONG_TOKEN`` sentinel) → ``#auth-gate`` hides and + the app is interactive (the composer is reachable) at ``next``; + * wrong / revoked (``expect_error=True``, or the sentinel default) + → ``#auth-gate-error`` (role=alert) is visible, the gate stays + visible — the visible gate IS the anonymity proof, the UI mirror + of ``login``'s wrong-password branch — and the visitor is still + anonymous. + """ + page.goto(app_url + next) + gate = page.locator("#auth-gate") + expect(gate).to_be_visible(timeout=30_000) + page.fill("#auth-gate-input", token) + page.click("#auth-gate-form button") + error_expected = token == WRONG_TOKEN if expect_error is None else expect_error + if error_expected: + error = page.locator("#auth-gate-error") + expect(error).to_have_attribute("role", "alert") + expect(error).to_be_visible(timeout=15_000) + expect(gate).to_be_visible() # the gate is the anonymity proof + return + expect(gate).to_be_hidden(timeout=30_000) + expect(page.locator("#message-input")).to_be_visible() # app is interactive + expect(page).to_have_url(app_url + next) + def login(page: Page, app_url: str, password: str | None = None, next: str | None = None) -> None: """Perform the real form login and wait for its outcome. @@ -22,13 +77,30 @@ def login(page: Page, app_url: str, password: str | None = None, next: str | Non → redirects to ``next`` (default ``/sources.html``); * wrong password → ``#login-error`` (role=alert) is visible, the URL never changes, and the visitor is still anonymous. + + Idempotent (phase 79): in a context that is ALREADY signed in the + login page boots straight to ``next`` (login.js's whoami redirect — + no form) and the call returns as a success; a test may sign in once + per context and call it again from a later section without breaking. """ attempt = ADMIN_PASSWORD if password is None else password url = f"{app_url}/login.html" if next is not None: url += f"?next={next}" page.goto(url) - expect(page.locator("#login-password")).to_be_visible() + if attempt == ADMIN_PASSWORD: + # Phase 79 (task 04): the helper must be IDEMPOTENT — a context + # that is ALREADY signed in never sees the form (login.js's + # boot whoami redirect goes straight to `next`), and a test that + # signs in twice (two sections, one context) must not break on + # the second call. + try: + expect(page.locator("#login-password")).to_be_visible(timeout=2_000) + except AssertionError: + expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000) + return + else: + expect(page.locator("#login-password")).to_be_visible() page.fill("#login-password", attempt) page.click("#login-form button[type=submit]") if attempt != ADMIN_PASSWORD: diff --git a/tests/e2e/test_admin_auth.py b/tests/e2e/test_admin_auth.py index 992ca24..4489d50 100644 --- a/tests/e2e/test_admin_auth.py +++ b/tests/e2e/test_admin_auth.py @@ -10,12 +10,19 @@ set (``tests/e2e/conftest.py``); the shared ``tests/e2e/auth_helpers.py::login`` performs the real form login on /login.html. Test → story mapping (Playwright Mapping Rule): -1. ``test_anonymous_chat_without_tuning`` -2. ``test_anonymous_sources_gated_viewer_open`` +1. ``test_anonymous_chat_gated_no_tuning`` +2. ``test_anonymous_sources_and_viewer_gated`` 3. ``test_login_wrong_password_shows_error`` 4. ``test_admin_login_unlocks_sources_and_tuning`` 5. ``test_logout_returns_to_anonymous`` 6. ``test_login_page_a11y`` + +Phase 79 (API tokens): the anonymous pins moved to the gated contract — +``POST /api/chat`` and ``GET /api/documents/content`` are +``require_user`` (401 ``authentication required`` for anonymous; the +phase-16 "the viewer stays open" soft rule is SUPERSEDED, shared chats +are the only open surface). The password sign-in / sign-out / +wrong-password assertions are UNCHANGED. """ from __future__ import annotations @@ -37,7 +44,6 @@ REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" QUESTION = "How is my Kubernetes cluster set up?" MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" -DOC_TITLE = "Kubernetes Homelab Cluster" DOC_VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md" @@ -88,14 +94,12 @@ def _ask(page: Page, question: str) -> None: # --------------------------------------------------------------------------- -# 1. Anonymous: chat works, the tuning UI is gone, Sign in is offered +# 1. Anonymous: chat is GATED (phase 79), the tuning UI is gone, Sign in +# is offered # --------------------------------------------------------------------------- -def test_anonymous_chat_without_tuning( - page: Page, app_url: str, mock_llm: int, db_ready: None -) -> None: - _reset_db(mock_llm, seed=True) +def test_anonymous_chat_gated_no_tuning(page: Page, app_url: str, db_ready: None) -> None: page.set_default_timeout(30_000) page.goto(app_url) @@ -108,31 +112,48 @@ def test_anonymous_chat_without_tuning( expect(page.locator("#sign-in-link")).to_have_attribute("href", "/login.html?next=/") expect(page.locator("#sign-out-btn")).to_be_hidden() - # Chat still streams a grounded answer (with source chips) for - # anonymous visitors… - _ask(page, QUESTION) - expect(page.locator(".msg.brain .source-chip", has_text="kubernetes.md")).to_have_count(1) + # Phase 79: the phase-16 "anonymous chat still streams" pin is + # SUPERSEDED — POST /api/chat is require_user-gated and the + # anonymous browser's own fetch gets the 401 contract (the in-app + # token gate that locks this UI is task 05's surface; the API + # contract is the stable half of the pin). + anon_chat = page.evaluate( + """async () => { + const r = await fetch('/api/chat', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({message: 'hello?'}), + }); + return {status: r.status, body: await r.json()}; + }""" + ) + assert anon_chat["status"] == 401, anon_chat + assert anon_chat["body"] == {"detail": "authentication required"}, anon_chat - # …but the tuning UI is completely gone: no Tune button (new or + # …and the server agrees the visitor is anonymous. + who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())") + assert who == {"authenticated": False, "role": "anonymous"} + + # The tuning UI is completely gone: no Tune button (new or # restored), no Tuning toggle or panel in the DOM at all. expect(page.locator(".msg.brain .tune-btn")).to_have_count(0) expect(page.locator("#steering-toggle")).to_have_count(0) expect(page.locator("#steering-panel")).to_have_count(0) - # A reload (the phase-14 restore path) must not bring it back. + # A reload must not bring it back. page.reload() - expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER) expect(page.locator(".msg.brain .tune-btn")).to_have_count(0) expect(page.locator("#steering-toggle")).to_have_count(0) expect(page.locator("#sign-in-link")).to_be_visible() # --------------------------------------------------------------------------- -# 2. Anonymous: Sources gated, the document viewer stays open (soft rule) +# 2. Anonymous: Sources gated AND the document viewer's DATA is gated +# (phase 79 supersedes the phase-16 soft rule) # --------------------------------------------------------------------------- -def test_anonymous_sources_gated_viewer_open( +def test_anonymous_sources_and_viewer_gated( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) @@ -160,10 +181,26 @@ def test_anonymous_sources_gated_viewer_open( # …and NO /api/docs call was ever made. assert api_docs_calls == [], f"anonymous sources page called /api/docs: {api_docs_calls}" - # The soft rule: any seeded document still opens by direct URL. + # Phase 79 (task 05): the phase-16 soft rule ("any seeded document + # still opens by direct URL") is SUPERSEDED — the content endpoint + # is require_user-gated. The page DOCUMENT still loads (anonymous + # gets the HTML), but the GATED DATA does not: the API refuses + # with 401, and the viewer shows the inline token gate + # (#doc-auth-gate) instead of a content error — the content fetch + # never runs, so no not-found card either. page.goto(app_url + DOC_VIEWER_URL) - expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000) - expect(page.locator("#doc-content")).not_to_be_empty() + expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000) + expect(page.locator("#doc-not-found")).to_be_hidden() + expect(page.locator("#doc-title")).to_have_text("Loading…") + anon_content = page.evaluate( + """async () => { + const r = await fetch( + '/api/documents/content?source=docs&path=homelab%2Fkubernetes.md'); + return {status: r.status, body: await r.json()}; + }""" + ) + assert anon_content["status"] == 401, anon_content + assert anon_content["body"] == {"detail": "authentication required"}, anon_content # --------------------------------------------------------------------------- diff --git a/tests/e2e/test_agent_document_tools.py b/tests/e2e/test_agent_document_tools.py index 8c4ff87..2f15fe7 100644 --- a/tests/e2e/test_agent_document_tools.py +++ b/tests/e2e/test_agent_document_tools.py @@ -73,6 +73,7 @@ from sqlalchemy.orm import Session from app.db import SessionLocal from app.models import Chunk, Document, QueryLog +from e2e.auth_helpers import login from tests.e2e.mock_llm import embed_text # -------------------------------------------------------------------------- @@ -358,7 +359,7 @@ def test_marker_question_lists_reads_and_quotes( ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, MARKER_QUESTION) @@ -446,7 +447,7 @@ def test_tool_lines_re_render_after_reload( ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) - page.goto(app_url) + login(page, app_url, next="/") _submit(page, MARKER_QUESTION) _wait_settled(page) @@ -483,7 +484,7 @@ def test_plain_grounded_question_has_no_tool_frames( ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, PLAIN_QUESTION) @@ -518,7 +519,7 @@ def test_deflected_question_has_no_tool_frames( ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, DEFLECT_QUESTION) diff --git a/tests/e2e/test_agent_unlimited_tools.py b/tests/e2e/test_agent_unlimited_tools.py index 0b9c930..1d97011 100644 --- a/tests/e2e/test_agent_unlimited_tools.py +++ b/tests/e2e/test_agent_unlimited_tools.py @@ -85,6 +85,7 @@ from sqlalchemy.orm import Session from app.db import SessionLocal from app.models import Chunk, Document, QueryLog +from e2e.auth_helpers import login from tests.e2e.mock_llm import embed_text # -------------------------------------------------------------------------- @@ -392,7 +393,7 @@ def test_multi_read_turn( ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, MULTI_QUESTION) @@ -474,7 +475,7 @@ def test_done_sources_include_reads( ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, MULTI_QUESTION) @@ -513,7 +514,7 @@ def test_relist_allowed( ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, MULTI_QUESTION) @@ -556,7 +557,7 @@ def test_single_tool_flow_regression( ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, SINGLE_QUESTION) diff --git a/tests/e2e/test_api_tokens.py b/tests/e2e/test_api_tokens.py new file mode 100644 index 0000000..e4064b5 --- /dev/null +++ b/tests/e2e/test_api_tokens.py @@ -0,0 +1,676 @@ +"""Phase 79 E2E (Playwright): API tokens — the owner's sentence, pinned +in a real browser. + +TODO.md L5 (owner 2026-09-06): "Add api tokens that the admin can +generate and hand out so people can log in to use the app. The only +thing that should be accessible without an API token is shared chats. +The web ui should ask for a token before letting a user through and +should cache that token in browser storage so they don't have to keep +entering it." + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_api_tokens.py -v --no-cov + +Test → claim mapping (every clause of the owner's sentence is pinned): + +1. ``test_anonymous_is_locked_out`` — "ask for a token before letting a + user through": a fresh context meets ``#auth-gate`` on the chat + page, ``#main`` is inert (the composer is NOT keyboard-reachable — + the inverted tab-order walk), and the three gated endpoints + (chat / suggestions / document content) 401 from the context's own + empty cookies. +2. ``test_shared_chats_stay_open_anonymous`` — "the only thing that + should be accessible without an API token is shared chats": the + admin creates + shares a saved chat (the house API pattern), a + FRESH context opens ``/shared/<token>`` anonymously and sees the + conversation rendered — no gate anywhere on that page. +3. ``test_admin_generates_token_in_ui`` — "the admin can generate": + the admin navigates to the Tokens view, labels a token + "e2e-alice" and generates — ``#token-once-value`` carries + ``bor_`` + 32 hex (the A4 plaintext-once), the table shows the + Active row, and the once-block is GONE on a re-show (the plaintext + can never be re-shown). +4. ``test_token_user_uses_the_app`` — "hand out so people can log in + and use the app": a fresh context signs in through the real gate + (the task-04 ``login_with_token`` helper), chats end-to-end + (mock LLM), opens a cited document in the same-page modal, and + gets the role-``user`` header contract — every admin nav link + absent, Sign out visible. +5. ``test_cached_token_survives_reload`` — "cache that token in + browser storage so they don't have to keep entering it": the + entered token lands in ``localStorage["bor.token"]``; a reload + re-auths silently — no gate, no re-entry, still role user. +6. ``test_admin_only_walls_403_for_token_user`` — "every existing + admin-only surface stays admin-only": the token user's own session + cookie 403s on tokens / chats / docs / steering / git-sources. +7. ``test_sign_out_clears_the_cached_token`` — sign out clears the + session AND the cached token (one logout, both gone); the gate + comes back. +8. ``test_revocation_closes_the_door`` — "revocation is enforced + IMMEDIATELY": the admin revokes through the UI two-step; the + holder's next gated request 401s (the 401 clears the dead + session cookie — the next whoami is anonymous), and a FRESH login + attempt with the same token is refused at the gate. +9. ``test_wrong_token_is_one_generic_error`` — no enumeration: a + wrong token shows the gate's role=alert line and keeps the + visitor anonymous; the API's error body for a malformed token is + byte-equal to the one for a well-formed unknown token. + +DB isolation: the shared e2e Postgres keeps ``api_tokens`` (and +``saved_chats``) rows across suites. This file is the only suite that +issues tokens, so an autouse fixture deletes the ``e2e-``-labeled +rows before each test (never a TRUNCATE — the shared DB may hold the +owner's real tokens); the shared-chat test deletes its own saved row +in a ``finally``. Every scenario runs in its OWN fresh browser +context — no cached token (localStorage) or session cookie leaks +between tests. +""" +from __future__ import annotations + +import asyncio +import re +from pathlib import Path +from threading import Thread +from typing import Any + +import httpx +import pytest +from playwright.sync_api import Browser, BrowserContext, Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient +from e2e.auth_helpers import login, login_with_token + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" +MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" +#: The plaintext token's shape (owner-locked A4): prefix + 32 hex. +TOKEN_RE = re.compile(r"bor_[0-9a-f]{32}") +#: The five admin-only nav links (the role-`user` contract: ALL of +#: them stay absent for a token user — header.js reveals them only +#: for role === "admin"). +ADMIN_NAV_LINKS = ("#nav-sources", "#nav-git-sources", "#nav-tuning", "#nav-history", "#nav-tokens") + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread. + + Playwright's sync API keeps an asyncio loop running on the test + thread, so ``asyncio.run`` cannot be called directly from a test + body. + """ + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: + """Truncate the KB (and query log + steering notes — deterministic + mock answers), then optionally re-import fixtures. ``saved_chats`` + and ``api_tokens`` are deliberately NOT touched (the house + pattern).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes")) + db.commit() + if not seed: + return None + return _run_in_thread(_import_fixtures(mock_port)) + + +def _cleanup_e2e_tokens() -> None: + """Delete this suite's issued tokens (deterministic re-runs). + + Label-scoped on ``e2e-`` — never a TRUNCATE: the shared e2e DB is + also the dev DB and may hold the owner's real tokens. + """ + with SessionLocal() as db: + db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'")) + db.commit() + + +@pytest.fixture(autouse=True) +def _e2e_tokens_clean(db_ready: None) -> None: + """Start every test from the same token-empty state (this file is + the only E2E suite that issues tokens).""" + _cleanup_e2e_tokens() + + +def _ask(page: Page, question: str) -> None: + """Send one turn and wait until the grounded answer has fully + landed (the ``done`` event restored the Send button).""" + page.fill("#message-input", question) + page.click("#send-btn") + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + expect(page.locator(".msg.brain .bubble").last).to_contain_text( + MOCK_ANSWER_MARKER, timeout=30_000 + ) + expect(page.locator("#send-btn")).to_be_enabled() + expect(page.locator("#send-label")).to_have_text("Send") + + +def _cookies(page: Page) -> dict[str, str]: + """The session cookies the browser context holds (the test's API + side sees exactly what that browser sees).""" + return { + c["name"]: c["value"] + for c in page.context.cookies() + if "name" in c and "value" in c + } + + +def _create_token(app_url: str, cookies: dict[str, str], label: str) -> tuple[str, str]: + """Admin-issued token through the house API pattern (httpx + the + signed admin cookie): ``POST /api/tokens`` → 201 (the ONE response + that carries the plaintext, A4). Returns (row id, plaintext).""" + r = httpx.post(f"{app_url}/api/tokens", json={"label": label}, cookies=cookies, timeout=10) + assert r.status_code == 201, r.text + body = r.json() + assert body["label"] == label + assert TOKEN_RE.fullmatch(body["token"]), f"bad token shape: {body}" + return body["id"], body["token"] + + +def _whoami(page: Page) -> dict[str, Any]: + """The page's own ``/api/whoami`` read (the context's cookies).""" + return page.evaluate("() => fetch('/api/whoami').then((r) => r.json())") + + +# --------------------------------------------------------------------------- +# 1. Anonymous is locked out: the gate is up, #main is inert, and the +# three app surfaces 401 (only the shared chats are open — test 2) +# --------------------------------------------------------------------------- + + +def test_anonymous_is_locked_out(page: Page, app_url: str, db_ready: None) -> None: + page.set_default_timeout(30_000) + js_errors: list[str] = [] + page.on("pageerror", lambda e: js_errors.append(str(e))) + + page.goto(app_url) + + # The gate is the visible surface of an anonymous chat page… + gate = page.locator("#auth-gate") + expect(gate).to_be_visible(timeout=30_000) + expect(gate).to_have_attribute("aria-labelledby", "auth-gate-title") + expect(page.locator("#auth-gate-input")).to_be_focused() # the gate takes the focus + expect(page.locator("#auth-gate-form button[type=submit]")).to_be_visible() + expect(page.locator("#auth-gate-error")).to_have_attribute("role", "alert") + + # …and it LOCKS the app: #main is inert while the gate is up, so + # the composer cannot be reached — not by mouse, not by keyboard. + assert page.evaluate("() => document.getElementById('main').inert === true") + + # Inverted tab-order walk (the test_suggestion_chips keyboard + # walk, inverted): from the page start, Tab cycles the gate and + # the sign-in link only — the composer is never a tab stop. + seen: list[str] = [] + for _ in range(12): + page.keyboard.press("Tab") + seen.append( + page.evaluate( + "() => (document.activeElement && document.activeElement.id) || ''" + ) + ) + assert "auth-gate-input" in seen, f"the gate input must be keyboard-reachable: {seen}" + assert "message-input" not in seen, ( + f"the composer must NOT be keyboard-reachable while the gate is up: {seen}" + ) + + # The API agrees, from the context's own (empty) cookies: the + # three app surfaces all refuse with ONE 401 detail. + anon_chat = page.evaluate( + """async () => { + const r = await fetch('/api/chat', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({message: 'hello?'}), + }); + return {status: r.status, body: await r.json()}; + }""" + ) + assert anon_chat["status"] == 401, anon_chat + assert anon_chat["body"] == {"detail": "authentication required"}, anon_chat + + anon_sugg = page.evaluate( + """() => fetch('/api/suggestions') + .then((r) => r.json().then((body) => ({status: r.status, body})))""" + ) + assert anon_sugg["status"] == 401, anon_sugg + assert anon_sugg["body"] == {"detail": "authentication required"}, anon_sugg + + anon_doc = page.evaluate( + """async () => { + const r = await fetch( + '/api/documents/content?source=docs&path=homelab%2Fkubernetes.md'); + return {status: r.status, body: await r.json()}; + }""" + ) + assert anon_doc["status"] == 401, anon_doc + assert anon_doc["body"] == {"detail": "authentication required"}, anon_doc + + # The gated boot itself must be crash-free. + assert not js_errors, f"the gated boot must not throw: {js_errors}" + + +# --------------------------------------------------------------------------- +# 2. Shared chats stay open: the ONLY anonymous content (the owner's +# sentence) — a fresh context reads the shared conversation with no +# gate anywhere +# --------------------------------------------------------------------------- + + +def test_shared_chats_stay_open_anonymous( + page: Page, browser: Browser, app_url: str, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + # As admin: create + share a saved chat (the house API pattern + # from test_share_chat — httpx with the signed session cookie). + q = "How is my Kubernetes cluster set up? (api-tokens-shared)" + r = httpx.post( + f"{app_url}/api/chats", + json={ + "messages": [ + {"who": "user", "text": q}, + {"who": "brain", "text": "Deterministic mock answer for E2E (api-tokens-shared)"}, + ] + }, + cookies=_cookies(page), + timeout=10, + ) + assert r.status_code == 201, r.text + chat_id: str = r.json()["id"] + r = httpx.post(f"{app_url}/api/chats/{chat_id}/share", cookies=_cookies(page), timeout=10) + assert r.status_code == 200, r.text + share_url: str = r.json()["share_url"] + assert share_url.startswith("/shared/"), share_url + title = " ".join(q.split())[:120] # the auto-title convention + + anon_ctx: BrowserContext | None = None + try: + # The anonymous JSON snapshot is open (no session at all)… + snap = httpx.get(f"{app_url}/api{share_url}", timeout=10) + assert snap.status_code == 200, snap.text + assert snap.json()["title"] == title + + # …and the shared PAGE renders the conversation in a FRESH + # context (no cookies, no cached token) — with no gate + # anywhere on that page: shared chats are the anonymous + # surface, full stop. + anon_ctx = browser.new_context() + anon = anon_ctx.new_page() + anon.set_default_timeout(30_000) + anon.goto(app_url + share_url) + expect(anon.locator("#shared-title")).to_have_text(title) + expect(anon.locator(".msg.user .bubble")).to_contain_text(q) + expect(anon.locator(".msg.brain .bubble")).to_contain_text("api-tokens-shared") + expect(anon.locator(".auth-gate")).to_have_count(0) + expect(anon.locator("#auth-gate, #doc-auth-gate")).to_have_count(0) + # The guest header offers sign-in (the shared page contract). + expect(anon.locator("#sign-in-link")).to_be_visible(timeout=15_000) + finally: + if anon_ctx is not None: + anon_ctx.close() + httpx.delete(f"{app_url}/api/chats/{chat_id}", cookies=_cookies(page), timeout=10) + + +# --------------------------------------------------------------------------- +# 3. The admin generates a token in the UI: the plaintext appears +# EXACTLY ONCE and the Active row lands in the table; the +# once-block is gone on a re-show +# --------------------------------------------------------------------------- + + +def test_admin_generates_token_in_ui(page: Page, app_url: str, db_ready: None) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + expect(page).to_have_url(app_url + "/", timeout=30_000) + + # The Tokens view is the shell's sixth nav link (the phase-76 + # fold) — revealed for the admin, hidden for everyone else. + expect(page.locator("#nav-tokens")).to_be_visible(timeout=15_000) + page.click("#nav-tokens") + expect(page.locator("#view-tokens")).to_be_visible() + expect(page.locator("#tokens-gate")).to_be_hidden() # admin: no sign-in gate + expect(page.locator("#token-create")).to_be_visible(timeout=15_000) + + # Generate: label "e2e-alice" → the plaintext appears exactly + # once, in the mono read-only field (the A4 shown-once contract). + page.fill("#token-label", "e2e-alice") + page.click("#token-generate") + expect(page.locator("#token-once")).to_be_visible(timeout=15_000) + token = page.input_value("#token-once-value") + assert TOKEN_RE.fullmatch(token), f"bad token shape: {token!r}" + expect(page.locator("#tokens-status")).to_have_text( + "Token created — copy it now; it won't be shown again." + ) + + # The table shows the Active row: the em-dash status marker, no + # Revoked pill, a Revoke action. + row = page.locator("#tokens-tbody tr", has_text="e2e-alice") + expect(row).to_have_count(1) + expect(row.locator("td.tokens-label-cell")).to_have_text("e2e-alice") + expect(row.locator("td").nth(3)).to_have_text("—") # Active = the plain em-dash + expect(row.locator(".stale-pill")).to_have_count(0) + expect(row.locator("button.token-revoke")).to_have_count(1) + + # The once-block is NOT re-shown on a re-show: nav away (RAG) and + # back — the router's re-show refresh re-runs the list load, + # which hides + wipes the once-block. The plaintext is gone. + page.click("#nav-sources") + expect(page.locator("#view-rag")).to_be_visible() + page.go_back() + expect(page.locator("#view-tokens")).to_be_visible(timeout=15_000) + expect(page.locator("#token-once")).to_be_hidden() + assert page.input_value("#token-once-value") == "", "the plaintext must be wiped on re-show" + # The row survives the re-render (the token itself is unaffected). + expect(page.locator("#tokens-tbody tr", has_text="e2e-alice")).to_have_count(1) + + +# --------------------------------------------------------------------------- +# 4. The token flow: a fresh context signs in through the real gate +# and USES the app — a grounded chat turn (mock LLM), a cited +# document opened in the same-page modal, the role-user header +# --------------------------------------------------------------------------- + + +def test_token_user_uses_the_app( + page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None +) -> None: + page.set_default_timeout(30_000) + _reset_db(mock_llm, seed=True) # a deterministic seeded KB for the grounded turn + + # The admin generates + hands out the token (fresh context per + # role — the admin's browser is never the holder's browser). + login(page, app_url, next="/") + _id, token = _create_token(app_url, _cookies(page), "e2e-bob") + + user_ctx: BrowserContext | None = None + try: + user_ctx = browser.new_context() + user = user_ctx.new_page() + user.set_default_timeout(30_000) + + # The holder signs in through the REAL in-app gate (the + # task-04 helper: fill #auth-gate-input → submit → gate hides). + login_with_token(user, app_url, token) + + # Use the app: a grounded turn against the seeded KB (mock + # LLM) — the brain bubble renders the deterministic answer. + _ask(user, "How is my Kubernetes cluster set up? (api-tokens-flow)") + + # A cited source chip opens the document in the SAME-PAGE + # modal (the require_user content endpoint passes for a + # live token session). + chip = user.locator(".msg.brain a.source-chip").first + expect(chip).to_be_visible(timeout=15_000) + chip.click() + expect(user.locator("#doc-modal")).to_be_visible() + expect( + user.locator("#doc-modal-content .doc-md, #doc-modal-content pre.doc-raw") + ).to_have_count(1, timeout=15_000) # the document rendered (not the loading line) + expect(user.locator("#doc-modal-content .doc-modal-loading")).to_have_count(0) + expect(user.locator("#doc-modal-title")).not_to_be_empty() + expect(user.locator("#doc-modal-title")).not_to_have_text("Loading…") + expect(user.locator("#doc-modal-title")).not_to_have_text("Document not found") + + # The role-`user` header contract: ALL five admin nav links + # are absent (they reveal only for role === "admin"), and the + # auth pair is the signed-in branch (Sign out, no Sign in). + for link in ADMIN_NAV_LINKS: + expect(user.locator(link)).to_be_hidden() + expect(user.locator("#sign-in-link")).to_be_hidden() + expect(user.locator("#sign-out-btn")).to_be_visible() + + # The server agrees: authenticated, role user — NOT admin. + who = _whoami(user) + assert who == {"authenticated": True, "role": "user"} + finally: + if user_ctx is not None: + user_ctx.close() + + +# --------------------------------------------------------------------------- +# 5. Caching: the token lands in localStorage and a reload re-auths +# silently — no gate, no re-entry +# --------------------------------------------------------------------------- + + +def test_cached_token_survives_reload( + page: Page, browser: Browser, app_url: str, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _id, token = _create_token(app_url, _cookies(page), "e2e-cache") + + user_ctx: BrowserContext | None = None + try: + user_ctx = browser.new_context() + user = user_ctx.new_page() + user.set_default_timeout(30_000) + + # The gate's success path caches the entered token in + # localStorage (the owner's sentence: "cache that token in + # browser storage"). + login_with_token(user, app_url, token) + assert user.evaluate("() => localStorage.getItem('bor.token')") == token + + # A reload re-auths SILENTLY from the cache: no gate, no + # re-entry — the chat UI is interactive straight away, the + # lock is released, the role is still user. + user.reload() + expect(user.locator("#auth-gate")).to_be_hidden(timeout=30_000) + expect(user.locator("#message-input")).to_be_visible() + assert user.evaluate("() => document.getElementById('main').inert === false") + assert _whoami(user) == {"authenticated": True, "role": "user"} + # The cache is what re-authed the page — it survived the reload. + assert user.evaluate("() => localStorage.getItem('bor.token')") == token + finally: + if user_ctx is not None: + user_ctx.close() + + +# --------------------------------------------------------------------------- +# 6. The admin-only walls: every admin surface 403s the token user's +# own session cookie (require_admin, unchanged) +# --------------------------------------------------------------------------- + + +def test_admin_only_walls_403_for_token_user( + page: Page, browser: Browser, app_url: str, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _id, token = _create_token(app_url, _cookies(page), "e2e-wall") + + user_ctx: BrowserContext | None = None + try: + user_ctx = browser.new_context() + user = user_ctx.new_page() + user.set_default_timeout(30_000) + login_with_token(user, app_url, token) + cookies = _cookies(user) + + # The token user's OWN signed session cookie 403s on every + # admin surface — "admin only" (the phase-16 contract), + # never a 401 (the user IS authenticated — just not an + # admin). + for method, url in ( + ("GET", "/api/tokens"), + ("GET", "/api/chats"), + ("GET", "/api/docs"), + ("POST", "/api/steering"), + ("GET", "/api/git-sources"), + ): + r = httpx.request( + method, + app_url + url, + cookies=cookies, + timeout=10, + json={"note": "wall check"} if method == "POST" else None, + ) + assert r.status_code == 403, (method, url, r.status_code, r.text) + assert r.json() == {"detail": "admin only"}, (method, url, r.text) + finally: + if user_ctx is not None: + user_ctx.close() + + +# --------------------------------------------------------------------------- +# 7. Sign out: the header binding clears the session AND the cached +# token — the gate comes back +# --------------------------------------------------------------------------- + + +def test_sign_out_clears_the_cached_token( + page: Page, browser: Browser, app_url: str, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _id, token = _create_token(app_url, _cookies(page), "e2e-signout") + + user_ctx: BrowserContext | None = None + try: + user_ctx = browser.new_context() + user = user_ctx.new_page() + user.set_default_timeout(30_000) + login_with_token(user, app_url, token) + assert user.evaluate("() => localStorage.getItem('bor.token')") == token + + # Sign out (the header binding): POST /api/logout + drop the + # cached token + reload — ONE logout clears both the server + # session and the localStorage key. + user.click("#sign-out-btn") + expect(user.locator("#auth-gate")).to_be_visible(timeout=30_000) + assert user.evaluate("() => localStorage.getItem('bor.token')") is None + assert _whoami(user) == {"authenticated": False, "role": "anonymous"} + finally: + if user_ctx is not None: + user_ctx.close() + + +# --------------------------------------------------------------------------- +# 8. Revocation closes the door: the admin's UI two-step kills the +# token immediately — the holder's next request 401s (and the +# 401 clears the dead session cookie), and a fresh login attempt +# with the same token is refused +# --------------------------------------------------------------------------- + + +def test_revocation_closes_the_door( + page: Page, browser: Browser, app_url: str, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _id, token = _create_token(app_url, _cookies(page), "e2e-revoke") + + user_ctx: BrowserContext | None = None + fresh_ctx: BrowserContext | None = None + try: + # The holder is signed in (a fresh context of their own). + user_ctx = browser.new_context() + user = user_ctx.new_page() + user.set_default_timeout(30_000) + login_with_token(user, app_url, token) + + # The admin revokes through the UI two-step (Revoke → Yes — + # the inline confirm, no native dialog). + page.goto(app_url + "/tokens.html") + row = page.locator("#tokens-tbody tr", has_text="e2e-revoke") + expect(row).to_have_count(1, timeout=15_000) + row.locator("button.token-revoke").click() + expect(row.locator(".history-confirm-yes")).to_be_visible() + row.locator(".history-confirm-yes").click() + revoked = page.locator("#tokens-tbody tr", has_text="e2e-revoke") + expect(revoked.locator(".stale-pill")).to_have_text("Revoked", timeout=15_000) + expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-revoke".') + + # Enforcement is IMMEDIATE on the holder's next request (the + # session stores the row id — no server-side session store; + # the live row check IS the revocation check). Driven with the + # USER context's own cookies (its real browser cookie jar — + # response headers land exactly as in a live browser): + # + # 1. whoami is LAZY by contract — the dead session still + # reports "user" (the endpoint does not live-check)… + assert _whoami(user) == {"authenticated": True, "role": "user"} + # 2. …but the next GATED request 401s — and the 401's + # Set-Cookie clears the dead session from the jar… + resp = user.evaluate( + """async () => { + const r = await fetch('/api/chat', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({message: 'hello?'}), + }); + return {status: r.status, body: await r.json()}; + }""" + ) + assert resp["status"] == 401, resp + assert resp["body"] == {"detail": "authentication required"}, resp + # 3. …so from now on the holder is genuinely anonymous. + assert _whoami(user) == {"authenticated": False, "role": "anonymous"} + + # A FRESH login attempt with the same (now revoked) token is + # refused at the gate: the error line shows, the gate stays, + # the visitor is still anonymous. + fresh_ctx = browser.new_context() + fresh = fresh_ctx.new_page() + fresh.set_default_timeout(30_000) + login_with_token(fresh, app_url, token, expect_error=True) + assert _whoami(fresh) == {"authenticated": False, "role": "anonymous"} + finally: + if user_ctx is not None: + user_ctx.close() + if fresh_ctx is not None: + fresh_ctx.close() + + +# --------------------------------------------------------------------------- +# 9. Wrong token: the gate's one generic error, no enumeration — the +# malformed and the well-formed-unknown failures are byte-equal +# --------------------------------------------------------------------------- + + +def test_wrong_token_is_one_generic_error(page: Page, app_url: str, db_ready: None) -> None: + page.set_default_timeout(30_000) + # The task-04 helper's default sentinel drives the wrong-token + # contract (bor_ + 32 zeros — never a live token): the role=alert + # error line shows, the gate stays, the visitor is anonymous. + login_with_token(page, app_url) # token defaults to the all-zeros sentinel + assert _whoami(page) == {"authenticated": False, "role": "anonymous"} + + # No enumeration: the API's ONE generic 401 — the error body for + # a wrong-FORMAT token equals the one for a well-formed but + # unknown token (no shape hint on a credential endpoint). + malformed = httpx.post( + f"{app_url}/api/token-auth", json={"token": "not-a-token-at-all"}, timeout=10 + ) + unknown = httpx.post( + f"{app_url}/api/token-auth", json={"token": "bor_" + "0" * 32}, timeout=10 + ) + assert malformed.status_code == 401 + assert unknown.status_code == 401 + assert malformed.json() == unknown.json() == {"detail": "invalid token"} diff --git a/tests/e2e/test_archive_upload_sources.py b/tests/e2e/test_archive_upload_sources.py index 33d98b3..fc08885 100644 --- a/tests/e2e/test_archive_upload_sources.py +++ b/tests/e2e/test_archive_upload_sources.py @@ -110,7 +110,6 @@ from app.db import SessionLocal from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, - APP_PORT, MOCK_PORT, SESSION_SECRET, USE_REAL_LLM, @@ -118,6 +117,12 @@ from e2e.conftest import ( ) REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): in a combined session run the +# conftest session app already owns its port — a second uvicorn on it +# dies on bind and this suite would silently drive the wrong server. +# The module app binds its own port instead (env-overridable). +APP_PORT = int(os.environ.get("E2E_APP_PORT_ARCHIVE", "8124")) APP_URL = f"http://127.0.0.1:{APP_PORT}" #: The slow-LLM proxy's port (the conftest's mock LLM stays on MOCK_PORT). diff --git a/tests/e2e/test_background_animation.py b/tests/e2e/test_background_animation.py deleted file mode 100644 index 7ab4ad9..0000000 --- a/tests/e2e/test_background_animation.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Phase 22 E2E (Playwright): the background layers — now a regression -suite for the phase-25 no-motion design. - -Story: ``.agents/user_stories/background-no-motion.md`` (the phase-25 -owner direction supersedes this suite's original pins; the phase-22 -history lives in ``.agents/user_stories/background-animation.md``) -Run in isolation (DB must be up: ``podman compose up -d db``): - - uv run pytest tests/e2e/test_background_animation.py -v --no-cov - -Phase 22 proved the phase-08 background (grid drift + whole-layer -breathe) actually animated in a real Chromium viewport. The owner then -reported (2026-08-25, chat): the background "jitters down and to the -right every second and it slowly blinks brighter and darker. It should -be smooth, fluxuating, dimming and brightening, but not moving. -Different bright spots should slowly fade in and out." — so the -phase-25 redesign (``frontend/assets/styles.css``) removed the -movement entirely: the grid (``body::before``) is a STATIC texture -(``bg-grid-drift`` deleted), and three independent soft glow spots run -their own slow opacity-only fades — ``body::after`` runs ``bg-glow-a`` -(26s), ``html::before`` runs ``bg-glow-b`` (34s, −12s delay), -``html::after`` runs ``bg-glow-c`` (42s, −23s delay). - -This adapted suite now pins the phase-25 contract on the same -layers (the full story gate is -``tests/e2e/test_background_no_motion.py``): the grid is static, the -indigo spot runs ``bg-glow-a`` and the three spot timelines advance, -all four pseudo-layers keep the fixed/z-index −1/pointer-events -none/no-occlusion contract, and there is no 360px overflow. - -Test → story mapping (Playwright Mapping Rule): - -1. ``test_grid_layer_is_static`` — computed style of ``body::before``: - ``animationName`` is ``"none"`` (the 0.73px/s drift is gone), no - ``bg-grid-drift`` entry in the document animation list, and the - static grid ``backgroundImage`` is still painted. -2. ``test_glow_layer_animation_running`` — ``body::after`` runs - ``bg-glow-a`` (26s, ease-in-out, infinite) with a matching entry in - the document animation list, ``playState === "running"``. -3. ``test_glow_timelines_advance`` — ``currentTime`` of all three spot - timelines sampled, ~500ms waited, all advanced — the fades are truly - running, not paused (headless Chromium starts the document - animation timeline ~1s after load, so the first sample polls until - the timelines are alive). -4. ``test_background_layers_contracts`` — all four pseudo-elements - (body ``::before``/``::after`` + the phase-25 ``html - ::before``/``::after`` spots): ``position: fixed``, ``z-index: -1``, - ``pointer-events: none``, ``inset: 0`` (UI Structure Check: behind - content, click-through, full-viewport); the page canvas stays on - ``<html>`` (``rgb(15, 10, 10)`` = ``var(--bg)``) and ``<body>`` - stays transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion - contract. -5. ``test_no_horizontal_overflow_with_layers`` — at a 360px viewport - ``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin, - replicated locally — the ``fixed; inset: 0`` layers must add no - width). - -Chromium note: pseudo-element CSS animations are enumerated by -``document.getAnimations()``, NOT by ``document.body.getAnimations()`` -(verified on Chromium 151 — the element-level list is empty for -pseudo-layers), so tests 1–3 match on ``animationName`` in the -document-level list. The two ``html`` pseudo-layers' computed styles -come from ``getComputedStyle(document.documentElement, -"::before"/"::after")``. -""" -from __future__ import annotations - -import time - -from playwright.sync_api import Browser, Page - -GRID_OLD = "bg-grid-drift" # deleted in phase 25 — must not exist anywhere -GLOWS = ("bg-glow-a", "bg-glow-b", "bg-glow-c") # the three phase-25 spot fades -PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a) - -# Computed styles of all four pseudo-layers + the html/body background -# contract (single evaluate — one round-trip per test). -JS_LAYER_REPORT = """() => { - const pick = (el, pseudo) => { - const cs = getComputedStyle(el, pseudo); - return { - anim: cs.animationName, - timing: cs.animationTimingFunction, - iterations: cs.animationIterationCount, - position: cs.position, - zIndex: cs.zIndex, - pointerEvents: cs.pointerEvents, - edges: [cs.top, cs.right, cs.bottom, cs.left], - image: cs.backgroundImage, - }; - }; - return { - grid: pick(document.body, "::before"), - glowA: pick(document.body, "::after"), - glowB: pick(document.documentElement, "::before"), - glowC: pick(document.documentElement, "::after"), - htmlBg: getComputedStyle(document.documentElement).backgroundColor, - bodyBg: getComputedStyle(document.body).backgroundColor, - }; -}""" - -# The background-layer animations from the Web Animations API -# ({name, playState, currentTime}); the keyframe names are passed as one -# array argument (Playwright serializes the Python list to a JS array). -JS_TIMELINE = """(names) => document.getAnimations() - .filter((a) => names.includes(a.animationName)) - .map((a) => ({ - name: a.animationName, - playState: a.playState, - t: a.currentTime, - }))""" - - -def _timeline(page: Page) -> dict[str, float]: - """animationName → currentTime (ms) for the three glow-spot layers.""" - entries = page.evaluate(JS_TIMELINE, list(GLOWS)) - return {str(a["name"]): float(a["t"]) for a in entries} - - -def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None: - """Poll until all three spot timelines report currentTime > 0. - - Headless Chromium starts the document animation timeline shortly - after load (observed ≈1.4s after navigation) — until then - currentTime is 0, so the "did it advance?" sample in - ``test_glow_timelines_advance`` must start once the timeline is - alive. - """ - deadline = time.monotonic() + timeout_ms / 1000 - while time.monotonic() < deadline: - times = _timeline(page) - if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS): - return - page.wait_for_timeout(100) - raise AssertionError( - f"background animation timeline never started (saw {_timeline(page)!r})" - ) - - -# -------------------------------------------------------------------------- -# Tests (story → test mapping, see module docstring) -# -------------------------------------------------------------------------- - - -def test_grid_layer_is_static(page: Page, app_url: str, db_ready: None) -> None: - """Phase-25 AC1: the grid layer is STATIC in a real viewport — - ``animationName`` is ``"none"``, no ``bg-grid-drift`` animation - exists, and the static grid texture is still painted (the owner - rejected the grid's motion, not the grid).""" - page.goto(app_url) - report = page.evaluate(JS_LAYER_REPORT) - grid = report["grid"] - assert grid["anim"] == "none", ( - f"body::before must be static (animationName, got {grid['anim']!r})" - ) - live = page.evaluate(JS_TIMELINE, [GRID_OLD]) - assert not live, ( - f"no {GRID_OLD} animation may exist (got {live!r}) — the drift is deleted" - ) - assert grid["image"] != "none", ( - f"the static grid texture must still be painted (image {grid['image']!r})" - ) - - -def test_glow_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None: - """Phase-25 AC2: the indigo spot layer (body::after) runs - bg-glow-a — 26s, ease-in-out, infinite — in a real viewport: the - matching CSSAnimation is reported ``running``.""" - page.goto(app_url) - report = page.evaluate(JS_LAYER_REPORT) - glow = report["glowA"] - assert glow["anim"] == "bg-glow-a", ( - f"body::after must run bg-glow-a (got {glow['anim']!r})" - ) - assert glow["iterations"] == "infinite", ( - f"body::after must fade forever (got {glow['iterations']!r})" - ) - live = page.evaluate(JS_TIMELINE, list(GLOWS)) - match = [a for a in live if a["name"] == "bg-glow-a"] - assert match, "no bg-glow-a entry in document.getAnimations() — spot not fading" - assert match[0]["playState"] == "running", ( - f"bg-glow-a is {match[0]['playState']!r} — the spot fade must be running" - ) - - -def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None: - """Phase-25 AC2: all three spot timelines actually advance — the - background is a live animation, not a static (or paused) frame. - Sample currentTime, wait ~500ms, and require real progress on all - three layers.""" - page.goto(app_url) - _wait_timelines_alive(page) - before = _timeline(page) - page.wait_for_timeout(500) - after = _timeline(page) - for name in GLOWS: - delta = after[name] - before[name] - assert delta >= 200, ( - f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) " - "— paused or static?" - ) - - -def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None: - """Phase-25 AC4: UI Structure Check — ALL FOUR background - pseudo-layers (body ::before/::after + the phase-25 html - ::before/::after spots) stay behind content (fixed, z-index -1, - pointer-events none, full-viewport) and nothing occludes them: the - page canvas is on <html>, <body> transparent.""" - page.goto(app_url) - report = page.evaluate(JS_LAYER_REPORT) - for key in ("grid", "glowA", "glowB", "glowC"): - info = report[key] - assert info["position"] == "fixed", f"{key} must stay position:fixed" - assert info["zIndex"] == "-1", ( - f"{key} must stay behind content (z-index -1, got {info['zIndex']!r})" - ) - assert info["pointerEvents"] == "none", ( - f"{key} must stay click-through (pointer-events none)" - ) - assert info["edges"] == ["0px", "0px", "0px", "0px"], ( - f"{key} must stay full-viewport (inset: 0, got {info['edges']!r})" - ) - assert report["htmlBg"] == PAGE_BG, ( - f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})" - ) - assert report["bodyBg"] == "rgba(0, 0, 0, 0)", ( - f"body must stay transparent so the layers show (got {report['bodyBg']!r})" - ) - - -def test_no_horizontal_overflow_with_layers( - browser: Browser, app_url: str, db_ready: None -) -> None: - """Phase-25 AC6: the background layers add no width — the phase-07 - overflow pin (documentElement.scrollWidth <= clientWidth) still - holds at the 360px floor with all four fixed; inset: 0 layers - live.""" - phone = browser.new_page(viewport={"width": 360, "height": 740}) - try: - phone.goto(f"{app_url}/") - scroll, client = phone.evaluate( - "() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]" - ) - assert scroll <= client, ( - f"horizontal overflow at 360px with the background layers: " - f"{scroll} > {client}" - ) - finally: - phone.close() diff --git a/tests/e2e/test_background_no_motion.py b/tests/e2e/test_background_no_motion.py index 12ed71a..585937b 100644 --- a/tests/e2e/test_background_no_motion.py +++ b/tests/e2e/test_background_no_motion.py @@ -1,119 +1,77 @@ -"""Phase 25 E2E (Playwright): the background no longer moves — it only fades. +"""Phase 78 E2E (Playwright): the background is fully static. -Story: ``.agents/user_stories/background-no-motion.md`` (supersedes -``background-animation.md``) +Source: ``TODO.md`` L4 — owner direction: "Remove the animated css +background, it's too resource intensive" (supersedes the phase-25 +fading-glow contract of the ``background-no-motion`` story; the +superseded chain is 08 → 25 → 78). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov -Owner report (2026-08-25, chat): the phase-22 background "jitters down -and to the right every second and it slowly blinks brighter and darker. -It should be smooth, fluxuating, dimming and brightening, but not -moving. Different bright spots should slowly fade in and out." +The three opacity-fading glow spots (``body::after`` / ``html::before`` +/ ``html::after``), their glow keyframes, and the +``prefers-reduced-motion`` rule that stilled them are deleted from +``styles.css`` — the infinite CSS animations ran continuously on every +page, in every tab. The 44px grid texture (``body::before``) STAYS: it +is static (zero animation cost). All other UI animations (typing dots, +spinner, toasts, nav slide, …) are untouched. -The two root causes (phase-22 measurements in -``.agents/reports/22_background_animation/``): - - the "jitter" was the grid's 44px/60s drift (0.73px/s, diagonally - down-right) — a 1px line translated sub-pixel by sub-pixel - rasterizes with per-frame stepping, not smooth motion; - - the "blink" was the whole-layer 14s opacity 0.85↔1 + scale(1)↔ - scale(1.05) pulse — one synchronized pulse reads as blinking. - -The fix (styles.css, pure CSS, zero JS — A11): the grid (``body::before``) -is a STATIC texture (no animation, ``bg-grid-drift`` deleted), and three -independent soft glow spots each run their own SLOW opacity-only fade — -``body::after`` (phase-08 indigo) runs ``bg-glow-a`` 26s, -``html::before`` (phase-08 cyan) runs ``bg-glow-b`` 34s with −12s delay, -``html::after`` (a third indigo) runs ``bg-glow-c`` 42s with −23s delay. -The out-of-phase 26/34/42s cycles (LCM 4641s) make the total light -fluxuate smoothly and irregularly — no blink, no jitter, no movement. - -This suite proves the *behavior* the unit source pins only describe, in -a real Chromium viewport: the grid is static, the three spots run -distinct opacity-only fades whose timelines advance and whose light -measurably changes, no ``bg-*`` keyframe animates anything but -``opacity`` (the deterministic no-movement proof), all four layers keep -the fixed/z-index −1/pointer-events-none/no-occlusion contract, -reduced motion stills all four, and there is no 360px overflow. +This suite proves the *behavior* the unit source pins +(``tests/unit/test_background_no_motion.py``) only describe, in a real +Chromium viewport: the three glow pseudo-elements report no +background-image, ``animation-name: none``, and no box at all (the +rules are gone), no ``bg-*`` keyframes or running ``bg-*`` animations +exist anywhere, the grid is still painted and static, the +no-occlusion canvas contract survives (``<html>`` owns ``var(--bg)``), +reduced motion changes nothing (already static), and there is no +360px overflow. Test → story mapping (Playwright Mapping Rule): -1. ``test_grid_layer_is_static`` — computed ``animationName`` of - ``body::before`` is ``"none"``; no ``bg-grid-drift`` entry in - ``document.getAnimations()``; the grid ``backgroundImage`` is still - present (the static texture survives). -2. ``test_three_glow_layers_run_distinct_fades`` — ``body::after`` → - ``bg-glow-a`` (26s), ``documentElement::before`` → ``bg-glow-b`` - (34s), ``documentElement::after`` → ``bg-glow-c`` (42s); each - ``ease-in-out`` + ``infinite`` with a matching - ``playState === "running"`` entry in the document animation list; - the three durations are pairwise distinct. -3. ``test_no_motion_properties_in_background_keyframes`` — walks - ``document.styleSheets``; across every frame of every ``bg-*`` - ``KEYFRAMES_RULE`` the set of declared property names is exactly - ``{"opacity"}`` — no ``transform``/``background-position`` anywhere - (the deterministic no-movement proof). -4. ``test_glow_timelines_advance`` — poll until all three timelines - report ``currentTime > 0`` (headless Chromium starts the document - timeline ~1s after load), sample, wait ~500ms, each advanced - ≥ 200ms. -5. ``test_background_light_actually_changes`` — (a) the computed - ``opacity`` of ``body::after`` changes by ≥ 0.05 within ~8s (a real - fade, not a frozen frame); (b) two clipped screenshots ~4s apart of - the bottom-left glow region (the ``html::after`` spot at 14%/86%) - differ in bytes — the light visibly changes while nothing moves - (a fresh ``/`` page has no other animation, so the diff is the - background's). -6. ``test_background_layers_contracts`` — all four pseudo-layers: - ``position: fixed``, ``z-index: -1``, ``pointer-events: none``, - top/right/bottom/left all ``0px``; the ``documentElement`` computed - background is ``rgb(15, 10, 10)`` (``var(--bg)`` — the canvas stays - on ``html``); ``document.body`` computed background is - ``rgba(0, 0, 0, 0)`` (no occlusion). -7. ``test_reduced_motion_stills_all_layers`` — - ``reduced_motion="reduce"`` context: all four pseudo-layers report - computed ``animationName`` ``"none"`` and still carry a - ``backgroundImage`` (the static background remains visible). -8. ``test_no_horizontal_overflow_with_layers`` — 360px viewport: - ``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin). - -Chromium notes (verified on Chromium 151, kept from the phase-22 suite): -pseudo-element CSS animations are enumerated by -``document.getAnimations()``, NOT by -``document.body.getAnimations()`` (the element-level list is empty for -pseudo-layers), so the running/advancing checks match on -``animationName`` in the document-level list. And the computed styles -of the two new ``html`` pseudo-layers come from -``getComputedStyle(document.documentElement, "::before")`` / -``("::after")`` — ``document.body`` only carries the two -``body`` pseudo-layers. +1. ``test_background_layers_computed_styles`` — computed styles: + ``body::before`` keeps the grid (``backgroundImage`` non-empty, + ``animationName: none``); ``body::after`` / ``html::before`` / + ``html::after`` report ``backgroundImage: none`` + + ``animationName: none`` + ``position: static`` + ``content: none`` + (the pseudo-elements have no box — the rules are deleted, not just + stilled); the page canvas stays on ``<html>`` (``rgb(15, 10, 10)`` = + ``var(--bg)``) and ``<body>`` stays transparent (no occlusion). +2. ``test_no_background_keyframes_or_animations`` — deterministic + static proof: no ``@keyframes`` rule with a ``bg-`` name in any + live stylesheet, and no running ``bg-*`` entry in + ``document.getAnimations()`` (pseudo-element CSS animations are + enumerated by the document-level list, not the element-level one — + verified on Chromium 151). +3. ``test_grid_layer_is_fixed_behind_content`` — the surviving grid + layer keeps the UI-structure contract: ``position: fixed``, + ``z-index: -1``, ``pointer-events: none``, ``inset: 0`` (behind + content, click-through, full-viewport). +4. ``test_reduced_motion_background_static`` — a + ``reduced_motion="reduce"`` context: the grid is still painted and + static, the deleted glow layers still report no image/animation — + nothing is resurrected under reduced motion. +5. ``test_no_horizontal_overflow_with_layers`` — 360px viewport: + ``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin — + the background adds no width). """ from __future__ import annotations -import time +from playwright.sync_api import Browser, Page -from playwright.sync_api import Browser, FloatRect, Page - -GLOW_A = "bg-glow-a" # body::after — phase-08 indigo spot, 26s -GLOW_B = "bg-glow-b" # html::before — phase-08 cyan spot, 34s, -12s delay -GLOW_C = "bg-glow-c" # html::after — third indigo spot, 42s, -23s delay -GLOWS = (GLOW_A, GLOW_B, GLOW_C) # the three spot keyframe names -EASE_IN_OUT = "ease-in-out" # computed animationTimingFunction for ease-in-out PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a) -# Computed styles of all four pseudo-layers + the html/body background -# contract (single evaluate — one round-trip per test). +# Computed styles of the surviving grid layer + the three deleted glow +# pseudo-layers + the html/body background contract (single evaluate — +# one round-trip per test). JS_LAYER_REPORT = """() => { const pick = (el, pseudo) => { const cs = getComputedStyle(el, pseudo); return { anim: cs.animationName, - duration: cs.animationDuration, - timing: cs.animationTimingFunction, - iterations: cs.animationIterationCount, position: cs.position, zIndex: cs.zIndex, pointerEvents: cs.pointerEvents, + content: cs.content, edges: [cs.top, cs.right, cs.bottom, cs.left], image: cs.backgroundImage, }; @@ -128,24 +86,12 @@ JS_LAYER_REPORT = """() => { }; }""" -# The three background-layer animations from the Web Animations API -# ({name, playState, currentTime}); the keyframe names are passed as one -# array argument (Playwright serializes the Python list to a JS array). -JS_TIMELINE = """(names) => document.getAnimations() - .filter((a) => names.includes(a.animationName)) - .map((a) => ({ - name: a.animationName, - playState: a.playState, - t: a.currentTime, - }))""" - -# Deterministic no-movement audit: walk every same-origin stylesheet and -# collect, for each @keyframes bg-* rule, the property names declared in -# every keyframe frame. Returns {names: [...], props: [...]} — props must -# be exactly ["opacity"]. -JS_KEYFRAME_PROPS = """() => { - const names = []; - const props = new Set(); +# Deterministic static audit: walk every same-origin stylesheet and +# collect the names of @keyframes rules starting with "bg-" (must be +# empty), plus the animationNames running in document.getAnimations() +# that start with "bg-" (must be empty). +JS_BG_ANIMATION_AUDIT = """() => { + const keyframeNames = []; for (const sheet of document.styleSheets) { let rules; try { @@ -155,236 +101,114 @@ JS_KEYFRAME_PROPS = """() => { } for (const rule of rules) { if (rule.type === CSSRule.KEYFRAMES_RULE && rule.name.startsWith("bg-")) { - names.push(rule.name); - for (const frame of rule.cssRules) { - for (const p of frame.style) { - props.add(p); - } - } + keyframeNames.push(rule.name); } } } - return { names, props: [...props] }; + const running = document.getAnimations() + .map((a) => a.animationName) + .filter((n) => n && n.startsWith("bg-")); + return { keyframeNames, running }; }""" -def _seconds(value: str) -> float: - """Chromium reports animation durations as "26s" — parse as seconds.""" - return float(str(value).replace("s", "")) - - -def _timeline(page: Page) -> dict[str, float]: - """animationName → currentTime (ms) for the three glow layers.""" - entries = page.evaluate(JS_TIMELINE, list(GLOWS)) - return {str(a["name"]): float(a["t"]) for a in entries} - - -def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None: - """Poll until all three glow timelines report currentTime > 0. - - Headless Chromium starts the document animation timeline shortly - after load (observed ≈1s after navigation) — until then currentTime - is 0, so the "did it advance?" sample in - ``test_glow_timelines_advance`` must start once the timeline is - alive. - """ - deadline = time.monotonic() + timeout_ms / 1000 - while time.monotonic() < deadline: - times = _timeline(page) - if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS): - return - page.wait_for_timeout(100) - raise AssertionError( - f"glow animation timelines never started (saw {_timeline(page)!r})" - ) - - # -------------------------------------------------------------------------- # Tests (story → test mapping, see module docstring) # -------------------------------------------------------------------------- -def test_grid_layer_is_static(page: Page, app_url: str, db_ready: None) -> None: - """AC1: no movement — the grid layer (body::before) runs NO animation - in a real viewport (the phase-22 0.73px/s drift read as a - once-per-second down-right jitter), and its static texture is still - painted (the grid stays — the owner rejected its motion, not the - grid).""" +def test_background_layers_computed_styles( + page: Page, app_url: str, db_ready: None +) -> None: + """AC1: the animated background no longer exists in a real viewport — + the three glow pseudo-elements report no background-image and + ``animation-name: none``, and they have no box at all + (``position: static`` / ``content: none`` — the CSS rules are + deleted, not merely stilled). The static grid (``body::before``) + stays: its texture is still painted and it carries no animation. + The no-occlusion contract survives: the canvas is on ``<html>``, + ``<body>`` transparent.""" page.goto(app_url) report = page.evaluate(JS_LAYER_REPORT) + grid = report["grid"] - assert grid["anim"] == "none", ( - f"body::before must be static (animationName, got {grid['anim']!r})" - ) - live = page.evaluate(JS_TIMELINE, ["bg-grid-drift"]) - assert not live, ( - f"no bg-grid-drift animation may exist (got {live!r}) — the drift is deleted" - ) assert grid["image"] != "none", ( f"the static grid texture must still be painted (image {grid['image']!r})" ) - - -def test_three_glow_layers_run_distinct_fades( - page: Page, app_url: str, db_ready: None -) -> None: - """AC2: three different bright spots, each on its own slow fade — - body::after runs bg-glow-a (26s), the two html pseudo-layers run - bg-glow-b (34s) and bg-glow-c (42s); every one is ease-in-out, - infinite, reported ``running`` in the document animation list, and - the three durations are pairwise distinct (out of phase).""" - page.goto(app_url) - report = page.evaluate(JS_LAYER_REPORT) - recipe = ( - ("glowA", GLOW_A, "26s"), - ("glowB", GLOW_B, "34s"), - ("glowC", GLOW_C, "42s"), + assert grid["anim"] == "none", ( + f"body::before must stay static (animationName, got {grid['anim']!r})" ) - durations: list[float] = [] - for key, name, expected in recipe: + + for key in ("glowA", "glowB", "glowC"): info = report[key] - assert info["anim"] == name, ( - f"{key} must run {name} (got {info['anim']!r})" + assert info["image"] == "none", ( + f"{key}: the deleted glow layer must carry no background-image " + f"(got {info['image']!r})" ) - assert info["duration"] == expected, ( - f"{key} must run a {expected} cycle (got {info['duration']!r})" + assert info["anim"] == "none", ( + f"{key}: the deleted glow layer must not animate " + f"(got {info['anim']!r})" ) - assert info["timing"] == EASE_IN_OUT, ( - f"{key} must ease in-out (got {info['timing']!r})" - ) - assert info["iterations"] == "infinite", ( - f"{key} must fade forever (got {info['iterations']!r})" - ) - durations.append(_seconds(info["duration"])) - assert len(set(durations)) == 3, ( - f"the three spot cycles must be out of phase (got {durations})" - ) - live = page.evaluate(JS_TIMELINE, list(GLOWS)) - for name in GLOWS: - match = [a for a in live if a["name"] == name] - assert match, f"no {name} entry in document.getAnimations() — spot not fading" - assert match[0]["playState"] == "running", ( - f"{name} is {match[0]['playState']!r} — the spot fade must be running" + assert info["content"] == "none" and info["position"] == "static", ( + f"{key}: the glow pseudo-element must have no box " + f"(content {info['content']!r}, position {info['position']!r})" ) - -def test_no_motion_properties_in_background_keyframes( - page: Page, app_url: str, db_ready: None -) -> None: - """AC1: the deterministic no-movement proof — walk the live stylesheets - in Chromium: across every frame of every ``bg-*`` @keyframes rule the - set of declared properties is exactly {opacity}. No transform, no - background-position, nothing that can move a pixel.""" - page.goto(app_url) - audit = page.evaluate(JS_KEYFRAME_PROPS) - names = set(str(n) for n in audit["names"]) - assert names == {GLOW_A, GLOW_B, GLOW_C}, ( - f"expected exactly the three spot keyframes {sorted({GLOW_A, GLOW_B, GLOW_C})}, " - f"found {sorted(names)}" - ) - props = {str(p) for p in audit["props"]} - assert props == {"opacity"}, ( - f"bg-* keyframes may only animate opacity (found {sorted(props)}) — " - "the background must not move" - ) - - -def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None: - """AC2: all three spot timelines actually advance — the background is - a live animation, not a static (or paused) frame. Sample - currentTime, wait ~500ms, and require real progress on all three - layers (the document animation timeline in headless Chromium starts - ~1s after load, so poll until it is alive first).""" - page.goto(app_url) - _wait_timelines_alive(page) - before = _timeline(page) - page.wait_for_timeout(500) - after = _timeline(page) - for name in GLOWS: - delta = after[name] - before[name] - assert delta >= 200, ( - f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) " - "— paused or static?" - ) - - -def test_background_light_actually_changes( - page: Page, app_url: str, db_ready: None -) -> None: - """AC2/AC3: the light fluxuates — (a) the computed opacity of - body::after measurably changes within a few seconds (a real fade, - not a frozen frame), and (b) a clipped screenshot of the bottom-left - glow region (the html::after spot at 14%/86% of the 1280×800 - viewport) differs in bytes ~4s later — the light visibly changes - while nothing moves (a fresh / page runs no other animation, so the - pixel diff is the background's).""" - page.goto(app_url) - - # (a) computed opacity of the indigo spot fades by >= 0.05 within ~8s. - def _spot_opacity() -> float: - return float( - page.evaluate("() => getComputedStyle(document.body, '::after').opacity") - ) - - t0 = _spot_opacity() - deadline = time.monotonic() + 8.0 - delta = 0.0 - while time.monotonic() < deadline: - delta = abs(_spot_opacity() - t0) - if delta >= 0.05: - break - page.wait_for_timeout(100) - assert delta >= 0.05, ( - f"body::after opacity did not fade (Δ={delta:.3f} < 0.05 over 8s) — " - "frozen frame?" - ) - - # (b) the rendered glow region changes over ~4s (bottom-left spot at - # 14%/86% ≈ (179px, 688px) in the 1280×800 viewport). - clip: FloatRect = {"x": 0, "y": 500, "width": 500, "height": 300} - shot_1 = page.screenshot(clip=clip) - page.wait_for_timeout(4000) - shot_2 = page.screenshot(clip=clip) - assert shot_1 != shot_2, ( - "the clipped bottom-left glow region is byte-identical 4s apart — " - "the light must visibly change even though nothing moves" - ) - - -def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None: - """AC4: UI Structure Check — all four background pseudo-layers (body - ::before/::after + the two new html ::before/::after spots) stay - behind content (fixed, z-index -1, pointer-events none, full-viewport) - and nothing occludes them: the page canvas stays on <html>, <body> - stays transparent.""" - page.goto(app_url) - report = page.evaluate(JS_LAYER_REPORT) - for key in ("grid", "glowA", "glowB", "glowC"): - info = report[key] - assert info["position"] == "fixed", f"{key} must stay position:fixed" - assert info["zIndex"] == "-1", ( - f"{key} must stay behind content (z-index -1, got {info['zIndex']!r})" - ) - assert info["pointerEvents"] == "none", ( - f"{key} must stay click-through (pointer-events none)" - ) - assert info["edges"] == ["0px", "0px", "0px", "0px"], ( - f"{key} must stay full-viewport (inset: 0, got {info['edges']!r})" - ) assert report["htmlBg"] == PAGE_BG, ( f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})" ) assert report["bodyBg"] == "rgba(0, 0, 0, 0)", ( - f"body must stay transparent so the layers show (got {report['bodyBg']!r})" + f"body must stay transparent so the grid shows (got {report['bodyBg']!r})" ) -def test_reduced_motion_stills_all_layers( +def test_no_background_keyframes_or_animations( + page: Page, app_url: str, db_ready: None +) -> None: + """AC1: the deterministic static proof — no ``@keyframes`` rule in + the background keyframe namespace (the ``bg-`` prefix) exists in any + live stylesheet, and no background-namespaced animation is running + in ``document.getAnimations()`` (the owner removed the infinite CSS + animations entirely — they ran on every page, in every tab).""" + page.goto(app_url) + audit = page.evaluate(JS_BG_ANIMATION_AUDIT) + assert not audit["keyframeNames"], ( + f"no bg-* @keyframes may remain (found {audit['keyframeNames']!r})" + ) + assert not audit["running"], ( + f"no bg-* animation may be running (found {audit['running']!r})" + ) + + +def test_grid_layer_is_fixed_behind_content( + page: Page, app_url: str, db_ready: None +) -> None: + """AC2: UI Structure Check — the surviving grid layer stays behind + the content and can never intercept input: ``position: fixed``, + ``z-index: -1``, ``pointer-events: none``, full-viewport + (``inset: 0``).""" + page.goto(app_url) + grid = page.evaluate(JS_LAYER_REPORT)["grid"] + assert grid["position"] == "fixed", "body::before must stay position:fixed" + assert grid["zIndex"] == "-1", ( + f"the grid must stay behind content (z-index -1, got {grid['zIndex']!r})" + ) + assert grid["pointerEvents"] == "none", ( + "the grid must stay click-through (pointer-events none)" + ) + assert grid["edges"] == ["0px", "0px", "0px", "0px"], ( + f"the grid must stay full-viewport (inset: 0, got {grid['edges']!r})" + ) + + +def test_reduced_motion_background_static( browser: Browser, app_url: str, db_ready: None ) -> None: - """AC5: with prefers-reduced-motion: reduce ALL FOUR pseudo-layers - stop animating (animation-name: none) — the static grid + spot - images remain visible.""" + """AC3: with ``prefers-reduced-motion: reduce`` the background is + already fully static — the grid is still painted and reports + ``animation-name: none``, and the deleted glow layers still report + no image/animation (nothing is resurrected; the reduced-motion + blocks that survive in styles.css cover UI animations only).""" context = browser.new_context( reduced_motion="reduce", viewport={"width": 1280, "height": 800} ) @@ -392,13 +216,23 @@ def test_reduced_motion_stills_all_layers( rpage = context.new_page() rpage.goto(app_url) report = rpage.evaluate(JS_LAYER_REPORT) - for key in ("grid", "glowA", "glowB", "glowC"): + grid = report["grid"] + assert grid["image"] != "none", ( + f"the static grid must remain visible under reduced motion " + f"(image {grid['image']!r})" + ) + assert grid["anim"] == "none", ( + f"the grid must stay static under reduced motion (got {grid['anim']!r})" + ) + for key in ("glowA", "glowB", "glowC"): info = report[key] - assert info["anim"] == "none", ( - f"{key} must not animate under reduced motion (got {info['anim']!r})" + assert info["image"] == "none", ( + f"{key}: no glow background may exist under reduced motion " + f"(got {info['image']!r})" ) - assert info["image"] != "none", ( - f"{key}: the static background image must remain visible" + assert info["anim"] == "none", ( + f"{key}: no glow animation may exist under reduced motion " + f"(got {info['anim']!r})" ) finally: context.close() @@ -407,9 +241,9 @@ def test_reduced_motion_stills_all_layers( def test_no_horizontal_overflow_with_layers( browser: Browser, app_url: str, db_ready: None ) -> None: - """AC6: the four background layers add no width — the phase-07 - overflow pin (documentElement.scrollWidth <= clientWidth) still holds - at the 360px floor with all four fixed; inset: 0 layers live.""" + """AC4: the background adds no width — the phase-07 overflow pin + (``documentElement.scrollWidth <= clientWidth``) holds at the 360px + floor with the single fixed; inset: 0 grid layer.""" phone = browser.new_page(viewport={"width": 360, "height": 740}) try: phone.goto(f"{app_url}/") diff --git a/tests/e2e/test_bottom_chat_actions.py b/tests/e2e/test_bottom_chat_actions.py index 29bcc74..9b601ea 100644 --- a/tests/e2e/test_bottom_chat_actions.py +++ b/tests/e2e/test_bottom_chat_actions.py @@ -70,6 +70,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -347,7 +348,7 @@ def test_bottom_cluster_pinned_at_every_scroll_position( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") build_conversation(page, n=len(SHORT_QUESTIONS)) @@ -434,9 +435,11 @@ def test_row_geometry_and_alignment( ) -> None: page.set_default_timeout(30_000) - # No conversation needed for the row — static markup, always present - # (the page fixture's viewport is the 1280×800 desktop). - page.goto(app_url) + # No conversation needed for the row's GEOMETRY — static markup, + # always present (the page fixture's viewport is the 1280×800 + # desktop). Phase 79: the touch-target section below seeds ONE + # turn, so the visitor signs in first (chat is require_user). + login(page, app_url, next="/") # Exactly ONE .chat-actions, inside the pinned unit, holding exactly # the two pills — and the A5 DOM order: New chat, then Share (the @@ -546,7 +549,7 @@ def test_buttons_still_work_from_the_bottom( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # One real conversation: the buttons have something to act on (and # the auto-save path fires — the row is cleaned up by _reset_db). diff --git a/tests/e2e/test_cache_busting.py b/tests/e2e/test_cache_busting.py index 6a9afb6..df06cee 100644 --- a/tests/e2e/test_cache_busting.py +++ b/tests/e2e/test_cache_busting.py @@ -23,7 +23,7 @@ from typing import Any import httpx from playwright.sync_api import Page -from e2e.auth_helpers import login +from e2e.auth_helpers import ADMIN_PASSWORD, login REPO = Path(__file__).resolve().parents[2] CHAT_QUESTION = "How is my Kubernetes cluster set up?" @@ -60,9 +60,17 @@ def _admin_cookies(page: Page) -> dict[str, str]: def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]: """Minimal SSE chat request (same pattern as ``test_chat_rag.py``): - POST /api/chat and collect the ``data:`` frames until the stream ends.""" + POST /api/chat and collect the ``data:`` frames until the stream ends. + + Phase 79: POST /api/chat is require_user-gated — the httpx client + signs in as the admin first (the middleware under test never touches + the auth contract; this is purely the request's credentials).""" + client = httpx.Client(timeout=60.0) + r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204 + frames: list[dict[str, Any]] = [] - with httpx.stream( + with client.stream( "POST", f"{app_url}/api/chat", json={"message": message}, timeout=60.0 ) as r: assert r.status_code == 200 diff --git a/tests/e2e/test_chat_persistence.py b/tests/e2e/test_chat_persistence.py index b2e8696..ec8f58b 100644 --- a/tests/e2e/test_chat_persistence.py +++ b/tests/e2e/test_chat_persistence.py @@ -130,7 +130,7 @@ def test_conversation_survives_reload( summary = _reset_db(mock_llm, seed=True) assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _ask(page, QUESTION) # The turn is persisted: versioned payload, RAW text (no HTML), and the @@ -179,7 +179,7 @@ def test_deflected_turn_restores_styling( ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _ask_deflected(page, OFF_TOPIC) # The deflected metadata (suggestions) is persisted with the answer. @@ -226,7 +226,7 @@ def test_new_chat_clears_conversation( ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _ask(page, QUESTION) expect(page.locator(".msg")).to_have_count(2) assert _stored(page) is not None @@ -267,18 +267,19 @@ def test_persists_across_page_navigation( ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _ask(page, QUESTION) _ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected - # A trip to Sources (phase 16: the catalog is admin-only — the trip - # starts with a real form login). Phase 76 (task 02): the shell + # A trip to Sources (phase 16: the catalog is admin-only — the + # test's form login above already holds the session, so the trip is + # a plain navigation). Phase 76 (task 02): the shell # carries the chat view (with its New chat button) in the DOM on # EVERY view — hidden + inert — so the button EXISTS here but must # be HIDDEN (the view-scoped absence pattern; it left the shared # bar at owner request, 2026-08-28 — pinned in # tests/e2e/test_shared_header.py). - login(page, app_url, next="/sources.html") + page.goto(f"{app_url}/sources.html") expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000) expect(page.locator("#new-chat-btn")).to_be_hidden() diff --git a/tests/e2e/test_chat_rag.py b/tests/e2e/test_chat_rag.py index f617e0e..3328719 100644 --- a/tests/e2e/test_chat_rag.py +++ b/tests/e2e/test_chat_rag.py @@ -27,6 +27,7 @@ from app.db import SessionLocal from app.models import QueryLog from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import ADMIN_PASSWORD, login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -78,7 +79,7 @@ def test_on_topic_question_streams_grounded_answer( summary = _reset_db(mock_llm, seed=True) assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # Healthy KB: the offline banner must stay hidden. expect(page.locator("#kb-banner")).to_be_hidden() @@ -117,7 +118,7 @@ def test_on_topic_question_streams_grounded_answer( def test_chat_logs_query(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", QUESTION) page.click("#send-btn") expect( @@ -147,8 +148,15 @@ def test_sse_stream_shape(app_url: str, mock_llm: int, db_ready: None) -> None: """Raw transport contract (PLAN §4): delta events, then one done.""" _reset_db(mock_llm, seed=True) + # Phase 79: POST /api/chat is require_user-gated — the httpx client + # signs in as the admin first (the form login's API side: 204 + the + # signed session cookie in the jar). + client = httpx.Client(timeout=60.0) + r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204 + frames: list[dict[str, Any]] = [] - with httpx.stream( + with client.stream( "POST", f"{app_url}/api/chat", json={"message": QUESTION}, timeout=60.0 ) as r: assert r.status_code == 200 diff --git a/tests/e2e/test_configurable_brand.py b/tests/e2e/test_configurable_brand.py index f7b05a3..b4c8746 100644 --- a/tests/e2e/test_configurable_brand.py +++ b/tests/e2e/test_configurable_brand.py @@ -52,6 +52,7 @@ import httpx import pytest from playwright.sync_api import Page, expect +from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, APP_PORT, @@ -215,7 +216,7 @@ def test_other_pages_titles_carry_the_name(page: Page, testy_server: str) -> Non def test_chat_status_label_uses_custom_name_pre_token( page: Page, testy_server: str, db_ready: None ) -> None: - page.goto(testy_server + "/") + login(page, testy_server, next="/") expect(page.locator("#send-btn")).to_be_enabled(timeout=15_000) # The ``think out loud`` marker (mock_llm.py) makes the answer diff --git a/tests/e2e/test_dark_tech_theme.py b/tests/e2e/test_dark_tech_theme.py index f31e308..0befb6a 100644 --- a/tests/e2e/test_dark_tech_theme.py +++ b/tests/e2e/test_dark_tech_theme.py @@ -1,4 +1,4 @@ -"""Phase 08 E2E (Playwright): dark tech theme — palette, no emoji, animated +"""Phase 08 E2E (Playwright): dark tech theme — palette, no emoji, static background, reduced motion, behavior intact, all assets local. Story: ``.agents/user_stories/dark-tech-theme.md`` @@ -13,14 +13,17 @@ Test → story mapping (Playwright Mapping Rule): (same contrast helper as Phase 07). 2. ``test_no_emoji_in_chrome`` — neither page's ``innerText`` nor raw ``outerHTML`` contains any emoji code point. -3. ``test_animated_background`` — the background layers (fixed, - pointer-events none, carrying images): the grid (``body::before``) - is STATIC (phase 25, owner 2026-08-25: no movement) and the three - glow spots run their opacity-only fades ``bg-glow-a/b/c`` at - 26s/34s/42s (``body::after`` + the two ``html`` pseudo-layers). +3. ``test_static_background`` — the background is fully STATIC (phase + 78, owner direction: the animated glow layers were removed as too + resource-intensive): the grid (``body::before``) is a static + texture (fixed, pointer-events none, image still painted, + ``animationName: none``) and the three glow pseudo-layers + (``body::after`` + the two ``html`` pseudo-layers) are deleted — + no background-image, no animation, no box. 4. ``test_reduced_motion_honored`` — a context with - ``reduced_motion="reduce"`` → ``animation-name: none`` on all four - layers (the static grid + spot images remain). + ``reduced_motion="reduce"`` → the grid stays painted and static + (``animation-name: none``) and the deleted glow pseudo-layers carry + no image or animation. 5. ``test_behavior_unchanged_smoke`` — on-topic question streams an answer + a source chip + the send button recovers (state machine intact under the new skin). @@ -36,7 +39,6 @@ from pathlib import Path from threading import Thread from typing import Any -import pytest from playwright.sync_api import Browser, Page, expect from sqlalchemy import text @@ -156,11 +158,6 @@ def _assert_aa(pair: Any, label: str) -> None: assert ratio >= 4.5, f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)" -def _seconds(value: str) -> float: - """Chromium reports animation durations as "60s" — parse as seconds.""" - return float(str(value).replace("s", "")) - - # -------------------------------------------------------------------------- # Tests (story → test mapping, see module docstring) # -------------------------------------------------------------------------- @@ -176,8 +173,8 @@ def test_dark_palette_and_contrast( # The visible page background is the <html> canvas (rgb(15, 10, 10) # = #0f0a0a — the 2026-08-28 dark-red rebrand; it was the phase-08 # #0a0e17 canvas). <body> itself stays transparent so the - # z-index:-1 grid/glow layers (test_animated_background) are not - # painted over. + # z-index:-1 grid layer (test_static_background) is not painted + # over. bg = page.evaluate( "() => getComputedStyle(document.documentElement).backgroundColor" ) @@ -187,8 +184,10 @@ def test_dark_palette_and_contrast( f"{path}: body must stay transparent (the background layers need to show)" ) - # Chat page pairs. - page.goto(f"{app_url}/") + # Chat page pairs. Phase 79: the onboarding chips are drawn from + # /api/suggestions (require_user-gated) — the visitor signs in + # first; the palette pins are auth-independent. + login(page, app_url, next="/") page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000) pairs = page.evaluate( """() => { @@ -260,16 +259,15 @@ def test_no_emoji_in_chrome(page: Page, app_url: str, db_ready: None) -> None: assert not hits, f"emoji in {label} on {path}: {sorted(named)}" -def test_animated_background(page: Page, app_url: str, db_ready: None) -> None: - """AC3: the background is subtly animated, pure CSS, zero JS, and can - never block or dim content. Phase-25 contract (owner 2026-08-25: - "not moving. Different bright spots should slowly fade in and out") - supersedes the phase-08 recipe: the grid (``body::before``) is a - STATIC texture (``animationName: none``, image still painted) and - the three glow spots each run their own opacity-only fade — - ``body::after`` → ``bg-glow-a`` 26s, ``html::before`` → - ``bg-glow-b`` 34s, ``html::after`` → ``bg-glow-c`` 42s — all fixed, - pointer-events:none, carrying an image.""" +def test_static_background(page: Page, app_url: str, db_ready: None) -> None: + """AC3: the background is fully static, pure CSS, zero JS, and can + never block or dim content. Phase-78 contract (owner direction, + TODO.md L4: the animated background was removed as too + resource-intensive) supersedes the phase-25 recipe: the grid + (``body::before``) is a STATIC texture (``animationName: none``, + image still painted, fixed, pointer-events:none), and the three + glow spots (``body::after`` + the two ``html`` pseudo-layers) are + DELETED — no background-image, no animation, no box.""" page.goto(app_url) report = page.evaluate( """() => { @@ -278,7 +276,6 @@ def test_animated_background(page: Page, app_url: str, db_ready: None) -> None: return { image: cs.backgroundImage, anim: cs.animationName, - duration: cs.animationDuration, position: cs.position, pointerEvents: cs.pointerEvents, }; @@ -291,34 +288,32 @@ def test_animated_background(page: Page, app_url: str, db_ready: None) -> None: }; }""" ) - for key in ("grid", "glowA", "glowB", "glowC"): - info = report[key] - assert info["image"] != "none", f"{key} must carry a background image" - assert info["position"] == "fixed", f"{key} must be position:fixed" - assert info["pointerEvents"] == "none", f"{key} must not intercept input" - # The phase-25 recipe: a static grid + three out-of-phase spot fades. - assert report["grid"]["anim"] == "none", ( - f"the grid must be static (got {report['grid']['anim']!r})" + grid = report["grid"] + assert grid["image"] != "none", ( + "the static grid must carry a background image" ) - for key, name, seconds in ( - ("glowA", "bg-glow-a", 26.0), - ("glowB", "bg-glow-b", 34.0), - ("glowC", "bg-glow-c", 42.0), - ): + assert grid["anim"] == "none", f"the grid must be static (got {grid['anim']!r})" + assert grid["position"] == "fixed", "the grid must be position:fixed" + assert grid["pointerEvents"] == "none", "the grid must not intercept input" + # The phase-78 recipe: the animated glow layers are gone entirely. + for key in ("glowA", "glowB", "glowC"): info = report[key] - assert info["anim"] == name, f"{key} must run {name} (got {info['anim']!r})" - assert _seconds(info["duration"]) == pytest.approx(seconds), ( - f"{key} must run its {seconds:.0f}s fade (got {info['duration']!r})" + assert info["image"] == "none", ( + f"{key}: the deleted glow layer must carry no background image " + f"(got {info['image']!r})" + ) + assert info["anim"] == "none", ( + f"{key}: the deleted glow layer must not animate (got {info['anim']!r})" ) def test_reduced_motion_honored( browser: Browser, app_url: str, db_ready: None ) -> None: - """AC4: with prefers-reduced-motion: reduce ALL FOUR background - layers stop animating (animation-name: none) — the static grid + - spot images remain (phase 25 stills the two html pseudo-layers as - well as the body pair).""" + """AC4: with prefers-reduced-motion: reduce the background is + already fully static (phase 78 deleted the animated glow layers) — + the grid stays painted and static (``animation-name: none``) and + the deleted glow pseudo-layers carry no image or animation.""" context = browser.new_context( reduced_motion="reduce", viewport={"width": 1280, "height": 800} ) @@ -339,12 +334,21 @@ def test_reduced_motion_honored( }; }""" ) - for key in ("grid", "glowA", "glowB", "glowC"): + grid = report["grid"] + assert grid["anim"] == "none", ( + f"the grid must stay static under reduced motion (got {grid['anim']!r})" + ) + assert grid["image"] != "none", ( + "grid: the static background image must remain visible" + ) + for key in ("glowA", "glowB", "glowC"): assert report[key]["anim"] == "none", ( - f"{key} must not animate under reduced motion (got {report[key]['anim']!r})" + f"{key}: no glow animation may exist under reduced motion " + f"(got {report[key]['anim']!r})" ) - assert report[key]["image"] != "none", ( - f"{key}: the static background image must remain visible" + assert report[key]["image"] == "none", ( + f"{key}: no glow image may exist under reduced motion " + f"(got {report[key]['image']!r})" ) finally: context.close() @@ -358,7 +362,7 @@ def test_behavior_unchanged_smoke( button recovers (never stale).""" _seed_kb(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated expect(page.locator("#kb-banner")).to_be_hidden() page.fill("#message-input", QUESTION) diff --git a/tests/e2e/test_document_back_navigation.py b/tests/e2e/test_document_back_navigation.py index 3c62e2b..a5a33dd 100644 --- a/tests/e2e/test_document_back_navigation.py +++ b/tests/e2e/test_document_back_navigation.py @@ -104,7 +104,7 @@ def test_back_from_chat_returns_to_chat( ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat AND the viewer content are gated page.fill("#message-input", QUESTION) page.click("#send-btn") @@ -187,6 +187,7 @@ def test_malicious_back_param_is_rejected( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) + login(page, app_url, next="/") # phase 79: the viewer content is gated errors: list[str] = [] dialogs: list[str] = [] page.on("pageerror", lambda e: errors.append(str(e))) diff --git a/tests/e2e/test_document_summaries.py b/tests/e2e/test_document_summaries.py index a7d5069..67324e0 100644 --- a/tests/e2e/test_document_summaries.py +++ b/tests/e2e/test_document_summaries.py @@ -48,6 +48,7 @@ from app.rag.chunker import chunk_document from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient from app.rag.retriever import RetrievedChunk, retrieve +from e2e.auth_helpers import login from tests.e2e.mock_llm import TOKEN_RE, embed_text REPO = Path(__file__).resolve().parents[2] @@ -116,7 +117,7 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None: def _ask(page: Page, app_url: str, question: str) -> Any: """Submit *question* and wait for the streamed brain bubble.""" page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", question) page.click("#send-btn") bubble = page.locator(".msg.brain .bubble") diff --git a/tests/e2e/test_document_viewer.py b/tests/e2e/test_document_viewer.py index 87a671b..a8bc453 100644 --- a/tests/e2e/test_document_viewer.py +++ b/tests/e2e/test_document_viewer.py @@ -92,7 +92,7 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: def _ask_for_chip(page: Page, app_url: str) -> Any: """Drive one chat turn and return the kubernetes.md source chip.""" - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated page.fill("#message-input", QUESTION) page.click("#send-btn") chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md") @@ -342,6 +342,10 @@ def test_standalone_page_still_works( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) + # Phase 79: the viewer content is gated — the signed-in session sees + # the phase-10 contract unchanged (the not-found cards below come + # from the 404 / missing-params paths, not the auth gate). + login(page, app_url, next="/") errors: list[str] = [] page.on("pageerror", lambda e: errors.append(str(e))) diff --git a/tests/e2e/test_edit_summaries.py b/tests/e2e/test_edit_summaries.py index 63ccd6e..6c7c48d 100644 --- a/tests/e2e/test_edit_summaries.py +++ b/tests/e2e/test_edit_summaries.py @@ -36,8 +36,9 @@ The admin edits through the REAL browser flow (form login → the viewer's Edit button → the inline editor → Save); the re-embed itself is then verified against the live database (the chunk's NEW content, a FRESH non-NULL vector, the content chunks byte-for-byte untouched — the -D4 re-embed scope) and against the public content endpoint (no cookie — -the viewer stays public, phase 16). +D4 re-embed scope) and against the content endpoint (phase 79 superseded the +phase-16 soft rule: the endpoint is require_user, so the pin runs +under the admin session — the shape is unchanged). """ from __future__ import annotations @@ -57,7 +58,7 @@ from app.db import SessionLocal from app.models import Chunk, Document from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient -from e2e.auth_helpers import login +from e2e.auth_helpers import ADMIN_PASSWORD, login from e2e.mock_llm import TOKEN_RE REPO = Path(__file__).resolve().parents[2] @@ -183,12 +184,16 @@ def _chunk_state() -> dict[str, Any]: def _api_summary(app_url: str) -> str | None: - """The public content endpoint's ``summary`` — NO cookie (the viewer - stays public, phase 16; a fresh httpx client carries no session).""" - r = httpx.get( + """The content endpoint's ``summary``. Phase 79: the endpoint is + require_user (the phase-16 soft rule is superseded) — the fresh + httpx client signs in as the admin first (every caller is an + admin-flow test).""" + client = httpx.Client(timeout=10) + r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204 + r = client.get( f"{app_url}/api/documents/content", params={"source": SOURCE, "path": DOC_PATH}, - timeout=10, ) assert r.status_code == 200, r.text return r.json()["summary"] @@ -334,27 +339,19 @@ def test_anonymous_cannot(page: Page, app_url: str) -> None: page.set_default_timeout(30_000) content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8") expected = _expected_summary(content, SOURCE, DOC_PATH) - digest_line, _ = expected.split("\n", 1) - # Fresh context (the function-scoped page fixture — no login): the - # panel renders the digest, but the edit affordance is ABSENT — no - # button, no header row, no editor wiring. The section keeps the - # phase-36 byte-for-byte shape: a bare h2 + the text-node <p>. + # Fresh context (the function-scoped page fixture — no login). + # Phase 79 (task 05): the content endpoint is require_user — the + # anonymous viewer meets the inline token gate (the content fetch + # never runs — no summary panel, no not-found card either), so the + # edit affordance is absent by construction. page.goto(_doc_url(app_url)) - panel = page.locator(".doc-summary") - expect(panel).to_have_count(1) - expect(panel).to_be_visible() - expect(panel).to_contain_text(digest_line) + expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000) + expect(page.locator("#doc-title")).to_have_text("Loading…") + expect(page.locator(".doc-summary")).to_have_count(0) expect(page.locator(".doc-summary-edit")).to_have_count(0) expect(page.locator(".doc-summary-head")).to_have_count(0) expect(page.locator(".doc-summary-editor")).to_have_count(0) - children = page.evaluate( - "() => [...document.querySelector('.doc-summary').children]" - ".map((el) => el.className)" - ) - assert children == ["doc-summary-title", "doc-summary-text"], ( - f"anonymous panel drifted from the phase-36 shape: {children}" - ) # The endpoint is admin-gated (D4): an anonymous PATCH → 403 # "admin only" (a fresh httpx client carries no session), and the diff --git a/tests/e2e/test_git_sources_admin.py b/tests/e2e/test_git_sources_admin.py index 30e4011..273f641 100644 --- a/tests/e2e/test_git_sources_admin.py +++ b/tests/e2e/test_git_sources_admin.py @@ -74,13 +74,18 @@ from app.models import GitSource from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, - APP_PORT, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module app binds its own port instead +# (a same-port second uvicorn dies on bind and would drive the wrong +# server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_GITADMIN", "8125")) APP_URL = f"http://127.0.0.1:{APP_PORT}" #: The five pages that ship the header (phase 34 contract) — the pages diff --git a/tests/e2e/test_global_tuning.py b/tests/e2e/test_global_tuning.py index 297207d..66a996e 100644 --- a/tests/e2e/test_global_tuning.py +++ b/tests/e2e/test_global_tuning.py @@ -516,6 +516,12 @@ def test_anonymous_cannot_manage( expect(page.locator("#nav-sources")).to_be_hidden() expect(page.locator("#nav-tuning")).to_be_hidden() + # Phase 79 (task 05): the anonymous visitor meets the token gate — + # the whole page (list AND create form) sits inert behind it, so + # "anonymous cannot manage" is enforced by the gate first. + expect(page.locator("#auth-gate")).to_be_visible(timeout=15_000) + assert page.evaluate("() => document.getElementById('main').inert") is True + # The list stays on its empty state even though a note exists… expect(page.locator("#tune-list .tuning-note")).to_have_count(0) expect(page.locator("#tune-empty")).to_be_visible() @@ -536,15 +542,40 @@ def test_anonymous_cannot_manage( row = db.get(SteeringNote, note_id) assert row is not None and row.note == SEED_NOTE, "the note must stay untouched" - # The create form 403s gracefully: an inline error (role=alert) with - # the API detail, and the typed instruction survives in the textarea. - page.fill("#tune-note", "an anonymous attempt") + # The gate is only the first lock: the create form 403s gracefully + # for a NON-ADMIN (token user) too — an inline error (role=alert) + # with the API detail, and the typed instruction survives in the + # textarea. Admin signs in via the header, mints a token, signs out + # again, then unlocks the page through the gate as that user. + login(page, app_url, next=TUNING_URL) + expect(page.locator("#nav-tuning")).to_be_visible(timeout=15_000) + token = page.evaluate( + """async () => { + const r = await fetch('/api/tokens', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({label: 'e2e-tuning-user'}), + }); + if (!r.ok) throw new Error('token create failed: ' + r.status); + return (await r.json()).token; + }""" + ) + page.click("#sign-out-btn") + page.wait_for_selector("#auth-gate:not([hidden])", timeout=15_000) + page.fill("#auth-gate-input", token) + page.click("#auth-gate-form button") + page.wait_for_selector("#auth-gate", state="hidden", timeout=15_000) + # The token user unlocked the page but is still NOT admin: the + # Tuning nav link stays hidden, the form is reachable only by + # direct URL — and the API refuses the create with 403. + expect(page.locator("#nav-tuning")).to_be_hidden() + page.fill("#tune-note", "a non-admin attempt") page.click("#tune-save") error = page.locator("#tune-form .tuning-error") expect(error).to_have_attribute("role", "alert") expect(error).to_be_visible(timeout=15_000) assert "admin only" in (error.inner_text() or "").lower() - expect(page.locator("#tune-note")).to_have_value("an anonymous attempt") + expect(page.locator("#tune-note")).to_have_value("a non-admin attempt") # Nothing was created: the DB still holds only the seeded note. with SessionLocal() as db: diff --git a/tests/e2e/test_grep_regex_teaching.py b/tests/e2e/test_grep_regex_teaching.py index 51b2607..8a8e11a 100644 --- a/tests/e2e/test_grep_regex_teaching.py +++ b/tests/e2e/test_grep_regex_teaching.py @@ -82,6 +82,7 @@ from sqlalchemy.orm import Session from app.db import SessionLocal from app.models import Chunk, Document +from e2e.auth_helpers import login from tests.e2e.mock_llm import ( GREP_TEACH_MARKER, GREP_TEACH_PATTERN, @@ -373,7 +374,7 @@ def test_regex_grep_self_corrects_to_plain_form( ) -> None: page.set_default_timeout(30_000) _reset_db_fixture() - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) _submit(page, GREP_TEACH_QUESTION) @@ -434,7 +435,7 @@ def test_plain_search_flow_not_swallowed_by_new_trigger( ) -> None: page.set_default_timeout(30_000) _reset_db_fixture() - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) # Turn 1 — the GREP-TEACH flow (the incident's regex-shaped first diff --git a/tests/e2e/test_harness_aligned_tools.py b/tests/e2e/test_harness_aligned_tools.py index d6bd9db..ccf29df 100644 --- a/tests/e2e/test_harness_aligned_tools.py +++ b/tests/e2e/test_harness_aligned_tools.py @@ -77,6 +77,7 @@ from app.db import SessionLocal from app.models import Chunk, Document from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login from tests.e2e.mock_llm import SEARCH_PATTERN, embed_text REPO = Path(__file__).resolve().parents[2] @@ -379,7 +380,7 @@ def test_read_flow_lines_answer_sources_no_raw_markup( ) -> None: page.set_default_timeout(30_000) _reset_db_read_pair() - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) _submit(page, READ_QUESTION) @@ -443,7 +444,7 @@ def test_grep_flow_line_then_answer( _pin_fixture() page.set_default_timeout(30_000) _reset_db_search_fixture(mock_llm) - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) _submit(page, SEARCH_QUESTION) @@ -488,7 +489,7 @@ def test_wire_argument_rule_across_both_flows( _pin_fixture() page.set_default_timeout(30_000) _reset_db_read_pair() - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) # Turn 1 — the READ flow (ls → read on the combined path). diff --git a/tests/e2e/test_header_consistency.py b/tests/e2e/test_header_consistency.py index d22d00a..9e620e4 100644 --- a/tests/e2e/test_header_consistency.py +++ b/tests/e2e/test_header_consistency.py @@ -120,12 +120,16 @@ def test_header_height_identical_across_pages_desktop( page.set_viewport_size({"width": 1280, "height": 800}) _seed_db(mock_llm) - heights = _header_heights(page, app_url) + # Anonymous: chat + sources bars. Phase 79: the viewer's DATA is + # require_user-gated (its title row needs the content), so the + # document bar is measured signed-in, below — the bar height itself + # is auth-independent, which is exactly what both measurements pin. + page.goto(app_url + "/") + heights = {"chat": _box_height(page, ".app-header")} + page.goto(app_url + SOURCES_URL) + heights["sources"] = _box_height(page, ".app-header") assert heights["chat"] == DESKTOP_HEADER_H, f"chat header {heights['chat']}px" assert heights["sources"] == DESKTOP_HEADER_H, f"sources header {heights['sources']}px" - assert heights["document"] == DESKTOP_HEADER_H, ( - f"document header {heights['document']}px (was content-sized)" - ) # Phase 16: the auth control lives in the same bar — anonymous sees # "Sign in", signed-in sees "Sign out", and neither state moves the @@ -139,6 +143,17 @@ def test_header_height_identical_across_pages_desktop( expect(page.locator("#sign-in-link")).to_be_hidden() assert _box_height(page, ".app-header") == DESKTOP_HEADER_H + # The document bar (row 1 — the standard bar of the two-row viewer + # header), signed in: identical to the other pages' bars. + page.goto(app_url + VIEWER_URL) + expect(page.locator("#doc-title")).to_have_text( + "Kubernetes Homelab Cluster", timeout=15_000 + ) + heights["document"] = _box_height(page, ".doc-header .app-header") + assert heights["document"] == DESKTOP_HEADER_H, ( + f"document header {heights['document']}px (was content-sized)" + ) + # --------------------------------------------------------------------------- # 2. Mobile (≤640px): all three pages, one identical 58px bar @@ -150,6 +165,7 @@ def test_header_height_identical_across_pages_mobile( ) -> None: page.set_viewport_size({"width": 375, "height": 812}) _seed_db(mock_llm) + login(page, app_url, next="/") # phase 79: the viewer's title needs the content heights = _header_heights(page, app_url) assert heights["chat"] == MOBILE_HEADER_H, f"chat header {heights['chat']}px" @@ -172,6 +188,7 @@ def test_viewer_header_content_still_fits( survives in the titlebar ROW, and row 1 stays the pinned standard bar — single-line on both desktop and mobile.""" _seed_db(mock_llm) + login(page, app_url, next="/") # phase 79: the viewer content is gated for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)): page.set_viewport_size({"width": width, "height": 800}) diff --git a/tests/e2e/test_hidden_tab_stream.py b/tests/e2e/test_hidden_tab_stream.py index 6d16eb9..d052b4f 100644 --- a/tests/e2e/test_hidden_tab_stream.py +++ b/tests/e2e/test_hidden_tab_stream.py @@ -376,7 +376,7 @@ def test_reload_after_hidden_tab_restores_one_bubble( ) -> None: _reset_db(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated _ask_long(page) answer = _wait_streaming(page) @@ -416,7 +416,7 @@ def test_baseline_no_pagehide_still_completes( an over-eager fix changing the ordinary settle).""" _reset_db(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated _ask_long(page) answer = _wait_streaming(page) @@ -470,7 +470,7 @@ def test_pre_token_guard_survives_hidden_window( # 120 s guard among them) becomes test-controlled, while the mock's # real-time stream is unaffected (the network is not a timer). page.clock.install() - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated page.fill("#message-input", SLOW_QUESTION) page.click("#send-btn") diff --git a/tests/e2e/test_honest_deflection.py b/tests/e2e/test_honest_deflection.py index a2d749a..e7e6140 100644 --- a/tests/e2e/test_honest_deflection.py +++ b/tests/e2e/test_honest_deflection.py @@ -29,6 +29,7 @@ from app.db import SessionLocal from app.models import QueryLog from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import ADMIN_PASSWORD, login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -86,7 +87,7 @@ def test_off_topic_question_deflects_honestly( summary = _reset_db(mock_llm, seed=True) assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") expect(page.locator("#kb-banner")).to_be_hidden() page.fill("#message-input", OFF_TOPIC) @@ -130,7 +131,7 @@ def test_deflection_suggestions_are_clickable( ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", OFF_TOPIC) page.click("#send-btn") chips = _deflected_chips(page) @@ -162,8 +163,15 @@ def test_deflected_done_event_and_query_log(app_url: str, mock_llm: int, db_read """Raw SSE contract for a deflected turn + the durable query_log row.""" _reset_db(mock_llm, seed=True) + # Phase 79: POST /api/chat is require_user-gated — the httpx client + # signs in as the admin first (the form login's API side: 204 + the + # signed session cookie in the jar). + client = httpx.Client(timeout=60.0) + r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204 + frames: list[dict[str, Any]] = [] - with httpx.stream( + with client.stream( "POST", f"{app_url}/api/chat", json={"message": OFF_TOPIC}, timeout=60.0 ) as r: assert r.status_code == 200 diff --git a/tests/e2e/test_kb_overview.py b/tests/e2e/test_kb_overview.py index 1e4dcfc..71b773e 100644 --- a/tests/e2e/test_kb_overview.py +++ b/tests/e2e/test_kb_overview.py @@ -46,6 +46,7 @@ from app.models import Document, KbOverview, QueryLog from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient from app.rag.overview import build_overview_prompt, regenerate_overview +from e2e.auth_helpers import login from tests.e2e.mock_llm import TOKEN_RE REPO = Path(__file__).resolve().parents[2] @@ -128,7 +129,7 @@ def _ask(page: Page, app_url: str, question: str, done_text: str) -> Any: marker (the no-row control, where the echo is absent by design). """ page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", question) page.click("#send-btn") bubble = page.locator(".msg.brain .bubble") diff --git a/tests/e2e/test_llm_history.py b/tests/e2e/test_llm_history.py index b288198..87deb5d 100644 --- a/tests/e2e/test_llm_history.py +++ b/tests/e2e/test_llm_history.py @@ -47,6 +47,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -149,7 +150,7 @@ def test_followup_receives_history_and_thinking( # Cold start: no restored conversation — every prior turn the model # sees on turn 2 is the one this test just sent. page.add_init_script("localStorage.clear()") - page.goto(app_url) + login(page, app_url, next="/") # Turn 1 — grounded + the thinking trigger: the brain record must # carry the streamed scratchpad in its ``thinking`` key. @@ -184,7 +185,7 @@ def test_first_question_has_no_history( assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) page.set_default_timeout(30_000) page.add_init_script("localStorage.clear()") - page.goto(app_url) + login(page, app_url, next="/") # Cold start: the request body's history is empty — no phantom # prior turns, no phantom thinking. @@ -207,7 +208,7 @@ def test_deflected_followup_receives_history( assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) page.set_default_timeout(30_000) page.add_init_script("localStorage.clear()") - page.goto(app_url) + login(page, app_url, next="/") _ask(page, T1) brain1 = _wait_record(page, 2)["messages"][1] diff --git a/tests/e2e/test_llm_retry.py b/tests/e2e/test_llm_retry.py index c8036d9..2413803 100644 --- a/tests/e2e/test_llm_retry.py +++ b/tests/e2e/test_llm_retry.py @@ -76,6 +76,7 @@ from sqlalchemy.orm import Session from app.db import SessionLocal from app.models import Chunk, Document, QueryLog +from e2e.auth_helpers import login from tests.e2e.mock_llm import embed_text REPO = Path(__file__).resolve().parents[2] @@ -319,7 +320,7 @@ def test_dead_then_recovered_deflected( ) -> None: _reset_db() page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, DEFLECT_Q) @@ -370,7 +371,7 @@ def test_dead_then_recovered_grounded( ) -> None: _reset_db() page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, GROUNDED_Q) @@ -423,7 +424,7 @@ def test_embedding_retry_completes( ) -> None: _reset_db() page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, EMBED_Q) @@ -465,7 +466,7 @@ def test_exhaustion_lands_on_the_error_banner( ) -> None: _reset_db() page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, EXHAUST_Q) diff --git a/tests/e2e/test_loading_feedback.py b/tests/e2e/test_loading_feedback.py index 43866dc..dba4e66 100644 --- a/tests/e2e/test_loading_feedback.py +++ b/tests/e2e/test_loading_feedback.py @@ -40,6 +40,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -179,7 +180,7 @@ def test_typing_indicator_during_slow_think( summary = _reset_db(mock_llm, seed=True) assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", SLOW_QUESTION) page.click("#send-btn") @@ -212,7 +213,7 @@ def test_button_state_machine( (class removed) + the input is focused back.""" _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", SLOW_QUESTION) page.click("#send-btn") @@ -241,7 +242,7 @@ def test_streaming_appends_live( shorter than a later one (no 'whole answer appears at once').""" _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", ON_TOPIC) page.click("#send-btn") @@ -278,7 +279,7 @@ def test_reduced_motion_keeps_feedback( _reset_db(mock_llm, seed=True) page.emulate_media(reduced_motion="reduce") page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", SLOW_QUESTION) page.click("#send-btn") @@ -302,7 +303,7 @@ def test_error_banner_on_llm_down( with the actionable retry hint, and the button recovers (never a zombie).""" page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", ON_TOPIC) page.click("#send-btn") diff --git a/tests/e2e/test_local_directory_sources.py b/tests/e2e/test_local_directory_sources.py index 74a6581..454e291 100644 --- a/tests/e2e/test_local_directory_sources.py +++ b/tests/e2e/test_local_directory_sources.py @@ -90,13 +90,18 @@ from app.db import SessionLocal from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, - APP_PORT, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module app binds its own port instead +# (a same-port second uvicorn dies on bind and would drive the wrong +# server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_LOCALDIR", "8126")) APP_URL = f"http://127.0.0.1:{APP_PORT}" GIT_SOURCES_URL = "/git-sources.html" diff --git a/tests/e2e/test_long_answers.py b/tests/e2e/test_long_answers.py index fbb50d4..4d4a28d 100644 --- a/tests/e2e/test_long_answers.py +++ b/tests/e2e/test_long_answers.py @@ -31,6 +31,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -83,7 +84,7 @@ def test_long_answer_streams_to_completion( ) -> None: _reset_db(mock_llm) page.set_default_timeout(45_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", LONG_QUESTION) page.click("#send-btn") @@ -118,7 +119,7 @@ def test_normal_answer_unaffected( ) -> None: _reset_db(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", NORMAL_QUESTION) page.click("#send-btn") diff --git a/tests/e2e/test_markdown_tables.py b/tests/e2e/test_markdown_tables.py index 402b3e8..998dc84 100644 --- a/tests/e2e/test_markdown_tables.py +++ b/tests/e2e/test_markdown_tables.py @@ -121,7 +121,7 @@ def _ask_table_answer(page: Page, app_url: str) -> Any: """Drive the trigger question and return the brain bubble once the whole byte-stable table answer has streamed in (the wide table's last cell lands last).""" - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated page.fill("#message-input", QUESTION) page.click("#send-btn") bubble = page.locator(".msg.brain .bubble").first @@ -333,7 +333,7 @@ def test_plain_pipe_stays_text( ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated page.fill("#message-input", PLAIN_QUESTION) page.click("#send-btn") diff --git a/tests/e2e/test_mobile_hamburger_nav.py b/tests/e2e/test_mobile_hamburger_nav.py index 2b6ce7d..d84e496 100644 --- a/tests/e2e/test_mobile_hamburger_nav.py +++ b/tests/e2e/test_mobile_hamburger_nav.py @@ -113,6 +113,20 @@ def _open_menu(page: Page) -> None: expect(page.locator("#app-nav")).to_have_css("opacity", "1") +def _js_open_menu(page: Page) -> None: + """Phase 79 (task 05): the in-app token gate is a full-viewport + overlay for ANONYMOUS visitors — it physically covers the header, + so a real click on #nav-toggle is intercepted by the gate (the gate + is the only interactive surface; the header is locked out with the + rest of the page). The binding is identical, so the menu contract + is driven programmatically: a JS-dispatched click runs the exact + same listener a real click would.""" + page.evaluate("() => document.querySelector('#nav-toggle').click()") + expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") + expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b")) + expect(page.locator("#app-nav")).to_have_css("opacity", "1") + + def _assert_menu_closed(page: Page) -> None: expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") assert "is-open" not in (page.locator("#app-nav").get_attribute("class") or ""), ( @@ -189,14 +203,19 @@ def test_anonymous_menu_contents( visible link — "Chat". The three admin-only links keep their ship-hidden state INSIDE the menu (the phase-19/35 contract is preserved by reusing the same <nav> element); opening flips - aria-expanded true.""" + aria-expanded true. + + Phase 79 (task 05): the anonymous visitor meets the token gate — a + full-viewport overlay that covers the header — so the toggle is + driven programmatically (the binding is identical; see + _js_open_menu).""" page = _mobile_page(browser) try: page.goto(app_url) _wait_settled_anonymous(page) _assert_menu_closed(page) - _open_menu(page) + _js_open_menu(page) assert _visible_nav_links(page) == ["Chat"], ( "anonymous: the menu must show exactly one visible link (Chat)" ) @@ -204,7 +223,7 @@ def test_anonymous_menu_contents( expect(page.locator(sel)).to_be_hidden() # A second click closes it again — aria-expanded round-trips. - page.click("#nav-toggle") + page.evaluate("() => document.querySelector('#nav-toggle').click()") _assert_menu_closed(page) finally: page.close() @@ -284,8 +303,11 @@ def test_esc_and_outside_close( page.goto(app_url) _wait_settled_anonymous(page) + # Phase 79 (task 05): the anonymous visitor's toggle click is + # intercepted by the gate overlay — drive the identical binding + # programmatically (see _js_open_menu). # Esc closes + refocuses the opener. - _open_menu(page) + _js_open_menu(page) page.keyboard.press("Escape") _assert_menu_closed(page) assert page.evaluate("() => document.activeElement.id") == "nav-toggle", ( @@ -294,8 +316,11 @@ def test_esc_and_outside_close( # Outside click: the menu STAYS open (accepted behavior — the # locked close set is Esc + link + resize, not backdrop click). - _open_menu(page) - page.locator("footer span").first.click() # a neutral, non-link point + # Phase 79 (task 05): for the anonymous visitor the "outside" + # point is the gate overlay itself — a REAL mouse click below + # the centered card (outside the nav, intercepted by the gate). + _js_open_menu(page) + page.mouse.click(10, 780) # the gate overlay — a neutral, non-nav point expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") assert "is-open" in (page.locator("#app-nav").get_attribute("class") or ""), ( "accepted behavior: an outside click must NOT close the menu" @@ -339,8 +364,9 @@ def test_animation_and_reduced_motion( f"got {report['property']!r}" ) # Opening flips class + aria together (the animated state). - _open_menu(page) - expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") + # Phase 79 (task 05): the anonymous toggle click is intercepted + # by the gate overlay — programmatic drive, same binding. + _js_open_menu(page) page.keyboard.press("Escape") _assert_menu_closed(page) finally: @@ -363,7 +389,9 @@ def test_animation_and_reduced_motion( assert _stilled("#app-nav") == "0s", ( f"reduced motion: closed state must not transition, got {_stilled('#app-nav')!r}" ) - rpage.click("#nav-toggle") + # Phase 79 (task 05): programmatic drive (the gate overlay + # intercepts the anonymous real click — same binding). + _js_open_menu(rpage) expect(rpage.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") expect(rpage.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b")) assert _stilled("#app-nav") == "0s", ( diff --git a/tests/e2e/test_nav_consistency.py b/tests/e2e/test_nav_consistency.py index b06d5f5..c440c9e 100644 --- a/tests/e2e/test_nav_consistency.py +++ b/tests/e2e/test_nav_consistency.py @@ -233,9 +233,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s page.goto(app_url + url) _wait_settled(page, admin=admin) if name == "viewer": - # The document itself has settled (rendered, not Loading…/ - # not-found) so the bar is measured on the real page. - expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000) + # The viewer has settled, so the bar is measured on the real + # page. Phase 79 (task 05): content is require_user-gated — + # admin settles on the title; anonymous settles on the inline + # token gate (#doc-auth-gate — the content fetch never runs, + # the title stays "Loading…"). + if admin: + expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000) + else: + expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000) + expect(page.locator("#doc-title")).to_have_text("Loading…") # The per-role VISIBLE inventory (the story: no button pops in or # out because of which page you are on). @@ -439,6 +446,7 @@ def test_viewer_row1_height_matches_chat_and_titlebar_present( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _seed_db(mock_llm) + login(page, app_url, next="/") # phase 79: the viewer's title needs the content for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)): page.set_viewport_size({"width": width, "height": 800}) @@ -474,6 +482,7 @@ def test_viewer_back_link_honors_back_param( ) -> None: page.set_viewport_size({"width": 1280, "height": 800}) _seed_db(mock_llm) + login(page, app_url, next="/") # phase 79: the viewer's title needs the content # Positive: back=/ (same-origin relative) is honored — href "/", # label "Chat", and the click returns to the chat page. diff --git a/tests/e2e/test_nav_rename_sources.py b/tests/e2e/test_nav_rename_sources.py index 76ca9f6..1e62833 100644 --- a/tests/e2e/test_nav_rename_sources.py +++ b/tests/e2e/test_nav_rename_sources.py @@ -112,11 +112,13 @@ CURRENT_LINK: dict[str, str | None] = { #: The four primary nav links, in their physical DOM order — the labels #: after the phase-48 swap (ids/hrefs unchanged). NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning") -#: The FIFTH a.nav-link in every page header (phase 53 saved-chat -#: history — ship-hidden, revealed by header.js for admins). The DOM -#: enumeration below therefore always sees it (pre-existing since phase -#: 53; the list below now matches the real nav). -NAV_TAIL = ("History",) +#: The FIFTH + SIXTH a.nav-link in every page header (phase 53 +#: saved-chat history + phase 79 task 06 access tokens — both +#: ship-hidden, revealed by header.js for admins). The DOM enumeration +#: below therefore always sees them (pre-existing; the list matches the +#: real nav — the phase-34 one-bar contract ships the SAME nav, incl. +#: the Tokens link, on every page). +NAV_TAIL = ("History", "Tokens") #: The login.js script — route pattern for the redirect suppression. LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$") @@ -197,7 +199,8 @@ def _assert_renamed_labels(page: Page, name: str) -> None: expect(git).to_have_attribute("href", "/git-sources.html") # The nav links (class nav-link) in physical DOM order — the four - # primaries plus the phase-53 admin-only History link. + # primaries plus the admin-only History (phase 53) and Tokens + # (phase 79 task 06) tail links. nav_texts = page.eval_on_selector_all( ".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())" ) diff --git a/tests/e2e/test_nav_switch_keeps_stream.py b/tests/e2e/test_nav_switch_keeps_stream.py index 340929d..ee148d9 100644 --- a/tests/e2e/test_nav_switch_keeps_stream.py +++ b/tests/e2e/test_nav_switch_keeps_stream.py @@ -515,7 +515,7 @@ def test_baseline_no_switch_still_completes( summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _ask_long(page) answer = _wait_streaming(page, page.locator(ANSWER)) diff --git a/tests/e2e/test_navbar_refresh.py b/tests/e2e/test_navbar_refresh.py new file mode 100644 index 0000000..aaff12c --- /dev/null +++ b/tests/e2e/test_navbar_refresh.py @@ -0,0 +1,629 @@ +"""Phase 77 E2E (Playwright): navbar re-shows refresh the view's data — +the story suite for the phase-77 refresh hook + the History refresh +button. + +Source: TODO.md L3 — "Clicking navbar icons should refresh the relevant +page. For example, clicking 'history' doesn't load new history until I +refresh. The history page should also have a refresh button." + +Since the phase-76 shell, a view's data was fetched exactly once, at +mount (mount-once, hide-forever) — a History view opened at 10:00 still +showed 10:00's data at 10:30. Phase 77 makes every USER-INITIATED +re-show of an already-mounted view re-fetch its list: the router +dispatches ``bor:view-refresh`` on the view's section (a switch back +onto it, a re-click of its own nav link — NO pushState — or back/ +forward onto it; the first show and boot never), the four data views +(History, RAG, Sources, Tuning) listen and re-run their loads, and the +History view gains an explicit Refresh button in its page-head (the +owner's second half of L3). The Chat view is deliberately excluded +(the in-flight stream and local conversation persist — the phase-76 +contract; the unchanged ``test_nav_switch_keeps_stream.py`` suite is +the additional control). + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov + +The "freshness" proof (the house pattern from the phase overview): +create new backing data via the API AFTER a view has loaded, nav back +(or re-click / press Refresh), assert the new row — with the phase-76 +canonical same-document sentinel (a ``window`` global set before the +nav clicks is still readable after — no document load). + +Test → story mapping (Playwright Mapping Rule): +1. ``test_reshow_refetches_history`` — THE L3 REPRO: saved chat A + visible on History; chat B created via the API while Chat is up; + Chat → History: row B appears (replaced list — no duplicates), the + window sentinel survives every click (same document). +2. ``test_active_view_reclick_refetches`` — a re-click of the ACTIVE + view's own nav link re-fetches (today a no-op): chat C created via + the API while History is up; click History AGAIN → C appears, the + URL is still /history.html and ``history.length`` is unchanged (no + pushState). +3. ``test_history_refresh_button`` — the explicit Refresh control + (TODO.md L3's second half): chat D created via the API; click + #history-refresh → D appears, #history-status announces "Saved + chats refreshed.", the button is disabled while the request is in + flight (the house hold-the-request pattern — deterministic, not a + race) and re-enabled after (accessible name + keyboard focus + pinned too). +4. ``test_popstate_refetches_history`` — back/forward onto an + already-mounted view re-fetches: Chat → History (loads) → Chat → + create E via the API → ``page.go_back()`` (popstate) → History → + E present. +5. ``test_all_four_data_views_refetch_on_reshow`` — one assertion per + data view (History, RAG, Sources, Tuning): the GET to the view's + list endpoint is exactly +1 on a re-show (the request-log pattern + from task 02) — the first show (the mount) is the baseline. +6. ``test_stream_surval_control`` — the phase-76 CONTRACT control with + the hook in place: send a question (the mock LLM's ~8 s stream) → + mid-stream nav to RAG → back to Chat → the FULL answer completes + and the turn settles (one query_log row). The unchanged + ``test_nav_switch_keeps_stream.py`` run in isolation is the + additional control. +""" +from __future__ import annotations + +import asyncio +import re +import time +from collections.abc import Callable +from pathlib import Path +from threading import Thread +from typing import Any + +import httpx +from playwright.sync_api import Locator, Page, Request, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient +from e2e.auth_helpers import login +from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" + +#: The phase-11 on-topic long-answer phrasing (house pattern, +#: test_nav_switch_keeps_stream.py): the honesty gate is HIGH and the +#: ~900-word answer streams for ~8–9 s — the guaranteed mid-stream +#: window. +LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer" + +#: The typing indicator is itself a .msg.brain — exclude its bubble. +ANSWER = ".msg.brain .bubble:not(.typing)" + +#: Distinct saved-chat titles per test (the DB is truncated at each +#: test's start, so fixed titles stay unambiguous within a test). +TITLE_A = "Phase77 re-show A" +TITLE_B = "Phase77 re-show B" +TITLE_C = "Phase77 re-click C" +TITLE_D = "Phase77 refresh button D" +TITLE_E = "Phase77 popstate E" + +# The four data views (task 02's refresh joiners): the nav link, the +# view's URL (the pushState target), and the list endpoint the view's +# load hits (the request-log assertion's target — the sync/upload +# pollers hit OTHER paths and only run while a job is in flight). +DATA_VIEWS: tuple[tuple[str, str, str], ...] = ( + ("#nav-history", "/history.html", "/api/chats"), + ("#nav-sources", "/sources.html", "/api/docs"), + ("#nav-git-sources", "/git-sources.html", "/api/git-sources"), + ("#nav-tuning", "/tuning.html", "/api/steering"), +) + + +# --------------------------------------------------------------------------- +# DB reset + API helpers (the house pattern from test_nav_switch_keeps_stream) +# --------------------------------------------------------------------------- + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread. + + Playwright's sync API keeps an asyncio loop running on the test thread, + so ``asyncio.run`` cannot be called directly from a test body. + """ + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int) -> ImportSummary: + """House reset + the prompt-shaping tables: steering notes and the + KB overview would otherwise append deterministic suffixes to every + mock answer and break the exact-text assertions; saved_chats is + cleared too (each test creates its own rows via the API).""" + with SessionLocal() as db: + db.execute( + text( + "TRUNCATE chunks, documents, query_log, " + "steering_notes, kb_overview, saved_chats" + ) + ) + db.commit() + return _run_in_thread(_import_fixtures(mock_port)) + + +def _query_log_count() -> int: + """The settled-row count over the whole (truncated) log — the + house pattern: a row finalizes ONLY when the LLM finished AND the + persistence succeeded, so 0 = cancelled, 1 = settled.""" + with SessionLocal() as db: + return db.execute(text("SELECT count(*) FROM query_log")).scalar_one() + + +def _admin_cookies(page: Page) -> dict[str, str]: + """The signed session cookies the browser holds after a form login — + the test's API side sees exactly what the signed-in browser sees.""" + return {c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c} + + +def _create_chat(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]: + """POST /api/chats (the public save surface) — the API-side + mutation the freshness proofs hinge on: the row is created while + the view is NOT showing, so only a re-fetch can surface it.""" + r = httpx.post( + f"{app_url}/api/chats", + timeout=10, + cookies=cookies, + json={ + "title": title, + "messages": [ + {"who": "user", "text": f"Saved via the API for the phase-77 suite: {title}"}, + {"who": "brain", "text": "Saved."}, + ], + }, + ) + assert r.status_code == 201, f"POST /api/chats for {title!r}: {r.status_code} {r.text}" + return r.json() + + +def _row(page: Page, title: str) -> Locator: + """The saved chat's row (the Title cell's Open link text is the + title — textContent only, so an exact-text locator is stable).""" + return page.locator("#history-tbody a.history-title-link", has_text=title) + + +def _wait_until(page: Page, pred: Callable[[], bool], timeout: float = 15.0) -> None: + """Poll ``pred`` until it holds — the tick is ``page.wait_for_timeout`` + (NOT a raw ``time.sleep``): the sync API delivers ``page.on`` events + only while a Playwright call is in flight, so the request log the + predicate reads is drained on every tick (a raw sleep starves the + event queue).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if pred(): + return + page.wait_for_timeout(50) + raise AssertionError("timeout waiting for the condition") + + +def _get_logger(page: Page) -> list[str]: + """Every GET the browser issues (the request-log pattern from task + 02 — the re-fetch proof where a cheap row-delta is not available). + POST/PUT (auto-saves, form posts) are deliberately not logged: the + assertion is about the view's LIST fetch.""" + seen: list[str] = [] + + def on_request(req: Request) -> None: + if req.method == "GET": + seen.append(req.url) + + page.on("request", on_request) + return seen + + +# --------------------------------------------------------------------------- +# Shared stream helpers (mirrored from test_nav_switch_keeps_stream.py — +# the phase-76 suite must stay unchanged; this file is the story gate) +# --------------------------------------------------------------------------- + + +def _no_error_banner(page: Page) -> None: + expect(page.locator('[role="alert"]:visible')).to_have_count(0) + + +def _ask_long(page: Page) -> None: + page.fill("#message-input", LONG_QUESTION) + page.click("#send-btn") + expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION) + + +def _wait_streaming(page: Page, answer: Locator) -> Locator: + """Wait until answer text is visibly streaming (a few delta frames + rendered — the mid-stream moment, well inside the ~8–9 s stream).""" + answer.wait_for(state="visible", timeout=30_000) + partial = "" + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + partial = answer.inner_text() + if len(partial.split()) >= 8: + break + time.sleep(0.05) + assert len(partial.split()) >= 8, "no answer deltas before the view switch" + # In flight at the switch: the button IS the enabled Stop control. + expect(page.locator("#send-label")).to_have_text("Stop") + expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop")) + return answer + + +def _wait_done(page: Page, answer: Locator) -> str: + """Wait for the ``done`` settle: the Send button is back and the + bubble carries the unique final line — no error banner on the way.""" + expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) + expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop")) + expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000) + _no_error_banner(page) + return answer.inner_text() + + +def _assert_full_answer(text: str) -> None: + """The bubble carries the FULL mock answer — every one of the 40 + numbered steps plus the unique final line (a truncated stream + would be missing its tail).""" + for i in range(1, LONG_ANSWER_LINES + 1): + assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer" + assert LONG_ANSWER_END in text + + +# --------------------------------------------------------------------------- +# 1. THE L3 REPRO: a switch back onto History re-fetches — the new row +# appears (list replaced, no duplicates), same document throughout +# --------------------------------------------------------------------------- + + +def test_reshow_refetches_history(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: + """Sign in → create saved chat A via POST /api/chats → nav to + History (row A visible) → create chat B via the API while Chat is + up → nav to Chat → nav back to History: row B is visible (the + re-show re-fetched — before phase 77 the 10:00 list stayed), row A + appears EXACTLY ONCE (the re-load replaces the list, never + duplicates it), and the window sentinel set before the nav clicks + is still readable after (the phase-76 canonical no-document-load + proof).""" + summary = _reset_db(mock_llm) + assert summary is not None and summary.added == 13 + page.set_default_timeout(30_000) + login(page, app_url, next="/") + expect(page.locator("#nav-history")).to_be_visible() + cookies = _admin_cookies(page) + + _create_chat(app_url, cookies, TITLE_A) + page.click("#nav-history") + expect(page).to_have_url(app_url + "/history.html") + expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000) + + # THE REPRO: B is created while History is NOT showing… + page.evaluate("() => { window.__shell_boot = 'phase77'; }") + _create_chat(app_url, cookies, TITLE_B) + page.click('a.nav-link[href="/"]') + expect(page).to_have_url(app_url + "/") + # …and the switch back re-fetches: B is visible without a reload. + page.click("#nav-history") + expect(page).to_have_url(app_url + "/history.html") + expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000) + # Replaced list, never duplicated: each title EXACTLY ONCE. + expect(_row(page, TITLE_A)).to_have_count(1) + expect(_row(page, TITLE_B)).to_have_count(1) + + # Same document through every click (a real navigation would wipe + # the window sentinel). + assert page.evaluate("() => window.__shell_boot") == "phase77", ( + "a real navigation would have wiped the window sentinel — " + "the switches must be same-document" + ) + + +# --------------------------------------------------------------------------- +# 2. A re-click of the ACTIVE view's own nav link re-fetches (no-op +# before phase 77) — NO pushState (the URL is already the path) +# --------------------------------------------------------------------------- + + +def test_active_view_reclick_refetches( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + """With History visible (rows A, B): create C via the API; click + the History nav link AGAIN (the active view's own link) → C + appears. The URL is still /history.html and history.length is + UNCHANGED — the re-click dispatches the refresh event instead of + a bare return, and it must not pushState (the URL already IS this + view's path).""" + summary = _reset_db(mock_llm) + assert summary is not None and summary.added == 13 + page.set_default_timeout(30_000) + login(page, app_url, next="/") + cookies = _admin_cookies(page) + + _create_chat(app_url, cookies, TITLE_A) + _create_chat(app_url, cookies, TITLE_B) + page.click("#nav-history") + expect(page).to_have_url(app_url + "/history.html") + expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000) + + length_before = page.evaluate("() => window.history.length") + _create_chat(app_url, cookies, TITLE_C) + page.click("#nav-history") # re-click the ACTIVE link + expect(page).to_have_url(app_url + "/history.html") + expect(_row(page, TITLE_C)).to_be_visible(timeout=15_000) + expect(_row(page, TITLE_A)).to_have_count(1) + + length_after = page.evaluate("() => window.history.length") + assert length_after == length_before, ( + "the active re-click must NOT pushState — the URL is already " + f"this view's path ({length_before} → {length_after})" + ) + + +# --------------------------------------------------------------------------- +# 3. The explicit Refresh button (TODO.md L3's second half): the +# visible, keyboard-reachable control with its in-flight lifecycle +# --------------------------------------------------------------------------- + + +def test_history_refresh_button(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: + """The Refresh control (TODO.md L3: "The history page should also + have a refresh button."): visible for admin in the page-head, + keyboard-reachable (Tab-able, focusable, accessible name), and the + in-flight lifecycle — create D via the API; click #history-refresh + → D appears, #history-status announces "Saved chats refreshed.", + the button is disabled DURING the in-flight request (asserted + deterministically: the request is held in the browser via + page.route — the house pattern from test_archive_upload_sources) + and re-enabled after.""" + summary = _reset_db(mock_llm) + assert summary is not None and summary.added == 13 + page.set_default_timeout(30_000) + login(page, app_url, next="/") + cookies = _admin_cookies(page) + + _create_chat(app_url, cookies, TITLE_A) + page.click("#nav-history") + expect(page).to_have_url(app_url + "/history.html") + expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000) + + btn = page.locator("#history-refresh") + expect(btn).to_be_visible() + expect(btn).to_be_enabled() + # WCAG 2.1 AA basics: the accessible name (icon-only below 640px — + # the aria-label carries it in both) + keyboard-reachable. + assert btn.get_attribute("aria-label") == "Refresh saved chats" + btn.focus() + active = page.evaluate("() => document.activeElement && document.activeElement.id") + assert active == "history-refresh", "the Refresh button must be keyboard-focusable" + + # Hold the refresh's GET in the browser so the in-flight state is + # observable deterministically instead of racing the fast + # localhost round-trip (the house pattern): the flag is armed only + # around the button's click. + hold = {"on": False} + + def handle(route: Any) -> None: + if hold["on"]: + time.sleep(1.0) + route.continue_() + + page.route("**/api/chats", handle) + _create_chat(app_url, cookies, TITLE_D) + hold["on"] = True + btn.click() + # In flight (§7.4): the button is disabled — no double-fire. + expect(btn).to_be_disabled() + hold["on"] = False + + # Settled: D is visible (the re-fetch replaced the list), the live + # region carries the exact success line, the button is re-enabled. + expect(_row(page, TITLE_D)).to_be_visible(timeout=15_000) + expect(_row(page, TITLE_A)).to_have_count(1) + expect(page.locator("#history-status")).to_have_text("Saved chats refreshed.") + expect(btn).to_be_enabled() + + +# --------------------------------------------------------------------------- +# 4. Back/forward (popstate) onto an already-mounted view re-fetches +# --------------------------------------------------------------------------- + + +def test_popstate_refetches_history( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + """Chat → History (the mount's own load) → back to Chat → create E + via the API → ``page.go_back()`` (popstate onto the already-mounted + History view) → E is present: the popstate path flows through + switchTo, so it inherits the wasMounted gating — a re-show + dispatches bor:view-refresh, the first show never did (E was not + visible on the original show).""" + summary = _reset_db(mock_llm) + assert summary is not None and summary.added == 13 + page.set_default_timeout(30_000) + login(page, app_url, next="/") + cookies = _admin_cookies(page) + + page.click("#nav-history") # Chat → History (mount + first load) + expect(page).to_have_url(app_url + "/history.html") + expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000) + + page.click('a.nav-link[href="/"]') # → Chat (pushState) + expect(page).to_have_url(app_url + "/") + _create_chat(app_url, cookies, TITLE_E) + + page.go_back() # popstate → /history.html (the already-mounted view) + expect(page).to_have_url(app_url + "/history.html") + expect(page.locator("#view-history")).not_to_be_hidden() + expect(_row(page, TITLE_E)).to_be_visible(timeout=15_000) + + +# --------------------------------------------------------------------------- +# 5. All four data views re-fetch on a re-show (the request-log pattern +# from task 02 — one assertion per view) +# --------------------------------------------------------------------------- + + +def test_all_four_data_views_refetch_on_reshow( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + """One assertion per data view (History, RAG, Sources, Tuning): + after the first show (the mount — its load is the baseline), a + switch back onto the view issues EXACTLY ONE more GET to the + view's list endpoint — the re-fetch the phase is about. The + windowed counts (before/after the re-show click) make the + assertion immune to the boot-time fetches (the header's steering + panel loads once at shell boot) and to the sync/upload pollers + (other paths, and only while a job is in flight). For History the + row-delta rides along: a chat created before the re-show appears + after it.""" + summary = _reset_db(mock_llm) + assert summary is not None and summary.added == 13 + page.set_default_timeout(30_000) + login(page, app_url, next="/") + expect(page.locator("#nav-sources")).to_be_visible() # admin: every link revealed + expect(page.locator("#nav-history")).to_be_visible() + expect(page.locator("#nav-git-sources")).to_be_visible() + expect(page.locator("#nav-tuning")).to_be_visible() + cookies = _admin_cookies(page) + + log = _get_logger(page) + + def count(endpoint: str) -> int: + return sum(1 for u in log if u.endswith(endpoint)) + + for nav_sel, view_path, endpoint in DATA_VIEWS: + # First show (the mount) — wait until the mount's own load + # lands in the log (the baseline). + page.click(nav_sel) + expect(page).to_have_url(app_url + view_path) + _wait_until(page, lambda ep=endpoint: count(ep) >= 1) + before = count(endpoint) + + # History: create the row-delta's backing data while hidden. + if endpoint == "/api/chats": + _create_chat(app_url, cookies, "Phase77 four-view row") + + # Back to Chat, then a re-show of the view: the refresh event + # must issue exactly one more list GET. + page.click('a.nav-link[href="/"]') + expect(page).to_have_url(app_url + "/") + page.click(nav_sel) + expect(page).to_have_url(app_url + view_path) + _wait_until(page, lambda b=before, ep=endpoint: count(ep) >= b + 1) + assert count(endpoint) == before + 1, ( + f"{nav_sel}: a re-show must re-fetch exactly once " + f"({before} → {count(endpoint)} GETs to {endpoint})" + ) + + if endpoint == "/api/chats": + expect(_row(page, "Phase77 four-view row")).to_be_visible(timeout=15_000) + + +# --------------------------------------------------------------------------- +# 6. The phase-76 CONTRACT control: with the refresh hook in place, an +# in-flight stream still survives a mid-stream nav switch +# --------------------------------------------------------------------------- + + +def test_stream_survival_control(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: + """The phase-76 contract holds with the hook in place: send a + question (the mock LLM's ~8 s stream) → mid-stream nav to RAG (a + re-show-capable, LISTENING view — the hook is live) → back to Chat + → the FULL answer completes and the turn settles (one query_log + row). The Chat view never listens for bor:view-refresh (the + negative unit pin) — its in-flight SSE reader and local + conversation persist through every switch. The unchanged + tests/e2e/test_nav_switch_keeps_stream.py run in isolation is the + additional control.""" + summary = _reset_db(mock_llm) + assert summary is not None and summary.added == 13 + page.set_default_timeout(30_000) + login(page, app_url, next="/") + expect(page.locator("#nav-sources")).to_be_visible() + + _ask_long(page) + answer = _wait_streaming(page, page.locator(ANSWER)) + + # THE SWITCH (mid-stream): the window sentinel set BEFORE the click + # is still readable AFTER it — same document. + page.evaluate("() => { window.__shell_boot = 'phase77'; }") + page.click("#nav-sources") + expect(page).to_have_url(app_url + "/sources.html") + assert page.evaluate("() => window.__shell_boot") == "phase77", ( + "a real navigation would have wiped the window sentinel" + ) + # The RAG view actually showed (the fixture docs' rows are listed) + # and the chat view is hidden (the stream fills it in the + # background — that persistence IS the phase-76 fix). + expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000) + expect(page.locator("#view-chat")).to_be_hidden() + + # Stay on RAG while the stream keeps running, then return to Chat. + page.wait_for_timeout(2000) + page.click('a.nav-link[href="/"]') + expect(page).to_have_url(app_url + "/") + assert page.evaluate("() => window.__shell_boot") == "phase77" + + # The answer COMPLETED — the FULL mock answer, no error banner — + # and the turn SETTLED (one query_log row; a cancelled turn would + # leave none). + done_text = _wait_done(page, answer) + _assert_full_answer(done_text) + assert _query_log_count() == 1, "the completed turn must finalize exactly one settled row" + + +# --------------------------------------------------------------------------- +# 7. Sanity: the API-side rows the suite creates carry the bor.chat.v1 +# record shape (guards _create_chat against a schema drift that +# would silently change what History renders) +# --------------------------------------------------------------------------- + + +def test_api_created_chats_carry_the_bor_chat_v1_shape( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + """The suite's API-side mutation helper (POST /api/chats) produces + rows in the exact bor.chat.v1 record shape the History table and + the ?chat= restore path render: who/text messages, the message + count, and no stray keys (the schema's extra=forbid rejects them + at the boundary with a 422 — a 201 here proves the shape) — and + History renders the row (the Open link's text IS the title).""" + summary = _reset_db(mock_llm) + assert summary is not None and summary.added == 13 + page.set_default_timeout(30_000) + login(page, app_url, next="/") + cookies = _admin_cookies(page) + + created = _create_chat(app_url, cookies, "Phase77 shape check") + r = httpx.get(f"{app_url}/api/chats/{created['id']}", timeout=10, cookies=cookies) + assert r.status_code == 200 + body = r.json() + assert body["title"] == "Phase77 shape check" + assert [m["who"] for m in body["messages"]] == ["user", "brain"] + assert body["message_count"] == 2 + # The bor.chat.v1 record shape: who/text present; only the schema's + # optional keys may accompany them (explicit nulls are preserved — + # the plain model_dump round-trips byte-identical). + allowed = {"who", "text", "sources", "deflected", "suggestions", + "thinking", "tools", "stopped"} + assert all({"who", "text"} <= set(m) <= allowed for m in body["messages"]) + # The History row renders it (the Open link's text IS the title). + page.click("#nav-history") + expect(page).to_have_url(app_url + "/history.html") + expect(_row(page, "Phase77 shape check")).to_be_visible(timeout=15_000) diff --git a/tests/e2e/test_no_reply_autoscroll.py b/tests/e2e/test_no_reply_autoscroll.py index 45ed62a..bbe5da8 100644 --- a/tests/e2e/test_no_reply_autoscroll.py +++ b/tests/e2e/test_no_reply_autoscroll.py @@ -64,6 +64,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -242,7 +243,7 @@ def test_no_autoscroll_during_long_answer( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # Turn 1 (settled) makes the document overflow the 800px viewport. submit(page, LONG_QUESTION) @@ -308,7 +309,7 @@ def test_no_autoscroll_during_thinking( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # One settled long turn so the document overflows (scrollable). submit(page, LONG_QUESTION) @@ -379,7 +380,7 @@ def test_submit_reveals_user_message( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # A populated conversation that overflows the viewport. submit(page, LONG_QUESTION) @@ -420,7 +421,7 @@ def test_restore_landing_one_shot( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # Settle a conversation (phase-14 persistence): long + short turns. submit(page, LONG_QUESTION) @@ -469,7 +470,7 @@ def test_restore_landing_one_shot( def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # The long answer streams to completion with its sources ... submit(page, LONG_QUESTION) @@ -527,7 +528,7 @@ def test_submit_does_not_hop_up( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # A settled long answer (the document overflows the 800px viewport), # and the user has scrolled to the very bottom to read it — the diff --git a/tests/e2e/test_pinned_composer.py b/tests/e2e/test_pinned_composer.py index 93c2f30..ea5697a 100644 --- a/tests/e2e/test_pinned_composer.py +++ b/tests/e2e/test_pinned_composer.py @@ -66,6 +66,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -285,7 +286,7 @@ def test_composer_pinned_at_every_scroll_position( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") build_conversation(page, n=len(SHORT_QUESTIONS)) @@ -357,7 +358,7 @@ def test_stop_is_reachable_from_scrolled_up( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # Turn 1 (settled long answer) overflows the viewport — the user now # has earlier content to read while turn 2 streams. @@ -513,7 +514,7 @@ def test_empty_chat_composer_sits_at_the_screen_bottom( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # A fresh visitor: the empty state, nothing to scroll — and yet the # input is already at the bottom of the screen. @@ -555,7 +556,7 @@ def test_pin_holds_on_mobile_under_the_header( page = browser.new_page(viewport=MOBILE) try: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # An empty phone chat rests the input at the bottom of the screen. expect(page.locator("#empty-state")).to_be_visible() diff --git a/tests/e2e/test_quadlet_jinja_import.py b/tests/e2e/test_quadlet_jinja_import.py index 6ec6238..7c4f2dc 100644 --- a/tests/e2e/test_quadlet_jinja_import.py +++ b/tests/e2e/test_quadlet_jinja_import.py @@ -188,7 +188,7 @@ def test_jinja_retrievable_not_deflected( ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated page.fill("#message-input", JINJA_QUESTION) page.click("#send-btn") diff --git a/tests/e2e/test_response_to_docs.py b/tests/e2e/test_response_to_docs.py index 5803fe5..38c1aaa 100644 --- a/tests/e2e/test_response_to_docs.py +++ b/tests/e2e/test_response_to_docs.py @@ -72,7 +72,6 @@ from app.rag.llm import LLMClient from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, - APP_PORT, SESSION_SECRET, USE_REAL_LLM, _wait_http, @@ -81,6 +80,13 @@ from e2e.conftest import ( REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module app (and the unconfigured one +# next to it, derived below) bind their own ports instead (a same-port +# second uvicorn dies on bind and would drive the wrong server). +# Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_DOCS", "8127")) + APP_URL = f"http://127.0.0.1:{APP_PORT}" #: The unconfigured app's port (task 07: a second app boot WITHOUT #: ``BOR_DOCS_REPO`` — a separate fixture on the next port, so it can @@ -352,9 +358,17 @@ def _stream_chat_answer(app_url: str, message: str) -> str: """Replay one turn through the raw SSE endpoint (the ``test_chat_rag.py`` transport pattern) and return the EXACT answer text — the markdown source the UI accumulates into ``m.text``, - byte-identical for the deterministic mock (same KB, same question).""" + byte-identical for the deterministic mock (same KB, same question). + + Phase 79: POST /api/chat is require_user-gated — the replay client + signs in as the admin first (every caller of this helper is an + admin-flow test; the guest flow never reaches it).""" + client = httpx.Client(timeout=120.0) + r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204 + frames: list[dict[str, Any]] = [] - with httpx.stream( + with client.stream( "POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0 ) as r: assert r.status_code == 200 @@ -567,10 +581,14 @@ def test_guest_has_no_button( page.goto(app_url) expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000) - _ask(page, app_url, QUESTION_1) # the grounded answer streams for guests too - # The "Save as doc" action is admin-only: ABSENT (not hidden) on - # the completed bubble, whatever the docs config says. + # Phase 79 (task 05): the guest meets the token gate — the composer + # is inert behind it, so the guest can never send a turn at all, and + # the admin-only Save-as-doc affordance (which renders only on + # completed brain bubbles) can never appear on their surface. + expect(page.locator("#auth-gate")).to_be_visible(timeout=15_000) + assert page.evaluate("() => document.getElementById('main').inert") is True + expect(page.locator(".msg.brain .bubble")).to_have_count(0) expect(page.locator(".save-as-doc-btn")).to_have_count(0) # The draft API 403s anonymous callers (httpx, no cookie at all). diff --git a/tests/e2e/test_responsive_polish.py b/tests/e2e/test_responsive_polish.py index d6bbe67..d55306f 100644 --- a/tests/e2e/test_responsive_polish.py +++ b/tests/e2e/test_responsive_polish.py @@ -206,13 +206,13 @@ def test_no_horizontal_overflow_at_viewports( for width, height in VIEWPORTS: page = browser.new_page(viewport={"width": width, "height": height}) try: - page.goto(f"{app_url}/") + login(page, app_url, next="/") # phase 79: the chips need a session page.locator("#suggestions .suggestion-chip").first.wait_for( state="visible", timeout=10_000 ) _assert_no_doc_overflow(page, f"chat @ {width}px") - login(page, app_url, next="/sources.html") # phase 16: admin-only + page.goto(f"{app_url}/sources.html") # phase 16: admin-only page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000) _assert_no_doc_overflow(page, f"sources @ {width}px") finally: @@ -309,10 +309,62 @@ def test_a11y_landmarks_and_labels( page: Page, app_url: str, db_ready: None ) -> None: """AC3: landmarks, working skip link, labeled input, named controls, - and a visible :focus-visible outline on every keyboard focusable.""" + and a visible :focus-visible outline on every keyboard focusable. + + Phase 79 (task 05): the anonymous visitor meets the token gate — + the page body is inert behind it, and the gate's reveal contract + puts focus on the token input — so the skip-link / full-tab-walk + contract is checked for the SIGNED-IN admin (byte-identical to + before), and the gate gets its own focus contract: reveal focuses + the input, and input + submit both show the global 3px + :focus-visible outline.""" + + def _outline() -> dict[str, str]: + return page.evaluate( + """() => { + const el = document.activeElement; + const cs = getComputedStyle(el); + return {id: el.id, + cls: String(el.className).split(" ")[0], + outline_style: cs.outlineStyle, + outline_width: cs.outlineWidth}; + }""" + ) + + def _assert_outlined(info: dict[str, str], label: str, path: str) -> None: + width_px = float(info["outline_width"].replace("px", "")) + assert info["outline_style"] == "solid" and width_px >= 2, ( + f"no visible focus outline on the gate {label} on {path} " + f"({info['outline_style']} {info['outline_width']})" + ) + + # --- Anonymous: the token gate's focus contract (phase 79) -------- for path in ("/", "/sources.html"): page.goto(f"{app_url}{path}") page.wait_for_load_state("networkidle") + expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000) + # Reveal contract: focus lands on the token input — the first + # (and only) body control an anonymous visitor can act on. + info = _outline() + assert info["id"] == "auth-gate-input", ( + f"gate reveal must focus the token input ({path}), on {info['id']!r}" + ) + _assert_outlined(info, "input", path) + # Tab from the input lands on the gate submit, also outlined. + page.keyboard.press("Tab") + info = _outline() + assert info["cls"] == "auth-gate-submit", ( + f"Tab from the gate input must land on the gate submit ({path}), " + f"on {info['id'] or info['cls']!r}" + ) + _assert_outlined(info, "submit", path) + + # --- Signed-in: the original AC3 contract (admin, gate hidden) ---- + login(page, app_url) + for path in ("/", "/sources.html"): + page.goto(f"{app_url}{path}") + page.wait_for_load_state("networkidle") + expect(page.locator("#auth-gate")).to_be_hidden() # Landmarks (PLAN §7.2). assert page.locator("header.app-header").count() == 1, f"header missing on {path}" @@ -380,8 +432,10 @@ def test_contrast_pairs_pass_aa( ) -> None: """AC4: every PLAN §7.2 color pair computed from the live computed styles meets WCAG 2.1 AA (>= 4.5:1).""" - # Chat page: ink/surface, white/brand, chip-ink/chip-bg, deflection pair. - page.goto(f"{app_url}/") + # Chat page: ink/surface, white/brand, chip-ink/chip-bg, deflection + # pair. Phase 79: the chips need /api/suggestions (require_user- + # gated) — sign in first; the color pins are auth-independent. + login(page, app_url, next="/") page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000) pairs = page.evaluate( """() => { @@ -451,7 +505,7 @@ def test_reduced_motion_respected( # Control: default context — the dots run the `typing` animation. page = browser.new_page(viewport={"width": 1280, "height": 800}) try: - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated page.fill("#message-input", SLOW_QUESTION) page.click("#send-btn") expect(page.locator(TYPING)).to_be_visible(timeout=1_000) @@ -470,7 +524,7 @@ def test_reduced_motion_respected( ) rpage = context.new_page() try: - rpage.goto(app_url) + login(rpage, app_url, next="/") # phase 79: chat is require_user-gated rpage.fill("#message-input", SLOW_QUESTION) rpage.click("#send-btn") expect(rpage.locator(TYPING)).to_be_visible(timeout=1_000) @@ -538,7 +592,7 @@ def test_long_content_wraps_without_overflow( # Chat @ 375px: unbroken 60-char tokens wrap inside both bubbles. chat = browser.new_page(viewport={"width": 375, "height": 812}) try: - chat.goto(app_url) + login(chat, app_url, next="/") # phase 79: chat is require_user-gated chat.set_default_timeout(30_000) chat.fill("#message-input", f"what do the notes say about {'x' * 60}") chat.click("#send-btn") diff --git a/tests/e2e/test_retrieval_quality.py b/tests/e2e/test_retrieval_quality.py index f5df36c..47bc3dc 100644 --- a/tests/e2e/test_retrieval_quality.py +++ b/tests/e2e/test_retrieval_quality.py @@ -129,7 +129,7 @@ def test_gitlab_question_is_grounded_with_gitlab_chip( must land on the tool's own document — not a generic template.""" _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated _ask(page, GITLAB_QUESTION) expect(page.locator(".msg.user .bubble")).to_contain_text(GITLAB_QUESTION) @@ -160,7 +160,7 @@ def test_keyword_only_question_beats_vector_ranking( weak — the lexical branch is what grounds the answer.""" _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated _ask(page, KEYWORD_QUESTION) bubble = page.locator(".msg.brain .bubble").first @@ -190,7 +190,7 @@ def test_off_topic_still_deflects_with_chips( "sourdough" matches nothing in the KB lexically → honest deflection.""" _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated _ask(page, OFF_TOPIC) bubble = page.locator(".msg.brain.is-deflected .bubble").first diff --git a/tests/e2e/test_retry_answer.py b/tests/e2e/test_retry_answer.py index 0ee4d7a..817ccf9 100644 --- a/tests/e2e/test_retry_answer.py +++ b/tests/e2e/test_retry_answer.py @@ -53,6 +53,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -221,7 +222,7 @@ def test_retry_redoes_in_place( summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # Two grounded turns: the conversation is [u1, b1, u2, b2]. _ask(page, Q1) @@ -290,7 +291,7 @@ def test_retry_on_stopped_partial( ) -> None: _reset_db(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _stop_long_answer_mid_stream(page) @@ -344,7 +345,7 @@ def test_retry_deflected( ) -> None: _reset_db(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _ask_deflected(page, OFF_TOPIC) @@ -401,7 +402,7 @@ def test_retry_inert_while_in_flight( ) -> None: _reset_db(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # One completed turn: the last (only) brain bubble carries Retry. _ask(page, Q1) diff --git a/tests/e2e/test_save_doc_session.py b/tests/e2e/test_save_doc_session.py index 62be1ef..c4d0de2 100644 --- a/tests/e2e/test_save_doc_session.py +++ b/tests/e2e/test_save_doc_session.py @@ -73,7 +73,6 @@ from app.rag.llm import LLMClient from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, - APP_PORT, SESSION_SECRET, USE_REAL_LLM, _wait_http, @@ -82,6 +81,11 @@ from e2e.conftest import ( REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module app binds its own port instead +# (a same-port second uvicorn dies on bind and would drive the wrong +# server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_SAVEDOC", "8129")) APP_URL = f"http://127.0.0.1:{APP_PORT}" BRANCH = "bor-docs" @@ -342,9 +346,16 @@ def _stream_chat_answer(app_url: str, message: str) -> str: answer is a pure function of the LAST user message + the document context (both identical whether or not the browser's phase-74 history rode along), so a bare replay recovers the same bytes the - multi-turn browser session rendered.""" + multi-turn browser session rendered. + + Phase 79: POST /api/chat is require_user-gated — the replay client + signs in as the admin first (the caller is an admin-flow test).""" + client = httpx.Client(timeout=120.0) + r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204 + frames: list[dict[str, Any]] = [] - with httpx.stream( + with client.stream( "POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0 ) as r: assert r.status_code == 200 diff --git a/tests/e2e/test_save_share_ux.py b/tests/e2e/test_save_share_ux.py index 5c1ecc1..9ea3af5 100644 --- a/tests/e2e/test_save_share_ux.py +++ b/tests/e2e/test_save_share_ux.py @@ -12,10 +12,14 @@ Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov -The owner-locked loop under test (2026-08-31): +The owner-locked loop under test (2026-08-31). Phase 79 note: chat is +now ``require_user``-gated (only shared chats stay open), so the +"signed-out visitor" of the L3/L4 pins signs in first — the mechanism +pins (no Save control, silent auto-save, exactly one row, share toast, +unshare, layout) are unchanged. -* **Anonymous auto-save (L4 / A2)** — there is NO Save control in the - DOM at any width; a signed-out visitor's first question auto-upserts +* **Auto-save (L4 / A2)** — there is NO Save control in the + DOM at any width; the visitor's first question auto-upserts EXACTLY ONE ``saved_chats`` row (the auto-title, both messages) — verified through the admin's ``GET /api/chats`` (the management surface stays admin-only, phase 55 task 01) with no button press @@ -253,9 +257,11 @@ def test_anonymous_auto_save( _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - # Anonymous by construction: the page fixture's context never logs - # in — this is the signed-out visitor. - page.goto(app_url + "/") + # Phase 79: chat is require_user-gated — the phase-55 "signed-out + # visitor" no longer exists (shared chats are the only open + # surface), so the default visitor signs in first; every other pin + # (no Save control, silent auto-save, exactly one row) is unchanged. + login(page, app_url, next="/") # L4: there is NO Save control at any width — the element is gone # from the DOM (phase 55, task 02), the pills are New chat + Share. @@ -300,7 +306,7 @@ def test_no_duplicate_row_across_reload( _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - page.goto(app_url + "/") + login(page, app_url, next="/") # phase 79: chat is require_user-gated q1 = "How is my Kubernetes cluster set up? (save-ux-reload)" _ask(page, q1) @@ -350,11 +356,14 @@ def test_anonymous_share_toast_and_unshare( _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) - # Anonymous by construction — no login anywhere in this test. - page.goto(app_url + "/") + # Phase 79: the phase-55 "no login anywhere" visitor no longer + # exists (chat is gated) — the visitor signs in first; the FRESH + # incognito context below (the read-only shared view) stays + # anonymous, which is the open surface this story pins. + login(page, app_url, next="/") - # L3: the Share pill is visible WITHOUT login (static markup — the - # phase-51 admin-only reveal gate is gone, task 03). + # L3: the Share pill is visible (static markup — the phase-51 + # admin-only reveal gate is gone, task 03). share = page.locator("#share-chat-btn") expect(share).to_be_visible() expect(share).to_have_attribute("aria-label", "Share chat") diff --git a/tests/e2e/test_search_tool.py b/tests/e2e/test_search_tool.py index 444e92f..b96a71a 100644 --- a/tests/e2e/test_search_tool.py +++ b/tests/e2e/test_search_tool.py @@ -75,6 +75,7 @@ from app.db import SessionLocal from app.models import QueryLog from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login from tests.e2e.mock_llm import SEARCH_PATTERN, SEARCH_TRIGGER REPO = Path(__file__).resolve().parents[2] @@ -337,7 +338,7 @@ def test_search_flow_searches_and_answers_from_match( _pin_fixture() page.set_default_timeout(30_000) _reset_db(mock_llm) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, SEARCH_QUESTION) @@ -415,7 +416,7 @@ def test_search_adds_no_source_by_itself( _pin_fixture() page.set_default_timeout(30_000) _reset_db(mock_llm) - page.goto(app_url) + login(page, app_url, next="/") _install_page_hooks(page) _submit(page, SEARCH_QUESTION) @@ -457,7 +458,7 @@ def test_search_tool_line_re_renders_after_reload( _pin_fixture() page.set_default_timeout(30_000) _reset_db(mock_llm) - page.goto(app_url) + login(page, app_url, next="/") _submit(page, SEARCH_QUESTION) _wait_settled(page) diff --git a/tests/e2e/test_shared_header.py b/tests/e2e/test_shared_header.py index 78ba825..a016d4c 100644 --- a/tests/e2e/test_shared_header.py +++ b/tests/e2e/test_shared_header.py @@ -194,9 +194,17 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa expect(nav).to_be_hidden() if page_kind == "viewer": - # The document itself has settled (rendered, not Loading…/not-found) - # so the bar is being measured on the real page. - expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000) + # The viewer has settled, so the bar is measured on the real + # page. Phase 79 (task 05): the content endpoint is + # require_user-gated — admin settles on the document title; + # anonymous settles on the inline token gate (#doc-auth-gate — + # the phase-16 not-found-card surface is replaced by the gate: + # the content fetch never runs, the title stays "Loading…"). + if admin: + expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000) + else: + expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000) + expect(page.locator("#doc-title")).to_have_text("Loading…") # The bar height never moves: 64px desktop / 58px ≤640px (phase 12), # bounding-box measurement — the new pills must fit inside it. diff --git a/tests/e2e/test_smoke.py b/tests/e2e/test_smoke.py index af1309d..19fb255 100644 --- a/tests/e2e/test_smoke.py +++ b/tests/e2e/test_smoke.py @@ -8,6 +8,8 @@ from __future__ import annotations import httpx from playwright.sync_api import Page, expect +from e2e.auth_helpers import login + def test_health_endpoint(app_url: str) -> None: r = httpx.get(f"{app_url}/api/health", timeout=5) @@ -26,7 +28,11 @@ def test_index_page_loads_locally(page: Page, app_url: str) -> None: def test_chat_roundtrip_never_stale_button(page: Page, app_url: str) -> None: - page.goto(app_url) + # Phase 79: chat is require_user-gated (and the in-app token gate + # locks #main for anonymous) — the round-trip signs in as admin + # first (real form login; DB-free, so the story's DB-down error- + # banner path still works). + login(page, app_url, next="/") page.locator("#message-input").fill("hello brain") page.locator("#send-btn").click() diff --git a/tests/e2e/test_source_removal_cleanup.py b/tests/e2e/test_source_removal_cleanup.py index b680388..46afa32 100644 --- a/tests/e2e/test_source_removal_cleanup.py +++ b/tests/e2e/test_source_removal_cleanup.py @@ -103,13 +103,18 @@ from app.models import Document, GitSource from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, - APP_PORT, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module app binds its own port instead +# (a same-port second uvicorn dies on bind and would drive the wrong +# server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_REMOVAL", "8131")) APP_URL = f"http://127.0.0.1:{APP_PORT}" GIT_SOURCES_URL = "/git-sources.html" diff --git a/tests/e2e/test_sources_midstream_bug.py b/tests/e2e/test_sources_midstream_bug.py index 8231f61..eb64c59 100644 --- a/tests/e2e/test_sources_midstream_bug.py +++ b/tests/e2e/test_sources_midstream_bug.py @@ -424,7 +424,7 @@ def test_no_orphan_brain_message_when_navigated_before_first_token( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated page.fill("#message-input", HESITATE_QUESTION) page.click("#send-btn") expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) @@ -482,7 +482,7 @@ def test_completed_turn_unaffected( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated _ask(page, QUESTION) # The done save point: full answer + metadata, exactly as phase 14. @@ -526,7 +526,7 @@ def test_new_chat_still_clears_conversation( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # phase 79: chat is require_user-gated _ask(page, QUESTION) assert _stored(page) is not None diff --git a/tests/e2e/test_stale_ui_copy.py b/tests/e2e/test_stale_ui_copy.py index 1e8a0bb..56d04d0 100644 --- a/tests/e2e/test_stale_ui_copy.py +++ b/tests/e2e/test_stale_ui_copy.py @@ -25,6 +25,8 @@ from __future__ import annotations from playwright.sync_api import Page, expect +from e2e.auth_helpers import login + # The locked replacement copy (owner-locked A1 / A2 / A3 — same literals # as tests/unit/test_stale_ui_copy.py; the unit pins guard the files, # this suite guards what the visitor actually sees). @@ -64,7 +66,7 @@ def test_chat_page_shows_no_homelab_or_deployment_text( rendered suggestion chips are covered by this body scan (case- insensitive; a fresh page context has no saved chats, so the empty state is what renders).""" - page.goto(f"{app_url}/") + login(page, app_url, next="/") expect(page.locator("#suggestions .suggestion-chip")).to_have_count(len(CHIPS)) body = page.evaluate("() => document.body.innerText.toLowerCase()") assert "homelab" not in body, f"homelab-era text rendered on the chat page: {body!r}" @@ -79,7 +81,7 @@ def test_chat_page_suggestion_chips_are_the_locked_list( """The four rendered empty-state chips (from ``GET /api/suggestions``, the code default) are the locked (A2) list, in order.""" - page.goto(f"{app_url}/") + login(page, app_url, next="/") chips = page.locator("#suggestions .suggestion-chip") expect(chips).to_have_count(len(CHIPS)) for i, chip in enumerate(CHIPS): diff --git a/tests/e2e/test_steering.py b/tests/e2e/test_steering.py index 14ad5bd..3606c8e 100644 --- a/tests/e2e/test_steering.py +++ b/tests/e2e/test_steering.py @@ -48,10 +48,11 @@ NOTE = "STEEER-MARKER be concise" XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>" #: The shell (index.html — served for BOTH / and /tuning.html since #: phase 76 task 01, when the Tuning view folded into it) ships exactly -#: FOUR classic/module script tags: the phase-39 brand.js classic layer +#: FIVE classic/module script tags: the phase-39 brand.js classic layer #: + markdown.js + the chat module (app.js) + the shell router -#: (router.js, which lazy-imports the tuning.js view module). -BASE_SCRIPT_COUNT = 4 +#: (router.js, which lazy-imports the tuning.js view module) + the +#: phase-79 token gate (token-gate.js, phase 79 task 05). +BASE_SCRIPT_COUNT = 5 async def _import_fixtures(mock_port: int) -> ImportSummary: diff --git a/tests/e2e/test_sticky_navbar.py b/tests/e2e/test_sticky_navbar.py index b7fb26f..e7d65a3 100644 --- a/tests/e2e/test_sticky_navbar.py +++ b/tests/e2e/test_sticky_navbar.py @@ -252,6 +252,7 @@ def test_doc_header_stuck_at_top_on_long_document( page.set_default_timeout(30_000) # The dedicated viewer page (phase 10/26 contract) on the generated # long doc — source ``gen``, path ``doc-long.md``. + login(page, app_url, next="/") # phase 79: the viewer content is gated page.goto(f"{app_url}/document.html?source=gen&path=doc-long.md") expect(page.locator("#doc-title")).to_have_text("Sticky long document") expect(page.locator("#doc-content .doc-md")).not_to_be_empty() diff --git a/tests/e2e/test_stop_generation.py b/tests/e2e/test_stop_generation.py index e7137f3..33e037c 100644 --- a/tests/e2e/test_stop_generation.py +++ b/tests/e2e/test_stop_generation.py @@ -47,6 +47,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -226,7 +227,7 @@ def test_stop_mid_stream( summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") stopped_text = _stop_mid_stream(page) @@ -266,7 +267,7 @@ def test_stop_pre_token( ) -> None: _reset_db(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") _stop_pre_token(page, SLOW_QUESTION, via_enter=False) @@ -302,7 +303,7 @@ def test_stopped_turn_survives_reload( ) -> None: _reset_db(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") stopped_text = _stop_mid_stream(page) assert _query_log_count() == 0 diff --git a/tests/e2e/test_suggestion_chips.py b/tests/e2e/test_suggestion_chips.py index bf664ac..066b836 100644 --- a/tests/e2e/test_suggestion_chips.py +++ b/tests/e2e/test_suggestion_chips.py @@ -1,37 +1,110 @@ -"""Phase 05 E2E (Playwright): onboarding suggestion chips, one-tap submit. +"""Phase 80 E2E (Playwright): onboarding chips = the last 3 questions asked. + +Story: ``.agents/user_stories/suggestion-chips.md`` (phase 05) — +REWRITTEN in place for the phase-80 semantics (the phase-76 precedent: +a semantic change rewrites the story suite in place). Source: +``TODO.md`` L6. + +The new contract (owner decision A6): the empty-state chip row is the +3 most recent user questions across ALL saved chats — chats walked +newest-``updated_at`` first, each chat's messages newest-first, +exact (case-sensitive) de-duplicated, cap 3. A fresh deployment — zero +saved questions — gets the SEED list instead (``BOR_SUGGESTIONS`` / +the built-in default). 1–2 saved questions → exactly those chips (NO +mixing with the seed). The row refetches when the empty state comes +back (New chat), so it is never stale. The deflection "Maybe try" +chips are a separate contract (``derive_suggestions``) — untouched. + +The four states pinned here: + +* **seed** — fresh DB (no saved chats) → the chip texts equal the + built-in default list EXACTLY (the ``SEED`` literal below is the + pin for the exact seed list — ``tests/unit/test_config.py`` pins + only the shape) — rendered as accessible buttons in the role=list + group, exactly as the phase-05 component contract; +* **last-3** — two saved chats with 5 user questions total (the older + one saved FIRST — the API stamps ``updated_at``) → a fresh page + load shows EXACTLY the 3 newest questions, newest-first; +* **partial** — exactly 2 saved questions deployment-wide → exactly + 2 chips (no seed top-up — the A6 contract, visible in the UI); +* **refetch** — boot with the seed chips, save a chat whose newest + question is Q via the API, click New chat (``#new-chat-btn``) → the + chips now are Q, and the request log shows a SECOND + ``GET /api/suggestions`` (the boot fetch was the first). + +Carried-over story behavior (unchanged semantics from the phase-05 +suite): one-tap submit (chip click → composer filled → submitted → +the mock-LLM brain bubble), Tab+Enter keyboard reachability of the +chips (the keyboard-walk assertion), and the mobile single +horizontal-scroll row. + +The endpoint is authed (phase 79, ``require_user``), so every test +signs in as admin first (``auth_helpers.login``). ``saved_chats`` is +global state on the shared e2e Postgres AND the state this contract +reads — the autouse fixture truncates it before and after EVERY test +(including the ones whose turns auto-save a row), so each test starts +from — and leaves — an empty deployment. -Story: ``.agents/user_stories/suggestion-chips.md`` Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov - -The onboarding row in the empty state renders real ``<button>`` chips from -``GET /api/suggestions`` (settings defaults). Clicking — or Tab + Enter — -fills the composer AND submits: one tap produces a user bubble with the -chip's exact text and a streamed Brain reply. On mobile (375px) the row -becomes a single horizontally scrollable line. """ from __future__ import annotations import asyncio +import json +import time +from collections.abc import Iterator +from datetime import datetime from pathlib import Path from threading import Thread from typing import Any -import httpx import pytest -from playwright.sync_api import Browser, Page, expect +from playwright.sync_api import Browser, Page, Request, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" +#: The EXACT built-in onboarding SEED (phase 80, TODO.md L6): the chip +#: row of a brand-new deployment, shown only before any question has +#: ever been saved. This literal is the E2E pin for the exact seed +#: list — ``tests/unit/test_config.py`` pins only the SHAPE (>=3 +#: non-blank distinct strings), and the e2e app under test is forced +#: to the code default by conftest's leak guard — keep in sync with +#: the ``Settings.suggestions`` default in ``app/config.py``. +SEED: list[str] = [ + "What documents are in the knowledge base?", + "Which source does each answer come from?", + "How do I add a new source?", + "Summarize the most recent document.", +] + + +@pytest.fixture(autouse=True) +def clean_chats(db_ready: None) -> Iterator[None]: + """``saved_chats`` is the state the phase-80 contract reads: + truncate it before and after every test so each state test starts + from (and leaves) an empty deployment. Unlike the KB tables, this + reset is non-optional — the chips ARE these rows, and the + carried-over submit tests auto-save a row per turn, which would + otherwise leak into the later state tests.""" + with SessionLocal() as db: + db.execute(text("TRUNCATE saved_chats")) + db.commit() + yield + with SessionLocal() as db: + db.execute(text("TRUNCATE saved_chats")) + db.commit() + async def _import_fixtures(mock_port: int) -> ImportSummary: kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} @@ -58,7 +131,9 @@ def _run_in_thread(coro: Any) -> Any: def _seed_kb(mock_port: int) -> ImportSummary: - """Deterministic KB: truncate everything, import the fixture docs.""" + """Deterministic KB: truncate the KB tables, import the fixture + docs (needed by the carried-over submit tests' grounded answers). + ``saved_chats`` is the autouse fixture's job.""" with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log")) db.commit() @@ -67,42 +142,75 @@ def _seed_kb(mock_port: int) -> ImportSummary: return summary -def _api_suggestions(app_url: str) -> list[str]: - body = httpx.get(f"{app_url}/api/suggestions", timeout=10).json() - return body["suggestions"] +def _user(q: str) -> dict[str, Any]: + return {"who": "user", "text": q} + + +def _brain(text: str = "Grounded mock brain reply.") -> dict[str, Any]: + return {"who": "brain", "text": text} + + +def _save_chat(page: Page, app_url: str, messages: list[dict[str, Any]]) -> dict[str, Any]: + """Save one conversation as the signed-in admin (``POST /api/chats``) + and return the 201 body. ``updated_at`` is the API's stamp (server + ``now()`` at INSERT) — the save ORDER is what makes the chip-walk + order deterministic in the state tests.""" + r = page.request.post( + f"{app_url}/api/chats", + data=json.dumps({"messages": messages}), + headers={"Content-Type": "application/json"}, + timeout=10_000, + ) + assert r.status == 201, r.text + return r.json() + + +def _api_suggestions(page: Page, app_url: str) -> list[str]: + # Phase 79: the endpoint is require_user-gated — the request rides + # the page's signed-in context (each test signs in above). + r = page.request.get(f"{app_url}/api/suggestions", timeout=10) + assert r.status == 200, r.text + return r.json()["suggestions"] def _chip_locator(page: Page) -> Any: return page.locator("#suggestions .suggestion-chip") -def test_onboarding_chips_render( - page: Page, app_url: str, mock_llm: int, db_ready: None +def _chip_texts(page: Page) -> list[str]: + return [c.strip() for c in _chip_locator(page).all_inner_texts()] + + +def test_seed_state_chips_are_the_builtin_default( + page: Page, app_url: str, db_ready: None ) -> None: - _seed_kb(mock_llm) + """Fresh deployment (no saved chats) → the chip row is EXACTLY the + built-in seed list — texts, count, and order — rendered in + ``#suggestions`` (role="list") as accessible buttons, exactly as + the phase-05 component contract.""" page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") # Accessible group: role=list + a name screen readers can announce. group = page.locator("#suggestions") expect(group).to_have_attribute("role", "list") expect(group).to_have_attribute("aria-label", "Suggested questions") - # 3+ visible chips, real buttons, each with non-empty text — and the - # texts match what the API returned (chips are drawn from the endpoint). chips = _chip_locator(page) expect(chips.first).to_be_visible(timeout=30_000) - assert chips.count() >= 3 - api_texts = _api_suggestions(app_url) + # The EXACT seed list, in order (the E2E pin — see the SEED literal). + assert _chip_texts(page) == SEED + # ...drawn from the endpoint (the API returns the same exact list). + assert _api_suggestions(page, app_url) == SEED + + # Real buttons, each with non-empty text, one per seed entry. + assert chips.count() == len(SEED) for i in range(chips.count()): chip = chips.nth(i) expect(chip).to_be_visible() expect(chip).to_have_attribute("type", "button") expect(chip).to_have_attribute("role", "listitem") - text = chip.inner_text().strip() - assert text, "every chip needs non-empty label text" - assert text in api_texts - assert len(set(api_texts)) >= 3 + assert chip.inner_text().strip(), "every chip needs non-empty label text" # Chips live in the empty state, which is visible before any message. expect(page.locator("#empty-state")).to_be_visible() @@ -115,17 +223,136 @@ def test_onboarding_chips_render( assert box is not None and box["height"] >= 44 +def test_last_three_questions_state(page: Page, app_url: str, db_ready: None) -> None: + """5 user questions across two saved chats (the older one saved + FIRST — the API stamps ``updated_at`` at INSERT) → a fresh page + load shows EXACTLY the 3 newest questions, newest-first: the + newer chat is walked first, then the older chat newest-first.""" + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + old_q = [ + "How did I install the GitLab runner on the Proxmox node?", + "Which disk holds the Borg backup archives?", + "How is the nftables firewall rule set ordered?", + ] + new_q = [ + "What TLS termination does Traefik do for homelab.local?", + "Which provider is the primary DNS for reeseapps.com?", + ] + + # The OLDER chat first: the API stamps ``updated_at`` (server + # now()), so save order IS walk order. The short pause keeps the + # two stamps strictly apart (and the assert below pins that the + # order the walk sees is the order the test intended). + older = _save_chat( + page, + app_url, + [ + _user(old_q[0]), _brain(), + _user(old_q[1]), _brain(), + _user(old_q[2]), _brain(), + ], + ) + time.sleep(0.05) + newer = _save_chat( + page, + app_url, + [ + _user(new_q[0]), _brain(), + _user(new_q[1]), _brain(), + ], + ) + assert datetime.fromisoformat(newer["updated_at"]) > datetime.fromisoformat( + older["updated_at"] + ), "the two API-stamped updated_at values must be strictly ordered" + + # A FRESH page load (a new boot fetch, not the pre-save boot): + # the chips are exactly the 3 newest questions, newest first. + page.goto(app_url + "/") + chips = _chip_locator(page) + expect(chips.first).to_be_visible(timeout=30_000) + expected = [new_q[1], new_q[0], old_q[2]] + assert _chip_texts(page) == expected + assert _api_suggestions(page, app_url) == expected + # The two older questions (and everything seed-shaped) are gone. + assert old_q[0] not in _chip_texts(page) + assert old_q[1] not in _chip_texts(page) + + +def test_partial_state_no_seed_topup(page: Page, app_url: str, db_ready: None) -> None: + """Exactly 2 saved questions deployment-wide → EXACTLY 2 chips + (newest first) — NO mixing/top-up with the seed (the A6 + contract, visible in the UI).""" + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + a = "How do I rotate the WireGuard keys on the VPN node?" + b = "What cron schedule runs the restic prune?" + _save_chat(page, app_url, [_user(a), _brain()]) + time.sleep(0.05) + _save_chat(page, app_url, [_user(b), _brain()]) + + page.goto(app_url + "/") + chips = _chip_locator(page) + expect(chips.first).to_be_visible(timeout=30_000) + assert chips.count() == 2, "exactly 2 chips — the row is never padded toward 3" + texts = _chip_texts(page) + assert texts == [b, a] + assert not (set(texts) & set(SEED)), "no seed text may appear once a question is saved" + + +def test_new_chat_refetches_the_chips(page: Page, app_url: str, db_ready: None) -> None: + """The row is never stale: boot with the seed chips → save a chat + whose newest question is Q via the API → click New chat + (``#new-chat-btn``) → the empty state comes back with the + REFETCHED row (exactly Q — the deployment now has one saved + question), and the request log shows a SECOND + ``GET /api/suggestions`` (the boot fetch was the first).""" + page.set_default_timeout(30_000) + sugg_gets: list[float] = [] + + def on_request(req: Request) -> None: + if req.url.endswith("/api/suggestions"): + sugg_gets.append(time.monotonic()) + + page.on("request", on_request) + login(page, app_url, next="/") + + chips = _chip_locator(page) + expect(chips.first).to_be_visible(timeout=30_000) + assert _chip_texts(page) == SEED, "boot state: the seed row" + assert len(sugg_gets) == 1, "exactly one GET /api/suggestions at boot" + + q = "Which service fronts the Pi-hole DNS on the network?" + _save_chat(page, app_url, [_user(q), _brain()]) + + clicked_at = time.monotonic() + page.click("#new-chat-btn") + + # The refetch re-renders #suggestions in place: the 4 seed chips + # are replaced by exactly Q (the partial state, live). + expect(chips).to_have_count(1, timeout=15_000) + expect(chips.first).to_have_text(q, timeout=15_000) + assert len(sugg_gets) == 2, "New chat triggered the refetch" + assert sugg_gets[1] > clicked_at, "the second GET is AFTER the click — the refetch" + + def test_chip_click_submits( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: + """Carried over (phase 05, unchanged semantics): one tap = one + question — the click fills AND submits; a grounded mock reply + follows. The chip submitted is the SEED row's first entry (the + autouse fixture guarantees the seed state).""" _seed_kb(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") first = _chip_locator(page).first expect(first).to_be_visible(timeout=30_000) chip_text = first.inner_text().strip() - assert chip_text + assert chip_text == SEED[0] # One tap = one question: the click fills AND submits. first.click() @@ -148,9 +375,12 @@ def test_chip_click_submits( def test_chips_keyboard_accessible( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: + """Carried over (phase 05, unchanged semantics): the first chip is + keyboard-reachable BEFORE the composer input (skip-link + nav + links come first), and Enter activates it — submitting.""" _seed_kb(mock_llm) page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") first = _chip_locator(page).first expect(first).to_be_visible(timeout=30_000) chip_text = first.inner_text().strip() @@ -193,13 +423,16 @@ def test_chips_keyboard_accessible( def test_chips_mobile_row( - browser: Browser, app_url: str, mock_llm: int, db_ready: None + browser: Browser, app_url: str, db_ready: None ) -> None: - _seed_kb(mock_llm) + """Carried over (phase 05, unchanged semantics): on mobile (375px) + the row is a single horizontally scrollable line — the seed state + (4 chips) overflows into scroll, nothing wraps, chips stay + >=44px tall on one line.""" page = browser.new_page(viewport={"width": 375, "height": 720}) try: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") row = page.locator("#suggestions") expect(row).to_be_visible(timeout=30_000) diff --git a/tests/e2e/test_summary_in_viewer.py b/tests/e2e/test_summary_in_viewer.py index b4a8089..1930489 100644 --- a/tests/e2e/test_summary_in_viewer.py +++ b/tests/e2e/test_summary_in_viewer.py @@ -24,8 +24,11 @@ This phase only surfaces the stored field: the content endpoint returns ``summary`` (task 01) and the shared ``renderDocument`` core draws the labeled ``.doc-summary`` panel above the content on BOTH surfaces (task 02) — the full-page viewer and the chat/sources modal. The tests assert -exactly that contract, plus the phase-16 soft rule: the content -endpoint stays public (anonymous fetch → 200, no admin cookie needed). +exactly that contract. Phase 79 supersedes the phase-16 soft rule: the +content endpoint is ``require_user``-gated, so the viewer surfaces and +the API shape pin run under a signed-in session (the shape itself — +``summary`` for the yaml, ``null`` for the markdown control — is +unchanged). """ from __future__ import annotations @@ -140,6 +143,7 @@ def test_full_page_shows_summary_and_original_together( cannot contain, is rendered in the raw ``<pre>``.""" _reset_db_and_import(mock_llm) page.set_default_timeout(30_000) + login(page, app_url, next="/") # phase 79: the viewer content is gated digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH) page.goto(f"{app_url}/document.html?source={SOURCE}&path={YAML_URL_PATH}") @@ -279,6 +283,7 @@ def test_markdown_doc_has_no_summary_panel( before (first child of the content container is the doc body).""" _reset_db_and_import(mock_llm) page.set_default_timeout(30_000) + login(page, app_url, next="/") # phase 79: the viewer content is gated # Full page: no panel, markdown column untouched. page.goto(f"{app_url}/document.html?source={SOURCE}&path={MD_URL_PATH}") @@ -294,7 +299,8 @@ def test_markdown_doc_has_no_summary_panel( assert order == ["doc-md"], f"markdown doc gained children: {order}" # Modal: same story — no panel, .doc-md is the sole content child. - login(page, app_url) + # (the session is already signed in — the form login above) + page.goto(f"{app_url}/sources.html") row = page.locator("#docs-tbody tr", has_text=MD_PATH) expect(row).to_have_count(1) row.locator("td:nth-child(2) a.doc-link").click() @@ -312,18 +318,21 @@ def test_markdown_doc_has_no_summary_panel( # --------------------------------------------------------------------------- -# 4. API shape (cheap, via the page context's request — still anonymous) +# 4. API shape (cheap, via the page context's request — signed in since +# phase 79 gated the endpoint) # --------------------------------------------------------------------------- -def test_content_api_summary_shape_anonymous( +def test_content_api_summary_shape( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """``GET /api/documents/content`` carries ``summary`` — the string - for the summarized yaml, ``null`` for the markdown control — and - stays PUBLIC: no admin cookie is set anywhere in this test, so both - 200s prove the phase-16 soft rule (viewer public) is unchanged.""" + for the summarized yaml, ``null`` for the markdown control. Phase 79 + superseded the phase-16 soft rule: the endpoint is require_user, so + the pin runs under the form-login session (the shape itself is + unchanged).""" _reset_db_and_import(mock_llm) + login(page, app_url, next="/") digest_line, _ = _summary_lines(SOURCE, YAML_PATH) expected_summary = f"{digest_line}\nSource: {SOURCE}/{YAML_PATH}" @@ -331,7 +340,7 @@ def test_content_api_summary_shape_anonymous( resp = page.context.request.get( f"{app_url}/api/documents/content?source={SOURCE}&path={YAML_URL_PATH}" ) - assert resp.status == 200, "anonymous viewer access must stay public" + assert resp.status == 200 body = resp.json() assert body["source"] == SOURCE assert body["path"] == YAML_PATH @@ -343,7 +352,7 @@ def test_content_api_summary_shape_anonymous( resp_md = page.context.request.get( f"{app_url}/api/documents/content?source={SOURCE}&path={MD_URL_PATH}" ) - assert resp_md.status == 200, "anonymous viewer access must stay public" + assert resp_md.status == 200 md_body = resp_md.json() assert md_body["format"] == "md" assert md_body["summary"] is None, "markdown docs never carry a summary" diff --git a/tests/e2e/test_sync_button.py b/tests/e2e/test_sync_button.py index 3590ce4..1fdfd85 100644 --- a/tests/e2e/test_sync_button.py +++ b/tests/e2e/test_sync_button.py @@ -44,13 +44,18 @@ from app.models import KbOverview from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, - APP_PORT, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module app binds its own port instead +# (a same-port second uvicorn dies on bind and would drive the wrong +# server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCBTN", "8132")) APP_URL = f"http://127.0.0.1:{APP_PORT}" #: The unique sentinel inside the fixture doc (task 03 step 1) — its @@ -162,7 +167,10 @@ def _truncate_kb() -> None: """Fresh KB per test (the E2E isolation pattern): the sync's counts and the ``kb_overview`` row must be the sync's own doing.""" with SessionLocal() as db: - db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview")) + # git_sources too: the shared Postgres may carry leftover + # registry rows from other suites, and effective_sources prefers + # the DB list over this app's BOR_GIT_SOURCES env fallback. + db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources")) db.commit() diff --git a/tests/e2e/test_sync_model_down.py b/tests/e2e/test_sync_model_down.py index b6845fa..b8a3aa3 100644 --- a/tests/e2e/test_sync_model_down.py +++ b/tests/e2e/test_sync_model_down.py @@ -65,13 +65,19 @@ from app.models import KbOverview from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, - APP_PORT, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module's healthy app binds its own port +# instead (a same-port second uvicorn dies on bind and would drive the +# wrong server); the dead-model app stays derived from it. Env- +# overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCDOWN", "8134")) APP_URL = f"http://127.0.0.1:{APP_PORT}" #: The dead-model app's port — distinct so the isolated run never #: clashes with the session app (``APP_PORT``) or the mock (8901). @@ -241,9 +247,12 @@ def app_url(healthy_app_url: str) -> str: def _truncate_kb() -> None: """Fresh KB per test (the E2E isolation pattern): any counts and the - ``kb_overview`` row the healthy run produces are its own doing.""" + ``kb_overview`` row the healthy run produces are its own doing. + ``git_sources`` too — the shared Postgres may carry leftover + registry rows that would take precedence over the apps' env + fallback repos.""" with SessionLocal() as db: - db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview")) + db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources")) db.commit() diff --git a/tests/e2e/test_sync_upload_progress.py b/tests/e2e/test_sync_upload_progress.py index 9c1cc3a..d87fc5f 100644 --- a/tests/e2e/test_sync_upload_progress.py +++ b/tests/e2e/test_sync_upload_progress.py @@ -102,7 +102,6 @@ from app.models import GitSource from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, - APP_PORT, MOCK_PORT, SESSION_SECRET, USE_REAL_LLM, @@ -110,6 +109,12 @@ from e2e.conftest import ( ) REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module app binds its own port instead +# (a same-port second uvicorn dies on bind and would drive the wrong +# server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCUP", "8133")) APP_URL = f"http://127.0.0.1:{APP_PORT}" GIT_SOURCES_URL = "/git-sources.html" diff --git a/tests/e2e/test_thinking_display.py b/tests/e2e/test_thinking_display.py index 828e16e..580dba0 100644 --- a/tests/e2e/test_thinking_display.py +++ b/tests/e2e/test_thinking_display.py @@ -42,6 +42,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" @@ -134,7 +135,7 @@ def test_thinking_block_streams_open_then_collapses( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", THINK_QUESTION) page.evaluate("() => document.querySelector('#composer').requestSubmit()") expect(page.locator(".msg.user .bubble").last).to_contain_text(THINK_QUESTION) @@ -172,7 +173,7 @@ def test_thinking_block_streams_open_then_collapses( def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") send_and_wait(page, THINK_QUESTION) last = page.locator(".msg.brain").last @@ -206,7 +207,7 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) - def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: None) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") send_and_wait(page, THINK_QUESTION) # The live block is collapsed; capture what it shows and what the @@ -245,7 +246,7 @@ def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: Non def test_no_thinking_block_without_trigger(page: Page, app_url: str, seeded_kb: None) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") send_and_wait(page, PLAIN_QUESTION) # No trigger → no thinking events → no block anywhere on the page. @@ -265,7 +266,7 @@ def test_no_thinking_block_without_trigger(page: Page, app_url: str, seeded_kb: def test_thinking_with_deflection(page: Page, app_url: str, seeded_kb: None) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") send_and_wait(page, THINK_DEFLECT_QUESTION) last = page.locator(".msg.brain").last diff --git a/tests/e2e/test_thinking_scroll.py b/tests/e2e/test_thinking_scroll.py index 54f9d1a..959ed2a 100644 --- a/tests/e2e/test_thinking_scroll.py +++ b/tests/e2e/test_thinking_scroll.py @@ -94,6 +94,7 @@ from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient +from e2e.auth_helpers import login from e2e.mock_llm import compose_thinking REPO = Path(__file__).resolve().parents[2] @@ -288,7 +289,7 @@ def test_thinking_window_user_scrollable( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") submit(page, HESITATE_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) @@ -391,7 +392,7 @@ def test_thinking_window_follows_while_pinned( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") submit(page, HESITATE_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) @@ -462,7 +463,7 @@ def test_thinking_window_stops_on_scroll_up( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") submit(page, HESITATE_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) @@ -523,7 +524,7 @@ def test_thinking_window_resumes_on_return( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") submit(page, HESITATE_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) @@ -595,7 +596,7 @@ def test_thinking_window_css_contract( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") send_and_wait(page, THINK_QUESTION) details = page.locator(".msg.brain").last.locator("details.thinking") @@ -625,7 +626,7 @@ def test_answer_bubble_still_scrollable( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(45_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", LONG_QUESTION) page.click("#send-btn") expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION) @@ -690,7 +691,7 @@ def test_restored_collapsed_thinking_unaffected( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") send_and_wait(page, THINK_QUESTION) details = page.locator(".msg.brain").last.locator("details.thinking") @@ -734,7 +735,7 @@ def test_thinking_window_follows_across_paragraph_breaks( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") submit(page, PARAS_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(PARAS_QUESTION) diff --git a/tests/e2e/test_toggle_after_nav.py b/tests/e2e/test_toggle_after_nav.py new file mode 100644 index 0000000..c139fff --- /dev/null +++ b/tests/e2e/test_toggle_after_nav.py @@ -0,0 +1,39 @@ +"""Quick diagnostic: does the toggle still work after SPA navigation?""" +from playwright.sync_api import expect + +from e2e.auth_helpers import login + + +def test_toggle_after_spa_nav(browser, app_url, db_ready): + """After SPA navigation, the hamburger toggle should still open the menu.""" + page = browser.new_page(viewport={"width": 375, "height": 812}) + try: + login(page, app_url, next="/") + # Wait for whoami to settle (admin) + page.wait_for_function( + "() => !document.querySelector('#nav-sources').hasAttribute('hidden')", + timeout=10_000, + ) + + # Open the menu + page.click("#nav-toggle") + expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") + + # Navigate to a different view via SPA + page.click("#nav-sources") + expect(page).to_have_url(app_url + "/sources.html", timeout=15_000) + + # Menu should be closed + expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") + + # NOW try to open the menu again - THIS IS THE BUG CHECK + page.click("#nav-toggle") + try: + expect(page.locator("#nav-toggle")).to_have_attribute( + "aria-expanded", "true", timeout=3000 + ) + print("PASS: Toggle still works after SPA navigation") + except Exception as e: + print(f"FAIL: Toggle does NOT work after SPA navigation: {e}") + finally: + page.close() diff --git a/tests/e2e/test_tool_path_teaching.py b/tests/e2e/test_tool_path_teaching.py index 0e8797a..58dbfa3 100644 --- a/tests/e2e/test_tool_path_teaching.py +++ b/tests/e2e/test_tool_path_teaching.py @@ -70,6 +70,7 @@ from sqlalchemy.orm import Session from app.db import SessionLocal from app.models import Chunk, Document +from e2e.auth_helpers import login from tests.e2e.mock_llm import ( LS_TEACH_TRIGGER, TOOLS_TRIGGER, @@ -367,7 +368,7 @@ def test_ls_misuse_self_corrects_to_noarg_listing( ) -> None: page.set_default_timeout(30_000) _reset_db_fixture() - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) _submit(page, LS_TEACH_QUESTION) @@ -421,7 +422,7 @@ def test_plain_tool_flow_not_swallowed_by_new_trigger( ) -> None: page.set_default_timeout(30_000) _reset_db_fixture() - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) # Turn 1 — the LS-TEACH flow (the incident's misuse → the diff --git a/tests/e2e/test_tool_scaffolding_guardrails.py b/tests/e2e/test_tool_scaffolding_guardrails.py index 80dc4e2..4fab32f 100644 --- a/tests/e2e/test_tool_scaffolding_guardrails.py +++ b/tests/e2e/test_tool_scaffolding_guardrails.py @@ -70,6 +70,7 @@ from sqlalchemy import select, text from app.db import SessionLocal from app.models import QueryLog +from e2e.auth_helpers import login from tests.e2e.mock_llm import SCAFFOLD_RECOVERY_ANSWER, compose_answer # -------------------------------------------------------------------------- @@ -291,7 +292,7 @@ def test_recovery_strips_scaffolding_and_streams_clean_answer( ) -> None: page.set_default_timeout(30_000) _reset_db_empty() - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) _submit(page, RECOVERY_Q) @@ -345,7 +346,7 @@ def test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable( ) -> None: page.set_default_timeout(30_000) _reset_db_empty() - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) _submit(page, TERMINAL_Q) @@ -406,7 +407,7 @@ def test_plain_turn_never_recovers_and_streams_byte_clean( ) -> None: page.set_default_timeout(30_000) _reset_db_empty() - page.goto(app_url) + login(page, app_url, next="/") _install_sse_hook(page) _submit(page, PLAIN_Q) diff --git a/tests/e2e/test_whole_document_context.py b/tests/e2e/test_whole_document_context.py index aef1fa2..e888df0 100644 --- a/tests/e2e/test_whole_document_context.py +++ b/tests/e2e/test_whole_document_context.py @@ -40,6 +40,7 @@ from app.models import Chunk, Document, QueryLog from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient from app.rag.retriever import TRUNCATION_MARKER +from e2e.auth_helpers import login from tests.e2e.mock_llm import embed_text REPO = Path(__file__).resolve().parents[2] @@ -172,7 +173,7 @@ def _run_in_thread(coro: Any) -> Any: def _ask(page: Page, app_url: str, question: str) -> Any: """Submit *question* and wait for the streamed brain bubble.""" page.set_default_timeout(30_000) - page.goto(app_url) + login(page, app_url, next="/") page.fill("#message-input", question) page.click("#send-btn") bubble = page.locator(".msg.brain .bubble") diff --git a/tests/e2e/test_wide_desktop_column.py b/tests/e2e/test_wide_desktop_column.py index de0d3c6..40a0159 100644 --- a/tests/e2e/test_wide_desktop_column.py +++ b/tests/e2e/test_wide_desktop_column.py @@ -278,7 +278,7 @@ def test_chat_column_wide_vs_base( at 1280×800 — the base below the breakpoint.""" wide = browser.new_page(viewport=WIDE_VIEWPORT) try: - wide.goto(f"{app_url}/") + login(wide, app_url, next="/") # phase 79: the chips need a session wide.locator("#suggestions .suggestion-chip").first.wait_for( state="visible", timeout=10_000 ) @@ -289,7 +289,7 @@ def test_chat_column_wide_vs_base( base = browser.new_page(viewport=BASE_VIEWPORT) try: - base.goto(f"{app_url}/") + login(base, app_url, next="/") # phase 79: the chips need a session base.locator("#suggestions .suggestion-chip").first.wait_for( state="visible", timeout=10_000 ) @@ -316,6 +316,7 @@ def test_document_column_wide_vs_base( wide = browser.new_page(viewport=WIDE_VIEWPORT) try: + login(wide, app_url, next="/") # phase 79: the viewer content is gated wide.goto(app_url + FIXTURE_DOC_URL) expect(wide.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000) expect(wide.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000) @@ -331,6 +332,7 @@ def test_document_column_wide_vs_base( base = browser.new_page(viewport=BASE_VIEWPORT) try: + login(base, app_url, next="/") # phase 79: the viewer content is gated base.goto(app_url + FIXTURE_DOC_URL) expect(base.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000) expect(base.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000) @@ -410,7 +412,7 @@ def test_narrow_unchanged( content box instead, so 736px is the discriminator).""" phone = browser.new_page(viewport={"width": 360, "height": 740}) try: - phone.goto(f"{app_url}/") + login(phone, app_url, next="/") # phase 79: the chips need a session phone.locator("#suggestions .suggestion-chip").first.wait_for( state="visible", timeout=10_000 ) @@ -421,7 +423,7 @@ def test_narrow_unchanged( tablet = browser.new_page(viewport={"width": 900, "height": 800}) try: - tablet.goto(f"{app_url}/") + login(tablet, app_url, next="/") # phase 79: the chips need a session tablet.locator("#suggestions .suggestion-chip").first.wait_for( state="visible", timeout=10_000 ) diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 4694ec5..6f46c5b 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -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 diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index d0d6a8c..54288f9 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -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) diff --git a/tests/integration/test_caching_revalidation.py b/tests/integration/test_caching_revalidation.py index 4963132..3dcec69 100644 --- a/tests/integration/test_caching_revalidation.py +++ b/tests/integration/test_caching_revalidation.py @@ -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 } diff --git a/tests/integration/test_chat_api.py b/tests/integration/test_chat_api.py index 6bd0502..3d6bb1e 100644 --- a/tests/integration/test_chat_api.py +++ b/tests/integration/test_chat_api.py @@ -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 diff --git a/tests/integration/test_document_content.py b/tests/integration/test_document_content.py index 687ebe3..e4acbcc 100644 --- a/tests/integration/test_document_content.py +++ b/tests/integration/test_document_content.py @@ -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 diff --git a/tests/integration/test_kb_overview_api.py b/tests/integration/test_kb_overview_api.py index 7dda095..feff291 100644 --- a/tests/integration/test_kb_overview_api.py +++ b/tests/integration/test_kb_overview_api.py @@ -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. diff --git a/tests/integration/test_migration_0012.py b/tests/integration/test_migration_0012.py new file mode 100644 index 0000000..8a9bdbb --- /dev/null +++ b/tests/integration/test_migration_0012.py @@ -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" + ) diff --git a/tests/integration/test_suggestions_api.py b/tests/integration/test_suggestions_api.py new file mode 100644 index 0000000..bb949c8 --- /dev/null +++ b/tests/integration/test_suggestions_api.py @@ -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"} diff --git a/tests/integration/test_tokens_api.py b/tests/integration/test_tokens_api.py new file mode 100644 index 0000000..7de0d43 --- /dev/null +++ b/tests/integration/test_tokens_api.py @@ -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"]] diff --git a/tests/unit/test_api_tokens_model.py b/tests/unit/test_api_tokens_model.py new file mode 100644 index 0000000..276d5a3 --- /dev/null +++ b/tests/unit/test_api_tokens_model.py @@ -0,0 +1,94 @@ +"""Unit tests: the ApiToken model registers the api_tokens contract +(phase 79, task 01). + +Schema-level assertions without a live DB (the +``tests/unit/test_models.py`` doc_drafts precedent): the table name, the +column set + nullability, the UNIQUE ``token_hash`` (two tokens with the +same hash must collide — the unique constraint backing +``ix_api_tokens_token_hash``), and a display-only, non-unique +``label``. The real duplicate-rejection behaviour is pinned against the +dev DB in ``tests/integration/test_migration_0012.py``. +""" +from __future__ import annotations + +from sqlalchemy import PrimaryKeyConstraint, String, UniqueConstraint +from sqlalchemy.schema import Table + +import app.models # noqa: F401 (import registers all tables on Base.metadata) +from app.db import Base + + +def _table() -> Table: + return Base.metadata.tables["api_tokens"] + + +def test_api_tokens_table_registered() -> None: + assert "api_tokens" in Base.metadata.tables, "ApiToken must register api_tokens" + assert _table().name == "api_tokens" + + +def test_api_tokens_column_contract() -> None: + """The column set + nullability: ``id`` UUID PK; ``label`` / + ``token_hash`` NOT NULL; ``created_at`` NOT NULL with a server + default (now()); ``last_used_at`` / ``revoked_at`` NULL until the + service (tasks 02/03) sets them.""" + tok = _table() + assert set(tok.c.keys()) == { + "id", "label", "token_hash", "created_at", "last_used_at", "revoked_at", + } + + assert tok.c["id"].primary_key is True, "api_tokens.id must be the PK" + assert tok.c["id"].nullable is False, "api_tokens.id must be NOT NULL" + + assert tok.c["label"].nullable is False, "label must be NOT NULL" + label_type = tok.c["label"].type + assert isinstance(label_type, String), "label must be String(120)" + assert label_type.length == 120, "label must be String(120)" + + assert tok.c["token_hash"].nullable is False, "token_hash must be NOT NULL" + hash_type = tok.c["token_hash"].type + assert isinstance(hash_type, String), "token_hash must be String(64)" + assert hash_type.length == 64, ( + "token_hash must be String(64) — a sha256 hex digest" + ) + + assert tok.c["created_at"].nullable is False, "created_at must be NOT NULL" + assert tok.c["created_at"].server_default is not None, ( + "created_at needs a server default (now())" + ) + + for name in ("last_used_at", "revoked_at"): + assert tok.c[name].nullable is True, ( + f"{name} must be NULL until first use / revocation" + ) + + +def test_api_tokens_token_hash_is_unique() -> None: + """Two tokens with the same hash must collide: a UNIQUE constraint + covers exactly ``token_hash`` (the backing constraint of the + ``ix_api_tokens_token_hash`` unique index — the stored credential + is the lookup key).""" + tok = _table() + uq = [ + c + for c in tok.constraints + if isinstance(c, UniqueConstraint) + and not isinstance(c, PrimaryKeyConstraint) + and {col.name for col in c.columns} == {"token_hash"} + ] + assert uq, "api_tokens must be unique on (token_hash) — the stored credential" + + +def test_api_tokens_label_is_not_unique() -> None: + """``label`` is the hand-out name — display-only: the column itself + is not unique and no unique constraint may cover it (two tokens can + share a label, e.g. two "alice" tokens issued at different times).""" + tok = _table() + assert not tok.c["label"].unique, "label must not be unique" + covering = [ + c + for c in tok.constraints + if isinstance(c, UniqueConstraint) + and any(col.name == "label" for col in c.columns) + ] + assert not covering, "no unique constraint may cover api_tokens.label" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 0402942..9c96b2c 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1,11 +1,17 @@ -"""Unit tests: single-admin auth (phase 16; A10 revised). +"""Unit tests: auth (phase 16 single-admin; phase 79 token users). Covers the config gate (fail-loud, including via ``create_app``), the constant-time password check, the ``require_admin`` dependency, the -whoami payload shape, and the sign_in/sign_out session semantics. +phase-79 ``require_user`` matrix (admin pass, live token pass, revoked / +missing-row 401 + session keys popped, anonymous 401, admin+user +coexistence), the three-role whoami payload shape, and the +sign_in/sign_out session semantics. """ from __future__ import annotations +import uuid +from datetime import UTC, datetime + import pytest from fastapi import HTTPException from starlette.middleware.sessions import Session @@ -18,9 +24,11 @@ from app.core.auth import ( check_password, ensure_admin_configured, require_admin, + require_user, sign_in, sign_out, ) +from app.models import ApiToken from app.schemas import WhoamiResponse @@ -122,7 +130,117 @@ def test_require_admin_403s_anonymous(session: dict) -> None: assert exc.value.detail == "admin only" -# ---------- whoami payload shape ---------- +# ---------- require_user (phase 79: admin OR live token, else 401) ---------- + + +class _FakeTokenResult: + """``execute()`` result for the fake db: ``.scalars().first()`` + yields the one fixed row (or ``None``).""" + + def __init__(self, row: ApiToken | None) -> None: + self._row = row + + def scalars(self) -> _FakeTokenResult: + return self + + def first(self) -> ApiToken | None: + return self._row + + +class _FakeTokenDb: + """Session stand-in for ``require_user``'s PK lookup: counts the + queries it is given and always returns the one fixed row — enough to + pin the matrix without a database (the admin path must NOT query at + all).""" + + def __init__(self, row: ApiToken | None) -> None: + self._row = row + self.queries = 0 + + def execute(self, _stmt: object) -> _FakeTokenResult: + self.queries += 1 + return _FakeTokenResult(self._row) + + +def _token_row(**kwargs: object) -> ApiToken: + base: dict[str, object] = { + "id": uuid.uuid4(), + "label": "alice", + "token_hash": "0" * 64, + "created_at": datetime.now(UTC), + } + base.update(kwargs) + return ApiToken(**base) # pyright: ignore[reportCallIssue] + + +def test_require_user_admin_session_passes_without_any_db_lookup() -> None: + """An admin ALWAYS passes — token state irrelevant, no row fetched + (the admin+user coexistence case: admin wins outright).""" + db = _FakeTokenDb(None) # would be a dead row if it were ever consulted + require_user( + _request_with_session( + admin=True, user=True, user_token_id=str(uuid.uuid4()) + ), + db, # pyright: ignore[reportArgumentType] + ) # no exception + assert db.queries == 0 + + +def test_require_user_active_token_session_passes() -> None: + row = _token_row() + db = _FakeTokenDb(row) + require_user( + _request_with_session(user=True, user_token_id=str(row.id)), + db, # pyright: ignore[reportArgumentType] + ) # no exception + assert db.queries == 1 # the live PK lookup ran + + +@pytest.mark.parametrize( + ("row", "token_id"), + [ + (None, str(uuid.uuid4())), # the row is gone (deleted out-of-band) + (_token_row(revoked_at=datetime.now(UTC)), str(uuid.uuid4())), # revoked + (None, "not-a-uuid"), # corrupt session — no valid row id at all + ], + ids=["missing-row", "revoked", "corrupt-token-id"], +) +def test_require_user_dead_token_session_401s_and_pops_both_keys( + row: ApiToken | None, token_id: str +) -> None: + """Row missing / revoked / unresolvable → 401 ``authentication + required`` AND the dead session is dropped NOW (both user keys + popped, so the next whoami is anonymous).""" + request = _request_with_session(user=True, user_token_id=token_id) + with pytest.raises(HTTPException) as exc: + require_user(request, _FakeTokenDb(row)) # pyright: ignore[reportArgumentType] + assert exc.value.status_code == 401 + assert exc.value.detail == "authentication required" + assert "user" not in request.session + assert "user_token_id" not in request.session + + +def test_require_user_anonymous_401s() -> None: + with pytest.raises(HTTPException) as exc: + require_user( + _request_with_session(), _FakeTokenDb(None) # pyright: ignore[reportArgumentType] + ) + assert exc.value.status_code == 401 + assert exc.value.detail == "authentication required" + + +def test_require_user_user_key_without_token_id_401s_and_pops() -> None: + """A ``user`` key with no ``user_token_id`` at all is a dead session + too — same 401, both keys dropped.""" + request = _request_with_session(user=True) + with pytest.raises(HTTPException) as exc: + require_user(request, _FakeTokenDb(None)) # pyright: ignore[reportArgumentType] + assert exc.value.status_code == 401 + assert exc.value.detail == "authentication required" + assert "user" not in request.session + + +# ---------- whoami payload shape (three roles, phase 79) ---------- def test_whoami_anonymous_payload() -> None: @@ -136,6 +254,21 @@ def test_whoami_admin_payload() -> None: assert body == WhoamiResponse(authenticated=True, role="admin") +def test_whoami_token_user_payload() -> None: + body = whoami(_request_with_session(user=True, user_token_id=str(uuid.uuid4()))) + assert body == WhoamiResponse(authenticated=True, role="user") + + +def test_whoami_admin_wins_when_both_roles_are_set() -> None: + """Coexistence is deliberate: a browser holding BOTH an admin and a + token session reports admin (the UI keys off role, the admin surface + stays open).""" + body = whoami( + _request_with_session(admin=True, user=True, user_token_id=str(uuid.uuid4())) + ) + assert body == WhoamiResponse(authenticated=True, role="admin") + + # ---------- sign_in / sign_out session semantics ---------- diff --git a/tests/unit/test_background_animation.py b/tests/unit/test_background_animation.py deleted file mode 100644 index dc34453..0000000 --- a/tests/unit/test_background_animation.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Unit: the phase-25 still-background contract — layer plumbing and the -phase-08 anchors (source pins). - -Phase 22 (owner report 2026-08-24) made the phase-08 background -perceptible: 60% grid-line alpha, a widened mask, a 60s one-cell grid -drift, and a 14s whole-layer glow breathe. The owner then reported -(2026-08-25, chat): the background "jitters down and to the right every -second and it slowly blinks brighter and darker. It should be smooth, -fluxuating, dimming and brightening, but not moving. Different bright -spots should slowly fade in and out." — the phase-22 design intent -(grid drift + whole-layer breathe) is superseded. - -The new design (styles.css, pure CSS, zero JS, no `filter` — A11): - - grid (body::before): a STATIC texture — the drift animation and its - keyframes are deleted (the 0.73px/s sub-pixel drift rasterizes as a - once-per-second down-right jitter); - - three independent soft glow spots — body::after (26s), html::before - (34s, -12s delay), html::after (42s, -23s delay) — each on its own - SLOW opacity-only fade (the whole-layer breathe keyframes are - deleted), so the total light fluxuates smoothly and irregularly; - LCM(26, 34, 42) = 4641s, so the composite pattern never repeats - within a viewing session. The 2026-08-28 rebrand recolored the - phase-08 indigo/cyan spots to the warm dark-red theme palette - (rose / orange / red) and the grid lines to the warm line tone — - structure (sizes, positions, alphas, periods) unchanged. - -This file keeps the generic layer-plumbing pins (the no-occlusion -contract, fixed / z-index -1 / pointer-events none — now across all -four layers) and the phase-08 no-blur/no-JS anchor. The full new -contract (no animation on the grid, opacity-only keyframes, the three -spot gradients, reduced motion across all four layers) is pinned in -tests/unit/test_background_no_motion.py; browser behavior is E2E-covered -by tests/e2e/test_background_no_motion.py (task 02). - -Story: .agents/user_stories/background-no-motion.md (supersedes -.agents/user_stories/background-animation.md). -""" -from __future__ import annotations - -import re -from pathlib import Path - -STYLES_CSS = ( - Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css" -) - -ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after") - - -def _css() -> str: - return STYLES_CSS.read_text(encoding="utf-8") - - -def _css_no_comments() -> str: - """styles.css with /* … */ comments stripped — for functional anchor - checks (filter/blur) that must not trip on explanatory comments.""" - return re.sub(r"/\*[\s\S]*?\*/", "", _css()) - - -def _rule_block(css: str, selector: str) -> str: - """Body of the first `selector { ... }` rule (top-level, no nesting).""" - rule = re.search( - r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css - ) - assert rule, f"styles.css must define a {selector} rule" - return rule.group(1) - - -def _grid_rule(css: str) -> str: - return _rule_block(css, "body::before") - - -def _glow_rule(css: str) -> str: - return _rule_block(css, "body::after") - - -def _bg_keyframes(css: str) -> dict[str, str]: - """Name → body for every @keyframes bg-* rule (balanced braces — - works for the one-line blocks and a multi-line reformat alike).""" - out: dict[str, str] = {} - for m in re.finditer(r"@keyframes (bg-[A-Za-z0-9-]+)\s*\{", css): - start, depth, i = m.end(), 1, m.end() - while i < len(css) and depth: - if css[i] == "{": - depth += 1 - elif css[i] == "}": - depth -= 1 - i += 1 - out[m.group(1)] = css[start:i - 1] - return out - - -# -------------------------------------------------------------------------- -# Layer plumbing — the no-occlusion contract (phase 08) must survive -# -------------------------------------------------------------------------- - - -def test_both_layers_are_fixed_zminus1_noninteractive() -> None: - """All four background layers stay behind the content and can never - intercept input: fixed, full-viewport, z-index -1, pointer-events - none (phase 25: html::before / html::after join body::before / - body::after as background layers — UI Structure Check: layers behind - content, no 360px overflow, since they are fixed; inset: 0).""" - for name, block in ( - ("body::before", _grid_rule(_css())), - ("body::after", _glow_rule(_css())), - ("html::before", _rule_block(_css(), "html::before")), - ("html::after", _rule_block(_css(), "html::after")), - ): - assert "position: fixed" in block, f"{name} must stay position:fixed" - assert "inset: 0" in block, f"{name} must stay full-viewport (inset: 0)" - assert "z-index: -1" in block, f"{name} must stay z-index:-1" - assert "pointer-events: none" in block, f"{name} must stay click-through" - assert "content: \"\"" in block, f"{name} must keep its pseudo content" - - -def test_html_owns_bg_and_body_stays_transparent() -> None: - """The no-occlusion contract: the visible page background lives on - <html>; <body> must remain transparent and non-stacking, or the - z-index:-1 layers (including the phase-25 html pseudo-layers, which - paint above the canvas and below body's content as the root stacking - context) are painted over (the phase-08 recipe).""" - html_block = _rule_block(_css(), "html") - assert "background: var(--bg)" in html_block, ( - "html must keep background: var(--bg) (the page canvas)" - ) - body_block = _rule_block(_css(), "body") - assert "background: transparent" in body_block, ( - "body must keep background: transparent so the layers show" - ) - # body must not gain a z-index/transform/opacity that would turn it - # into a stacking context trapping the negative-z-index layers. - for prop in ("z-index", "transform", "opacity", "filter"): - assert prop + ":" not in body_block, ( - f"body must not create a stacking context (found {prop})" - ) - - -# -------------------------------------------------------------------------- -# Grid layer — phase 25: a static texture (the drift is gone) -# -------------------------------------------------------------------------- - - -def test_grid_is_static_no_drift() -> None: - """body::before must carry NO animation — the phase-22 60s one-cell - drift (0.73px/s down-right) rasterized as a once-per-second jitter; - the owner wants no movement (2026-08-25). Its keyframes are deleted - too.""" - block = _grid_rule(_css()) - assert "animation" not in block, ( - "body::before must not animate (the no-movement contract)" - ) - assert "bg-grid-drift" not in _css(), ( - "@keyframes bg-grid-drift must be deleted" - ) - - -def test_grid_cells_and_line_contrast() -> None: - """44px cells with 1px lines at the phase-22 fixed 60% line alpha, - in the rebrand warm line tone (2026-08-28; the phase-22 indigo - value rgb(38 48 74 / 0.6) was recolored with the dark-red theme) - — the static texture keeps the values that made the grid readable - (see tests/unit/test_background_no_motion.py for the phase-25 - story).""" - block = _grid_rule(_css()) - assert "background-size: 44px 44px" in block - line = "linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)" - assert line in block, "grid must keep horizontal 1px lines at 60% line alpha" - assert ( - "linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)" - in block - ), "grid must keep vertical 1px lines at 60% line alpha" - assert "0.35" not in block, "the too-faint 35% line alpha must not return" - - -def test_grid_mask_widened_and_prefixed() -> None: - """The phase-22 mask: 140%×110% ellipse, fully visible to 40% of the - radius, faded out by 90% — the grid must read across most of the - viewport (phase-08's 120%×90%/25%/78% masked it to the top ~25%). - The -webkit- and standard mask-image must stay in lockstep.""" - block = _grid_rule(_css()) - mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)" - assert f"-webkit-mask-image: {mask};" in block - assert f"mask-image: {mask};" in block - - -# -------------------------------------------------------------------------- -# Glow layers — phase 25: three spots, each on its own slow opacity fade -# -------------------------------------------------------------------------- - - -def test_three_spots_run_own_slow_opacity_fades() -> None: - """The whole-layer breathe is replaced by three independent - opacity-only fades on distinct slow periods with negative delays (out - of phase): body::after 26s, html::before 34s -12s, html::after 42s - -23s. The old breathe keyframes are deleted.""" - assert "animation: bg-glow-a 26s ease-in-out infinite" in _glow_rule(_css()) - assert ( - "animation: bg-glow-b 34s ease-in-out -12s infinite" - in _rule_block(_css(), "html::before") - ) - assert ( - "animation: bg-glow-c 42s ease-in-out -23s infinite" - in _rule_block(_css(), "html::after") - ) - assert "bg-glow-breathe" not in _css(), ( - "@keyframes bg-glow-breathe must be deleted" - ) - - -def test_glow_keyframes_are_opacity_only() -> None: - """The no-movement contract: every bg-* keyframe block animates ONLY - opacity (no transform/scale, no background-position).""" - keyframes = _bg_keyframes(_css()) - assert set(keyframes) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"}, ( - "exactly three bg-glow-* keyframe blocks must exist" - ) - for name, body in keyframes.items(): - props = set(re.findall(r"([A-Za-z-]+)\s*:", body)) - assert props == {"opacity"}, ( - f"{name} must animate only opacity, found {sorted(props)}" - ) - - -def test_glow_spots_use_the_rebrand_warm_palette() -> None: - """The three spots keep their radii and positions from the phase-25 - layout (rose top-left on body::after, orange bottom-right on - html::before, red bottom-left 52rem at 14% 86% on html::after) but - wear the 2026-08-28 rebrand palette (warm dark-red theme; the - phase-08 indigo/cyan values are gone). All spots fade to - transparent at 62%.""" - assert ( - "radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), " - "transparent 62%)" in _glow_rule(_css()) - ) - assert ( - "radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), " - "transparent 62%)" in _rule_block(_css(), "html::before") - ) - assert ( - "radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), " - "transparent 62%)" in _rule_block(_css(), "html::after") - ) - - -# -------------------------------------------------------------------------- -# Phase-08 anchor: pure CSS, zero JS, no blur -# -------------------------------------------------------------------------- - - -def test_no_blur_no_js_in_background_layers() -> None: - """The phase-08 performance anchor: no `filter` (or any filter) on - any layer, and the motion is CSS-only — the three glow layers carry - an `animation:` shorthand (the grid is deliberately still in phase - 25), and nothing in styles.css references a script.""" - for name, block in ( - ("body::before", _grid_rule(_css())), - ("body::after", _glow_rule(_css())), - ("html::before", _rule_block(_css(), "html::before")), - ("html::after", _rule_block(_css(), "html::after")), - ): - assert "filter" not in block, f"{name} must not use any filter" - for name, block in ( - ("body::after", _glow_rule(_css())), - ("html::before", _rule_block(_css(), "html::before")), - ("html::after", _rule_block(_css(), "html::after")), - ): - assert "animation:" in block, f"{name} must be CSS-animated" - assert "blur" not in _css_no_comments(), ( - "no filter: blur anywhere in styles.css (phase-08 perf anchor)" - ) diff --git a/tests/unit/test_background_no_motion.py b/tests/unit/test_background_no_motion.py index d872173..b30adc1 100644 --- a/tests/unit/test_background_no_motion.py +++ b/tests/unit/test_background_no_motion.py @@ -1,99 +1,117 @@ -"""Unit: the phase-25 still-background contract (source pins). +"""Unit: the phase-78 static-background contract (source pins). -Owner report (2026-08-25, chat): the animated background "jitters down -and to the right every second and it slowly blinks brighter and darker. -It should be smooth, fluxuating, dimming and brightening, but not -moving. Different bright spots should slowly fade in and out." +Owner direction (TODO.md L4, recorded per AGENTS.md rule 3): "Remove the +animated css background, it's too resource intensive" — the three +opacity-fading glow spots (``body::after`` / ``html::before`` / +``html::after``), their glow keyframes, and the +``prefers-reduced-motion`` rule whose only job was stilling those layers +are deleted from ``styles.css``. The 44px grid texture on +``body::before`` STAYS — it is static (zero animation cost). -The diagnosis (`.agents/reports/25_background_no_motion/`) found both -root causes in the phase-22 design: - - "jitters down and to the right" = the grid's 44px/60s drift - (0.73px/s, diagonally down-right) — a 1px grid line translated - sub-pixel by sub-pixel rasterizes with per-frame stepping; - - "slowly blinks" = the whole-layer 14s opacity 0.85↔1 + - scale(1)↔scale(1.05) pulse — one synchronized pulse reads as a blink. +Supersedes the phase-25 fading-glow contract (superseded chain +08 → 25 → 78); the phase-25 unit source-pin suite +(``tests/unit/test_background_animation.py``) is deleted with its +premise (three fading glows on named keyframe cycles). -The fix (styles.css, pure CSS, zero JS, no `filter` — A11): the grid is -a STATIC texture (no animation, no bg-grid-drift keyframes), and three -independent soft glow spots (body::after, html::before, html::after) -each run their own SLOW opacity-only fade (26/34/42s, ease-in-out, -negative delays → out of phase; LCM 4641s → the composite pattern -effectively never repeats within a viewing session), so the total light -fluxuates smoothly and irregularly — no blink, no jitter, no movement. - -Story: .agents/user_stories/background-no-motion.md. Browser behavior -(no motion, visible fades, no occlusion, no overflow) is E2E-covered by -tests/e2e/test_background_no_motion.py (task 02). +Story: n/a (TODO-derived). Browser behavior (no background animation in +a real viewport, the grid still painted, no occlusion, no overflow) is +E2E-covered by ``tests/e2e/test_background_no_motion.py`` (task 02). """ from __future__ import annotations import re +from pathlib import Path -from tests.unit.test_background_animation import _css, _css_no_comments, _rule_block - -ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after") - -# (layer, keyframes name, duration shorthand, single-spot gradient) -# The 2026-08-28 rebrand recolored the phase-08 indigo/cyan spots to the -# warm dark-red theme palette (rose / orange / red) — structure -# (radius, position, alpha, period, delay) is the phase-25 design. -GLOW_SPOTS = ( - ("body::after", "bg-glow-a", "animation: bg-glow-a 26s ease-in-out infinite", - "radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), transparent 62%)"), - ("html::before", "bg-glow-b", "animation: bg-glow-b 34s ease-in-out -12s infinite", - "radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), transparent 62%)"), - ("html::after", "bg-glow-c", "animation: bg-glow-c 42s ease-in-out -23s infinite", - "radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), transparent 62%)"), +STYLES_CSS = ( + Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css" ) +GRID_LAYER = "body::before" # the static 44px grid — STAYS +# The phase-25 glow layers — all three deleted in phase 78 (they were +# the ONLY animated part of the background). +GLOW_LAYERS = ("body::after", "html::before", "html::after") -def _bg_keyframes(css: str) -> dict[str, str]: - """Name → body for every @keyframes bg-* rule (balanced braces — - works for the one-line blocks and a multi-line reformat alike).""" - out: dict[str, str] = {} - for m in re.finditer(r"@keyframes (bg-[A-Za-z0-9-]+)\s*\{", css): - start, depth, i = m.end(), 1, m.end() - while i < len(css) and depth: - if css[i] == "{": - depth += 1 - elif css[i] == "}": - depth -= 1 - i += 1 - out[m.group(1)] = css[start:i - 1] - return out + +def _css() -> str: + return STYLES_CSS.read_text(encoding="utf-8") + + +def _css_no_comments() -> str: + """styles.css with /* … */ comments stripped — for functional rule + checks that must not trip on explanatory prose.""" + return re.sub(r"/\*[\s\S]*?\*/", "", _css()) + + +def _find_rule(css: str, selector: str) -> re.Match[str] | None: + """The first top-level ``selector { ... }`` rule, or None (the + deleted glow layers must be ABSENT, so this may not assert).""" + return re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css) + + +def _rule_block(css: str, selector: str) -> str: + """Body of the first ``selector { ... }`` rule (top-level, no + nesting).""" + rule = _find_rule(css, selector) + assert rule, f"styles.css must define a {selector} rule" + return rule.group(1) # -------------------------------------------------------------------------- -# No movement — the grid is a static texture, and no bg-* keyframe may -# animate anything but opacity +# The animated part is gone — no glow layers, no glow keyframes # -------------------------------------------------------------------------- -def test_grid_has_no_animation() -> None: - """body::before must carry NO animation declaration — the phase-22 - 0.73px/s drift rasterized as a once-per-second down-right jitter; the - owner wants no movement (2026-08-25).""" - block = _rule_block(_css(), "body::before") - assert "animation" not in block, ( - "body::before must not animate (the no-movement contract)" +def test_no_bg_glow_keyframes_remain() -> None: + """The three glow @keyframes blocks are deleted — the names must not + appear anywhere in the file (no declaration, no keyframe block, no + stale comment). Pinned by the ``bg-`` prefix: no token carrying the + background keyframe namespace may survive (the prefix is a plain + literal, so this pin itself carries none of the deleted names).""" + assert "bg-" not in _css(), ( + "no token in the background keyframe namespace (bg-*) may remain " + "in styles.css — the glow keyframes and their declarations are deleted" + ) + assert re.search(r"@keyframes bg-", _css_no_comments()) is None, ( + "no background @keyframes may remain in styles.css" ) -def test_drift_and_breathe_keyframes_are_deleted() -> None: - """@keyframes bg-grid-drift and @keyframes bg-glow-breathe are gone — - the names must not appear anywhere in the file (no declaration, no - keyframe block, no stale comment).""" - css = _css() - assert "bg-grid-drift" not in css, "bg-grid-drift must be deleted" - assert "bg-glow-breathe" not in css, "bg-glow-breathe must be deleted" +def test_glow_layers_are_deleted() -> None: + """body::after / html::before / html::after no longer exist as CSS + rules — the layers (their background-images, their animations, their + fixed/z-index:-1 boxes) are gone from the page entirely.""" + rules = _css_no_comments() + for sel in GLOW_LAYERS: + assert _find_rule(rules, sel) is None, ( + f"{sel} must be deleted (the phase-78 static contract)" + ) -def test_grid_keeps_its_static_texture() -> None: - """The owner rejected the grid's MOTION, not the grid: 44px cells, - 1px lines at the fixed 60% line alpha (rebrand warm tone, - 2026-08-28), and the widened radial mask (both the -webkit- and - standard mask properties) stay.""" - block = _rule_block(_css(), "body::before") +def test_no_background_layer_declares_animation() -> None: + """None of the four background pseudo-element selectors carries an + ``animation:`` declaration — the three glow selectors are ABSENT, + and the surviving grid layer is animation-free.""" + rules = _css_no_comments() + for sel in (GRID_LAYER, *GLOW_LAYERS): + rule = _find_rule(rules, sel) + if rule is None: + continue # deleted layer — nothing to animate + assert "animation" not in rule.group(0), ( + f"{sel} must not animate (static background contract)" + ) + + +# -------------------------------------------------------------------------- +# The static grid STAYS — byte-identical texture, no animation +# -------------------------------------------------------------------------- + + +def test_grid_layer_is_static_and_unchanged() -> None: + """The owner removed the animated part, not the grid: body::before + keeps 44px cells, 1px lines at the fixed 60% line alpha (warm + rebrand tone), and the widened radial mask (both the -webkit- and + standard mask properties) — and carries NO animation.""" + block = _rule_block(_css(), GRID_LAYER) assert "background-size: 44px 44px" in block assert ( "linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block @@ -104,121 +122,33 @@ def test_grid_keeps_its_static_texture() -> None: mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)" assert f"-webkit-mask-image: {mask};" in block assert f"mask-image: {mask};" in block + assert "animation" not in block, "the grid must stay static (no animation)" -def test_exactly_three_bg_glow_keyframes_exist() -> None: - """Exactly three bg-* keyframe blocks: bg-glow-a/b/c (the old - bg-grid-drift and bg-glow-breathe are deleted).""" - assert set(_bg_keyframes(_css())) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"} - - -def test_bg_keyframes_animate_opacity_only() -> None: - """The no-movement contract: across ALL frames of ALL bg-* keyframes - the set of declared properties is exactly {opacity} — no transform, - scale, background-position, nothing else may appear.""" - props: set[str] = set() - for _name, body in _bg_keyframes(_css()).items(): - props |= set(re.findall(r"([A-Za-z-]+)\s*:", body)) - assert props == {"opacity"}, ( - f"bg-* keyframes must animate only opacity, found {sorted(props)}" - ) - - -def test_glow_layers_declare_no_transform_or_position_animation() -> None: - """The three glow layers themselves must not declare transform or - background-position either (the no-movement contract applies to the - layers, not just their keyframes).""" - for sel, _name, _anim, _grad in GLOW_SPOTS: - block = _rule_block(_css(), sel) - assert "transform" not in block, f"{sel} must not declare transform" - assert "background-position" not in block, ( - f"{sel} must not declare background-position" - ) - - -# -------------------------------------------------------------------------- -# Three distinct bright spots, each on its own slow opacity-only fade -# -------------------------------------------------------------------------- - - -def test_each_spot_runs_its_own_slow_opacity_fade() -> None: - """body::after (rose, 26s), html::before (orange, 34s, -12s delay), - html::after (red, 42s, -23s delay) — the rebrand warm palette on - the phase-25 layout; each glow layer's background-image is EXACTLY - the single radial gradient from the spec (color, radius, position, - 62% transparent stop).""" - for sel, _name, animation, gradient in GLOW_SPOTS: - block = _rule_block(_css(), sel) - assert animation in block, f"{sel} must run {animation}" - assert f"background-image: {gradient};" in block, ( - f"{sel} must carry exactly one radial gradient: {gradient}" - ) - - -def test_glow_durations_are_distinct_and_slow() -> None: - """The three cycles are out of phase (distinct durations) and each is - slow (>= 20s); LCM(26, 34, 42) = 4641s, so the composite pattern - effectively never repeats within a viewing session.""" - durations: list[float] = [] - for sel, name, _anim, _grad in GLOW_SPOTS: - block = _rule_block(_css(), sel) - m = re.search(rf"animation: {name}\s+([\d.]+)s", block) - assert m, f"{sel} must run its {name} fade" - durations.append(float(m.group(1))) - assert len(set(durations)) == len(durations), ( - "the three spot cycles must be out of phase (distinct durations)" - ) - assert all(d >= 20 for d in durations), ( - f"each spot fade must be slow (>= 20s), got {durations}" - ) - - -def test_glow_keyframes_low_and_high_opacities() -> None: - """Each cycle: 0%/100% at its own low opacity (0.25 / 0.20 / 0.15), - 50% at 1 — smooth fade in and out, never a hard cut.""" - keyframes = _bg_keyframes(_css()) - lows = {"bg-glow-a": 0.25, "bg-glow-b": 0.20, "bg-glow-c": 0.15} - for name, low in lows.items(): - body = keyframes[name] - m = re.search(r"0%,\s*100%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body) - assert m and float(m.group(1)) == low, ( - f"{name} must start/end at opacity {low}" - ) - m = re.search(r"50%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body) - assert m and float(m.group(1)) == 1.0, (f"{name} must peak at opacity 1") - - -# -------------------------------------------------------------------------- -# Layer plumbing — the no-occlusion contract across all four layers -# -------------------------------------------------------------------------- - - -def test_all_four_layers_are_fixed_zminus1_noninteractive() -> None: - """All four background pseudo-layers stay behind the content and can - never intercept input: fixed, full-viewport, z-index -1, - pointer-events none, with pseudo content (UI Structure Check: layers - behind content, no 360px overflow — the layers are fixed; inset: 0).""" - for sel in ALL_LAYERS: - block = _rule_block(_css(), sel) - assert "position: fixed" in block, f"{sel} must stay position:fixed" - assert "inset: 0" in block, f"{sel} must stay full-viewport (inset: 0)" - assert "z-index: -1" in block, f"{sel} must stay z-index:-1" - assert "pointer-events: none" in block, f"{sel} must stay click-through" - assert 'content: ""' in block, f"{sel} must keep its pseudo content" +def test_grid_layer_plumbing() -> None: + """The surviving grid layer stays behind the content and can never + intercept input: fixed, full-viewport, z-index -1, pointer-events + none, with pseudo content (UI Structure Check: the fixed; inset: 0 + layer adds no width — no 360px overflow).""" + block = _rule_block(_css(), GRID_LAYER) + assert "position: fixed" in block + assert "inset: 0" in block + assert "z-index: -1" in block + assert "pointer-events: none" in block + assert 'content: ""' in block def test_html_owns_canvas_and_body_stays_transparent() -> None: - """The no-occlusion contract: <html> keeps the var(--bg) canvas; - <body> stays transparent and non-stacking — or the z-index:-1 layers - (including the new html::before / html::after spots) would be painted - over.""" + """The no-occlusion contract survives the deletion: <html> keeps the + var(--bg) canvas; <body> stays transparent and non-stacking — or the + z-index:-1 grid layer would be painted over.""" html_block = _rule_block(_css(), "html") assert "background: var(--bg)" in html_block, ( "html must keep background: var(--bg) (the page canvas)" ) body_block = _rule_block(_css(), "body") assert "background: transparent" in body_block, ( - "body must keep background: transparent so the layers show" + "body must keep background: transparent so the grid shows" ) for prop in ("z-index", "transform", "opacity", "filter"): assert prop + ":" not in body_block, ( @@ -227,31 +157,34 @@ def test_html_owns_canvas_and_body_stays_transparent() -> None: # -------------------------------------------------------------------------- -# Reduced motion + phase-08 anchors (no filter, no blur, zero JS) +# Reduced motion + phase-08 anchors # -------------------------------------------------------------------------- -def test_reduced_motion_stills_all_four_layers() -> None: - """prefers-reduced-motion: reduce must still ALL FOUR layers together - (body::before, body::after, html::before, html::after) with - animation: none — the typing/spinner/thinking blocks are untouched.""" +def test_background_reduced_motion_block_is_deleted() -> None: + """The prefers-reduced-motion rule whose only job was stilling the + background layers (body::before, body::after, html::before, + html::after → animation: none) is deleted WITH the layers. The + unrelated reduced-motion blocks (typing dots, spinner, toasts, nav + slide, …) stay untouched.""" blocks = re.findall( r"@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}", _css() ) - assert any( - all(sel in b for sel in ALL_LAYERS) and "animation: none" in b - for b in blocks - ), "a reduced-motion block must still all four background layers" + assert blocks, "the unrelated reduced-motion blocks must survive" + for i, block in enumerate(blocks): + for sel in (GRID_LAYER, *GLOW_LAYERS): + assert sel not in block, ( + f"reduced-motion block {i} still references background layer {sel}" + ) -def test_no_filter_in_any_layer_and_no_blur_anywhere() -> None: - """The phase-08 performance anchor: no `filter` in any background - layer block, and no `blur` anywhere in styles.css (comments - stripped).""" - for sel in ALL_LAYERS: - assert "filter" not in _rule_block(_css(), sel), ( - f"{sel} must not use any filter" - ) +def test_no_filter_no_blur() -> None: + """The phase-08 performance anchor survives the deletion: no + ``filter`` in the grid rule and no ``blur`` anywhere in styles.css + (comments stripped).""" + assert "filter" not in _rule_block(_css(), GRID_LAYER), ( + "the grid layer must not use any filter" + ) assert "blur" not in _css_no_comments(), ( "no filter: blur anywhere in styles.css (phase-08 perf anchor)" ) diff --git a/tests/unit/test_caching.py b/tests/unit/test_caching.py index da5d067..1073bdc 100644 --- a/tests/unit/test_caching.py +++ b/tests/unit/test_caching.py @@ -211,6 +211,7 @@ def test_html_pages_include_history() -> None: "/tuning.html", "/git-sources.html", "/history.html", + "/tokens.html", # phase 79 task 06: the admin tokens page (shell route) "/shared.html", # phase 51: the shared page's static path "/doc-edit.html", # phase 59: the doc edit screen (task 06) ): diff --git a/tests/unit/test_chat_cancel.py b/tests/unit/test_chat_cancel.py index 6e5a446..fc76d93 100644 --- a/tests/unit/test_chat_cancel.py +++ b/tests/unit/test_chat_cancel.py @@ -22,6 +22,7 @@ query_log row; structured ``error`` frame, not logged as cancelled). from __future__ import annotations import asyncio +import base64 import gc import json import logging @@ -32,9 +33,10 @@ from types import SimpleNamespace from typing import Any import pytest +from itsdangerous import TimestampSigner from app.api import chat as chat_api -from app.config import Settings +from app.config import Settings, get_settings from app.main import app as fastapi_app from app.models import Document, KbOverview, QueryLog from app.rag.llm import LLMClient @@ -195,6 +197,21 @@ def _install_llm(monkeypatch: pytest.MonkeyPatch, llm: LLMClient) -> None: # ---------- the ASGI driver (client disconnect at the ASGI boundary) ---------- +def _admin_cookie_header() -> tuple[bytes, bytes]: + """A valid signed ``bor_session`` cookie carrying the admin session. + + Phase 79 (task 03): ``POST /api/chat`` is user-gated, and the raw + ASGI scope below carries no browser — so it presents the same signed + cookie ``SessionMiddleware`` would have emitted after + ``POST /api/login`` (the admin session short-circuits + ``require_user``; the anonymous 401 contract is pinned in + ``test_auth_api.py``).""" + settings = get_settings() + data = base64.b64encode(json.dumps({"admin": True}).encode("utf-8")) + signed = TimestampSigner(settings.session_secret).sign(data) + return b"cookie", f"{settings.session_cookie}={signed.decode('ascii')}".encode("ascii") + + def _scope() -> dict[str, Any]: return { "type": "http", @@ -209,6 +226,7 @@ def _scope() -> dict[str, Any]: "headers": [ (b"host", b"testserver"), (b"content-type", b"application/json"), + _admin_cookie_header(), # phase 79: the signed-in admin ], "client": ("testclient", 50000), "server": ("testserver", 80), diff --git a/tests/unit/test_chat_gate.py b/tests/unit/test_chat_gate.py index f8a64a6..721d99e 100644 --- a/tests/unit/test_chat_gate.py +++ b/tests/unit/test_chat_gate.py @@ -24,6 +24,7 @@ from app.rag.agent import AGENT_TOOLS from app.rag.llm import StreamPiece from app.rag.retriever import RetrievedChunk, weak_hit_titles from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions +from tests.conftest import ADMIN_PASSWORD if TYPE_CHECKING: from app.rag.scaffolding import ScaffoldingFilter @@ -492,6 +493,17 @@ class _FakeSession: return None +@pytest.fixture(autouse=True) +def _admin_signed_in(client: TestClient) -> None: + """Phase 79 (task 03): ``POST /api/chat`` is user-gated — the + endpoint-level tests run as the signed-in ADMIN, so the shared + ``client`` logs in once per test. The admin session short-circuits + ``require_user`` before any DB touch, so the fake-session wiring in + ``gate_env`` is untouched.""" + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}" + + @pytest.fixture() def gate_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[_FakeSession, _CannedLLM]]: """``POST /api/chat`` with retriever, session, and LLM all faked.""" diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py index b641065..f78121f 100644 --- a/tests/unit/test_chat_persistence.py +++ b/tests/unit/test_chat_persistence.py @@ -165,6 +165,10 @@ def test_new_chat_clears_key_and_ui() -> None: assert "clearStoredConversation()" in body assert 'querySelectorAll(".msg")' in body assert "emptyState.hidden = false" in body + assert "loadSuggestions()" in body, ( + "phase 80: the empty state came back — the onboarding chips refetch " + "so the row reflects the CURRENT last-3 state, not the boot fetch" + ) assert "setUiState(UI_STATE.idle)" in body assert "sendStatus.textContent" in body, "confirmation via the live region" assert "UI_STATE.thinking" in body and "UI_STATE.streaming" in body, ( diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py index 224e84d..6000ab0 100644 --- a/tests/unit/test_document_viewer.py +++ b/tests/unit/test_document_viewer.py @@ -34,6 +34,7 @@ from app.api.docs import doc_format from app.db import get_db from app.main import create_app from app.models import Document +from tests.conftest import ADMIN_PASSWORD FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" @@ -96,10 +97,16 @@ class _FakeSession: def _client_with_row(row: object) -> TestClient: """Fresh app whose ``get_db`` dependency is a stub returning ``row`` - (``None`` → no matching document row).""" + (``None`` → no matching document row). Phase 79: the endpoint is + user-gated, so the client signs in as the admin first — these tests + pin the CONTENT mapping (200/404/422), not the auth contract (which + ``test_auth_api.py`` pins).""" app = create_app() app.dependency_overrides[get_db] = lambda: _FakeSession(row) - return TestClient(app) + client = TestClient(app) + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}" + return client def test_content_unknown_pair_maps_to_404() -> None: diff --git a/tests/unit/test_frontend_router.py b/tests/unit/test_frontend_router.py index f2cbf00..c1931cf 100644 --- a/tests/unit/test_frontend_router.py +++ b/tests/unit/test_frontend_router.py @@ -36,6 +36,45 @@ active stamps) are gone with the four folded view documents, so Chat link), and header.js carries NO ``is-active`` write: the whoami auth gate + the mobile hamburger are its only nav responsibilities, and the router is the SINGLE runtime writer of the active state. + +Phase 77 task 01 (the re-show refresh hook): a user-initiated re-show +of an ALREADY-MOUNTED view dispatches ``bor:view-refresh`` on the +view's section — gated on the pre-mount ``wasMounted`` capture, so the +first show (the mount) and boot never fire it (the mount's own load is +the first fetch); a re-click of the active view's own nav link +dispatches the event instead of a bare return (no ``pushState`` — the +URL is already that view's path); the History view listens (armed only +in the admin branch, after the whoami gate — anonymous never fetches). + +Phase 77 task 02 (the other data views join the refresh): RAG +(``sources.js``), Sources (``git-sources.js``) and Tuning +(``tuning.js``) each listen for ``bor:view-refresh`` on their root and +re-run their existing load (armed only in the admin branch, after the +whoami gate — the same gate guard as History). ``sources.js``'s +``loadDocs`` clears the tbody's rows at the TOP (before the fetch — +the History pattern), so a refresh from a populated list into an empty +result leaves no ghost rows. The Chat view (``app.js``) does NOT +listen — the negative pin: the in-flight SSE stream and the local +conversation must survive every switch (the phase-76 contract), so +the exclusion is a contract, not an oversight. + +Phase 77 task 03 (the explicit History refresh control, TODO.md L3): +the History page-head becomes a flex row (scoped to ``#view-history`` +— the other four views' page-heads are untouched) carrying the +``#history-refresh`` button (``aria-label="Refresh saved chats"``, +the aria-hidden house refresh glyph + the visible "Refresh" label — +the phase-46 auth-link convention) OUTSIDE the table wrap (reachable +while the empty state shows). history.js binds it in the admin branch +only (the anonymous branch hides it — no dead control beside the +gate); the click disables the button (no double-fire in flight), +reruns the re-entrant ``loadChats()`` and re-enables on success AND +failure (the finally). The outcome lands in ``#history-status``: +``Saved chats refreshed.`` on success (a 0-row fetch is a success) — +and the failure lines now live INSIDE ``loadChats`` (the house copy: +"is the app reachable?" / "try again."), so every caller of a failed +load sees it (the §7.4 never-stale contract). styles.css reuses the +``.new-chat-btn`` visual language (brand pill, ≥44px, hover, +:disabled) and goes icon-only below 640px. """ from __future__ import annotations @@ -64,8 +103,9 @@ def _html() -> str: def test_view_map_covers_the_shell_paths() -> None: """The VIEW map is pathname → view name: the shell's own two URLs ("/" and "/index.html") are the chat view, plus one entry per - folded view (tasks 01–03: tuning, rag, git-sources, history — - all four non-chat navbar views are in).""" + folded view (tasks 01–03: tuning, rag, git-sources, history; + phase 79 task 06: tokens — all five non-chat navbar views are + in).""" js = _js() view_start = js.find("const VIEW = {") assert view_start != -1, "the VIEW map must exist" @@ -80,8 +120,11 @@ def test_view_map_covers_the_shell_paths() -> None: assert '"/history.html": "history"' in view_body, ( "task 03 folds the History view into the shell" ) + assert '"/tokens.html": "tokens"' in view_body, ( + "phase 79 task 06 folds the Tokens view into the shell" + ) # The view names are the #view-<name> section slugs in index.html. - for name in ("chat", "tuning", "history"): + for name in ("chat", "tuning", "history", "tokens"): assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section" @@ -172,6 +215,9 @@ def test_only_non_chat_views_have_lazy_modules() -> None: assert 'history: () => import("./history.js")' in mods_body, ( "the History view module is lazy-imported on first show" ) + assert 'tokens: () => import("./tokens.js")' in mods_body, ( + "the Tokens view module is lazy-imported on first show" + ) assert '"chat"' not in mods_body, "the chat view has no lazy module" assert 'import("./app.js")' not in js, "app.js must never be lazy-imported" @@ -227,6 +273,8 @@ def test_router_writes_active_state_title_and_meta() -> None: assert "Manage the global tuning notes that steer every Brain of Reese answer." in js assert 'history: "Saved chats · Brain of Reese"' in js assert "Saved chats — every conversation is saved automatically, one click back." in js + assert 'tokens: "Access tokens · Brain of Reese"' in js + assert "Generate and revoke the API tokens that let people use the app." in js # The brand composition (phase 39's window.BOR_BRAND, read at # write time — never a hardcoded stamp). assert 'window.BOR_BRAND || "Brain of Reese"' in js @@ -291,6 +339,15 @@ def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None: tuning_link = tuning_match.group(0) assert "hidden" in tuning_link, "#nav-tuning ships hidden (admin-only)" assert "is-active" not in tuning_link, "no static active stamp on the Tuning link" + # The Tokens nav link (phase 79 task 06) ships hidden (admin-only) + # and UNstamped too — the router is the single writer of the active + # state, and a token user (role "user") must never see the link + # (header.js reveals it for admin only). + tokens_match = re.search(r'<a[^>]*id="nav-tokens"[^>]*>', html) + assert tokens_match, "the shell must carry the #nav-tokens nav link" + tokens_link = tokens_match.group(0) + assert "hidden" in tokens_link, "#nav-tokens ships hidden (admin-only)" + assert "is-active" not in tokens_link, "no static active stamp on the Tokens link" # ---------- phase 76 task 04: the header is shell-owned ---------- @@ -352,3 +409,456 @@ def test_boot_order_is_brand_app_router() -> None: assert 'type="module"' in router_tag, "router.js is an ES module" # No CDN: every asset reference is local (AGENTS.md rule 6). assert 'src="http' not in html and 'href="http' not in html + + +# ---------- phase 77 task 01: the re-show refresh hook ---------- + + +def test_reshow_dispatches_view_refresh_gated_on_pre_mount_capture() -> None: + """Phase 77: a user-initiated re-show of an already-mounted view + dispatches the ``bor:view-refresh`` CustomEvent on the view's + section. The dispatch site is INSIDE the ``if (wasMounted)`` guard, + and the ``wasMounted`` capture runs BEFORE the mount-once set + (``mounted[name] = true``) — so the first show (the mount) and boot + never dispatch: the mount's own load is the first fetch. Event + order: the view is visible and the head/nav state is written + BEFORE the refresh fires, and the focus/scroll tail runs after.""" + js = _js() + assert '"bor:view-refresh"' in js, "the refresh event literal must exist" + fn = js.find("async function switchTo") + assert fn != -1, "switchTo must exist" + body = js[fn : js.find("\n}", fn)] + capture = body.find("const wasMounted = mounted[name]") + mount_set = body.find("mounted[name] = true") + assert 0 <= capture < mount_set, ( + "the wasMounted capture must precede the mount-once set " + "(first show is exempt from the refresh)" + ) + gate = body.find("if (wasMounted)") + dispatch = body.find('root.dispatchEvent(new CustomEvent("bor:view-refresh"))') + assert 0 <= gate < dispatch < gate + 120, ( + "the dispatch must sit inside the wasMounted guard" + ) + show_loop = body.find("Object.entries(viewEls)") + title_write = body.find("document.title = titleFor(name)") + current_set = body.find("current = name") + focus = body.find("root.focus(") + assert show_loop < title_write < current_set < gate < dispatch < focus, ( + "visible → head/nav state → refresh dispatched → focus/scroll tail" + ) + + +def test_active_view_reclick_dispatches_refresh_not_bare_return() -> None: + """Phase 77: a re-click of the ACTIVE view's own nav link is a + re-fetch, not a no-op — the ``name === current`` branch dispatches + the refresh event on that view's section and returns. It must NOT + pushState (the URL is already this view's path) and must NOT + re-run the switch (no re-mount).""" + js = _js() + fn = js.find('nav.addEventListener("click"') + assert fn != -1, "the delegated click handler on the nav must exist" + body = js[fn : js.find("\n });", fn)] + branch = body.find("if (name === current)") + assert branch != -1, "the active re-click branch must exist" + branch_end = body.find("}", branch) + branch_body = body[branch : branch_end + 1] + assert 'new CustomEvent("bor:view-refresh")' in branch_body, ( + "the re-click branch must dispatch the refresh event (not a bare return)" + ) + assert "history.pushState" not in branch_body, ( + "the re-click must NOT pushState — the URL is already this view's path" + ) + assert "switchTo" not in branch_body, "the re-click must NOT re-run the switch" + assert "return" in branch_body, "the re-click still returns early (menu closes)" + + +def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None: + """Phase 77: the History view re-fetches on a user-initiated + re-show — history.js registers a ``bor:view-refresh`` listener on + the view's root that re-runs the (now re-entrant) ``loadChats()``. + The listener is armed only AFTER the whoami gate passes: anonymous + shows the gate and never fetches (the phase-50 contract the story + E2E pins), and the ``started`` flag means the listener can only + re-run a load the mount already made.""" + history_js = (ASSETS / "history.js").read_text(encoding="utf-8") + assert 'addEventListener("bor:view-refresh"' in history_js, ( + "history.js must listen for the refresh event on the view root" + ) + gate = history_js.find("if (!(await fetchIsAdmin()))") + listener = history_js.find('addEventListener("bor:view-refresh"') + assert 0 <= gate < listener, ( + "the listener is armed only in the ADMIN branch (after the gate)" + ) + assert re.search(r"if \(started\)\s+loadChats\(\)", history_js), ( + "the listener is gated on the first load (started)" + ) + # Re-entrancy: a re-load drops the data rows (except the hidden + # empty-state row) before fetching — the list is replaced, not + # duplicated. + load = history_js.find("async function loadChats()") + assert load != -1, "loadChats must exist" + load_body = history_js[load : history_js.find("\n }", load)] + assert "tr !== emptyRow" in load_body and "tr.remove()" in load_body, ( + "loadChats must remove the data rows (the empty row stays) first" + ) + clear_i = load_body.find("tr !== emptyRow") + fetch_i = load_body.find('fetch("/api/chats")') + assert 0 <= clear_i < fetch_i, "the row clearing precedes the fetch" + + +# ---------- phase 77 task 02: RAG / Sources / Tuning re-fetch; chat stays out ---------- + + +def _asset(name: str) -> str: + path = ASSETS / name + assert path.is_file(), f"missing {path}" + return path.read_text(encoding="utf-8") + + +def _pin_refresh_listener(js: str, gate: str, listener_call: str, name: str) -> None: + """Shared shape of the task-02 pin: the view module listens for + ``bor:view-refresh`` on its own root, the listener re-runs the + view's existing load, and the listener is armed ONLY in the ADMIN + branch — after the whoami gate (anonymous never fetches).""" + listener = js.find('addEventListener("bor:view-refresh"') + assert listener != -1, f"{name} must listen for the refresh event on the view root" + gate_i = js.find(gate) + assert 0 <= gate_i < listener, ( + f"{name}: the listener must be armed in the ADMIN branch (after {gate!r})" + ) + assert listener_call in js[listener : listener + 120], ( + f"{name}: the listener must re-run the view's load ({listener_call!r})" + ) + + +def test_rag_view_refetches_on_reshow() -> None: + """Phase 77 task 02: the RAG (knowledge base) view re-fetches on a + user-initiated re-show — sources.js listens and re-runs + ``loadDocs()``. ``loadDocs`` is now re-entrant: the tbody's rows + are cleared at the TOP, before the fetch (the History pattern from + task 01), so a refresh from a populated list into an empty result + replaces the list instead of leaving ghost rows.""" + js = _asset("sources.js") + _pin_refresh_listener( + js, "const admin = await fetchIsAdmin();", "() => loadDocs()", "sources.js" + ) + load = js.find("async function loadDocs()") + assert load != -1, "loadDocs must exist" + body = js[load : js.find("\n }", load)] + clear_i = body.find("tbody.replaceChildren()") + fetch_i = body.find('fetch("/api/docs")') + assert 0 <= clear_i < fetch_i, ( + "the row clearing must precede the fetch (a populated → empty refresh " + "must not leave ghost rows)" + ) + + +def test_git_sources_view_refetches_on_reshow() -> None: + """Phase 77 task 02: the Sources (git-sources) view re-fetches on a + user-initiated re-show — git-sources.js listens and re-runs + ``loadSources()``. A re-call resets ALL THREE list states: the + populated render (renderSources replaces the tbody + re-syncs the + empty state) and the load error (``hideLoadError()`` runs on the + success path BEFORE rendering, so an error followed by a + successful refresh clears it).""" + js = _asset("git-sources.js") + _pin_refresh_listener( + js, "const admin = await fetchIsAdmin();", "() => loadSources()", "git-sources.js" + ) + load = js.find("async function loadSources()") + assert load != -1, "loadSources must exist" + body = js[load : js.find("\n }", load)] + hide_i = body.find("hideLoadError()") + render_i = body.find("renderSources(") + assert 0 <= hide_i < render_i, ( + "the success path must clear the load error before rendering " + "(an error followed by a successful refresh clears the error)" + ) + render = js.find("function renderSources(") + assert render != -1, "renderSources must exist" + render_body = js[render : js.find("\n }", render)] + assert "tbody.replaceChildren()" in render_body, ( + "a re-render replaces the list (the populated state resets)" + ) + + +def test_tuning_view_refetches_on_reshow() -> None: + """Phase 77 task 02: the Tuning view re-fetches on a user-initiated + re-show — tuning.js listens and re-runs ``loadNotes()``. A re-call + replaces the list (renderNotes clears it first); a FAILED refresh + keeps the last rendered list — loadNotes's documented contract + (progressive enhancement, never a blanked panel), unchanged by the + listener (it just calls the function).""" + js = _asset("tuning.js") + _pin_refresh_listener( + js, "if (await fetchIsAdmin())", "() => loadNotes()", "tuning.js" + ) + render = js.find("function renderNotes(") + assert render != -1, "renderNotes must exist" + render_body = js[render : js.find("\n }", render)] + assert 'tuneList.textContent = ""' in render_body, ( + "a re-render clears the list first (the re-call replaces it)" + ) + + +def test_chat_view_does_not_listen_for_view_refresh() -> None: + """Negative pin: app.js (the chat view) must NOT listen for + ``bor:view-refresh`` — the in-flight SSE stream and the local + conversation survive EVERY switch (the phase-76 contract the + stream E2E pins). The exclusion is a contract, not an oversight; + the deliberate-exclusion comment lives at the chat view's + module-scope state in app.js.""" + js = _asset("app.js") + assert 'addEventListener("bor:view-refresh"' not in js, ( + "the chat view must NOT listen for the refresh event — its " + "in-flight stream and local conversation must survive every " + "switch (phase 76)" + ) + assert "bor:view-refresh" in js, ( + "the exclusion is documented at the chat view's module-scope state" + ) + + +# ---------- phase 77 task 03: the History refresh button ---------- + + +def _history_view(html: str) -> str: + """The shell's History view section (the test_history_page pattern): + from the #view-history open tag to the container main's close + (the view is the shell's LAST view section).""" + start = html.find('<section class="view" id="view-history"') + assert start != -1, "the #view-history section must be in the shell" + end = html.find("</main>", start) + assert end != -1, "the container main must close after the view" + return html[start:end] + + +def test_history_refresh_button_markup_lives_in_the_page_head() -> None: + """Phase 77 task 03 (TODO.md L3): the History page-head carries the + explicit refresh control — #history-refresh, a ``type="button"`` + ``.history-refresh`` with the accessible name + ``aria-label="Refresh saved chats"``, the house inline-SVG refresh + glyph (aria-hidden) and the visible "Refresh" label (the phase-46 + auth-link convention: label visible ≥640px, icon-only below — the + aria-label keeps the name in both). It sits INSIDE the view's + .page-head and BEFORE the table wrap (outside it — the button must + stay reachable while the empty state is showing).""" + view = _history_view(_html()) + btn_i = view.find('id="history-refresh"') + assert btn_i != -1, "the #history-refresh button must exist" + tag_start = view.rfind("<button", 0, btn_i) + tag_end = view.find(">", btn_i) + tag = view[tag_start:tag_end] + assert 'type="button"' in tag, "a plain button (no form submit)" + assert 'class="history-refresh"' in tag + assert 'aria-label="Refresh saved chats"' in tag, ("the accessible name") + tail = view[tag_end:tag_end + 600] + assert 'aria-hidden="true"' in tail, "the refresh glyph must be aria-hidden" + assert '<span class="history-refresh-label">Refresh</span>' in tail, ( + "the visible Refresh label (icon-only below 640px, label above)" + ) + head_i = view.find('class="page-head"') + wrap_i = view.find('id="history-table-wrap"') + assert -1 < head_i < btn_i < wrap_i, ( + "the button sits in the page-head, before (OUTSIDE) the table wrap" + ) + + +def test_history_refresh_button_binding_admin_only_with_outcome_lines() -> None: + """Phase 77 task 03: history.js binds #history-refresh in the ADMIN + branch only — the anonymous branch HIDES the button (the gate is + what anonymous sees; no dead control beside the sign-in gate) and + still fetches nothing. The click handler disables the button + BEFORE the fetch (no double-fire while in flight) and delegates to + the re-entrant load; the re-enable sits in a ``finally`` (success + AND failure — a click can never leave the button stuck disabled). + The success line is ``Saved chats refreshed.``; the failure lines + live INSIDE ``loadChats`` itself — the house copy (network: + "is the app reachable?"; non-2xx: "try again.") — so every caller + of a failed load (the mount's first load, a re-show, the button) + sees the outcome in #history-status.""" + js = _asset("history.js") + gate = js.find("if (!(await fetchIsAdmin()))") + assert gate != -1 + branch = js[gate:js.find("return;", gate)] + assert "refreshBtn.hidden = true" in branch, ( + "the anonymous branch hides the button (no dead control)" + ) + admin_after = js[js.find("return;", gate):] + bind = admin_after.find('refreshBtn.addEventListener("click"') + assert bind != -1, "the refresh binding must exist in the admin branch" + handler = admin_after[bind:admin_after.find(");", bind)] + assert "refreshBtn.disabled = true" in handler, ( + "the click disables the button before the fetch (no double-fire)" + ) + # refreshChats is defined alongside loadChats (before the gate) — + # its BODY is pinned on the whole file, its BINDING on the admin + # branch above (the hoisted function is only reachable from the + # admin-branch binding: the anonymous branch never references it). + fn = js.find("async function refreshChats()") + assert fn != -1, "refreshChats must exist" + fn_body = js[fn:js.find("\n }", fn)] + assert "loadChats()" in fn_body, "the button re-runs the (re-entrant) load" + assert "finally" in fn_body and "refreshBtn.disabled = false" in fn_body, ( + "the button re-enables on success AND failure (the finally)" + ) + assert '"Saved chats refreshed."' in fn_body, "the exact success line" + load = js.find("async function loadChats()") + load_body = js[load:js.find("\n }", load)] + assert "Couldn't load saved chats — is the app reachable?" in load_body, ( + "the network-error line lives in loadChats (every caller sees it)" + ) + assert "Couldn't load saved chats — try again." in load_body, ( + "the non-2xx line lives in loadChats (every caller sees it)" + ) + + +def test_history_refresh_button_css_reuses_the_new_chat_language() -> None: + """Phase 77 task 03 (styles.css): .history-refresh reuses the + .new-chat-btn visual language — the solid brand pill (--bg text on + --brand, 5.2:1 ≥ WCAG 4.5:1), the ≥44px target, the lightened + hover fill, the dimmed :disabled (the in-flight state), the glyph + hidden on desktop (the label carries the pill) — and the global + :focus-visible ring applies (no button-scoped focus override). + The page-head flex row is SCOPED to #view-history (the other four + views' page-heads are untouched). Below 640px the pill goes + icon-only (the phase-46 auth-link convention — the aria-label + keeps the accessible name).""" + css = _asset("styles.css") + block = re.search(r"\.history-refresh \{([\s\S]*?)\n\}", css) + assert block, "styles.css must style .history-refresh" + body = block.group(1) + assert "background: var(--brand)" in body, "the .new-chat-btn brand fill" + assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)" + assert "min-height: 44px" in body, "the comfortable touch target" + assert "border-radius: 999px" in body and "border: 0" in body, "the pill" + assert ".history-refresh:hover { background: #f55a72; color: var(--bg); }" in css + assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, ( + "the in-flight disabled state is dimmed (the house language)" + ) + assert ".history-refresh svg { width: 16px; height: 16px; display: none; }" in css, ( + "desktop: the label carries the pill (the glyph is hidden)" + ) + row = re.search(r"#view-history \.page-head \{([\s\S]*?)\n\}", css) + assert row and "display: flex" in row.group(1), ( + "the page-head flex row is scoped to the History view" + ) + mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css) + assert mobile, "the 640px media query must exist" + mbody = mobile.group(1) + assert ".history-refresh-label { display: none; }" in mbody, ( + "icon-only below 640px (the phase-46 convention)" + ) + assert ".history-refresh svg { display: block; }" in mbody, ( + "the glyph is the whole control below 640px" + ) + + +# ---------- phase 79 task 06: the Tokens view (generate · list · revoke) ---------- + + +def test_tokens_view_module_contract() -> None: + """Phase 79 task 06: tokens.js follows the phase-76 view-module + contract — ``export async function mount(root)`` is the entry, the + whoami gate (``fetchIsAdmin``) runs in mount and the anonymous + branch shows the gate + hides the table + RETURNS with NO + /api/tokens request (the router 403s anonymous), the + ``bor:view-refresh`` listener is armed ONLY in the ADMIN branch + (after the gate) and re-runs the re-entrant ``loadTokens()`` + (gated on the ``started`` flag), and ``loadTokens`` hides the + once-block and clears the data rows BEFORE the fetch — the + plaintext is never re-shown and the list is replaced, not + duplicated. Every cell is textContent: the file never touches + innerHTML (XSS-safe by construction).""" + js = _asset("tokens.js") + assert "export async function mount(root)" in js, ( + "mount(root) must be the module's entry (the phase-76 fold)" + ) + assert 'import { fetchIsAdmin } from "./header.js";' in js, ( + "the view imports ONLY the shared cached whoami promise" + ) + mount_i = js.find("export async function mount(root)") + gate_i = js.find("if (!(await fetchIsAdmin()))") + assert 0 <= mount_i < gate_i, "the whoami gate must run in mount" + # The anonymous branch: gate in, table out, then a bare return — + # and NO fetch call anywhere inside it. + branch = js[gate_i:js.find("return;", gate_i)] + assert "fetch(" not in branch, ( + "the anonymous branch must not fetch anything" + ) + assert "tableWrap.hidden = true" in branch + assert "gateEl.hidden = false" in branch + # The re-show refresh: armed in the ADMIN branch only (after the + # gate), gated on the first load (started), re-running loadTokens. + listener = js.find('addEventListener("bor:view-refresh"') + assert 0 <= gate_i < listener, ( + "the refresh listener is armed only in the ADMIN branch (after the gate)" + ) + assert re.search(r"if \(started\)\s+loadTokens\(\)", js), ( + "the listener is gated on the first load (started)" + ) + # loadTokens: re-entrant — the once-block hides and the data rows + # (except the hidden empty-state row) are dropped BEFORE the fetch. + load = js.find("async function loadTokens()") + assert load != -1, "loadTokens must exist" + load_body = js[load:js.find("\n }", load)] + hide_i = load_body.find("onceBlock.hidden = true") + clear_i = load_body.find("tr !== emptyRow") + fetch_i = load_body.find('fetch("/api/tokens")') + assert 0 <= hide_i < clear_i < fetch_i, ( + "once-block hide + row clearing must precede the fetch " + "(a re-render never re-shows the plaintext; the list is replaced)" + ) + assert "innerHTML" not in js, ( + "every cell is textContent — no innerHTML anywhere (XSS-safe)" + ) + + +def test_tokens_view_scaffold_in_the_shell() -> None: + """Phase 79 task 06: the shell carries the #view-tokens section — + hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) — + with the page-head (h1 \"Access tokens\"), the #tokens-gate (the + #history-gate pattern, ship-hidden, its Sign in returning to the + Tokens view), the role=\"status\" live region, the create row + (label input + Generate — ship-hidden, anonymous-safe), the + #token-once block (ship-hidden — only a 201 reveals it), and the + full-width table (AGENTS.md rule 5) with the visually-hidden + Actions header + the hidden #tokens-empty-row.""" + html = _html() + view = html.find('<section class="view" id="view-tokens"') + assert view != -1, "the #view-tokens section must be in the shell" + tag_end = html.find(">", view) + tag = html[view:tag_end] + assert "hidden" in tag and "inert" in tag, ( + "the folded view ships hidden AND inert" + ) + assert 'tabindex="-1"' in tag, "the target view is focusable" + main_end = html.find("</main>", view) + assert view < main_end, "the view section lives inside the single main" + body = html[view:main_end] + assert "<h1>Access tokens</h1>" in body + gate = re.search(r'<section[^>]*id="tokens-gate"[^>]*>', body) + assert gate and "hidden" in gate.group(0), "#tokens-gate must ship hidden" + assert 'href="/login.html?next=/tokens.html"' in body, ( + "the gate's Sign in returns to the Tokens view (no-JS fallback)" + ) + assert re.search(r'<span[^>]*id="tokens-status"[^>]*role="status"[^>]*>', body) + create = re.search(r'<div[^>]*id="token-create"[^>]*>', body) + assert create and "hidden" in create.group(0), ( + "the create row ships hidden (anonymous-safe)" + ) + assert re.search(r'<input[^>]*id="token-label"[^>]*>', body) + assert re.search(r'<button[^>]*id="token-generate"[^>]*>', body) + once = re.search(r'<div[^>]*id="token-once"[^>]*>', body) + assert once and "hidden" in once.group(0), ( + "the once-block ships hidden (only a 201 reveals it)" + ) + assert re.search(r'<input[^>]*id="token-once-value"[^>]*readonly[^>]*>', body) + assert re.search(r'<button[^>]*id="token-once-copy"[^>]*>', body) + wrap = re.search(r'<div[^>]*id="tokens-table-wrap"[^>]*>', body) + assert wrap and 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0) + assert 'id="tokens-tbody"' in body + assert re.search(r'<tr[^>]*id="tokens-empty-row"[^>]*hidden>', body) + # The Actions column header is visually-hidden (the row buttons + # carry their own aria-labels — the history-table convention). + assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body diff --git a/tests/unit/test_save_chat_ui.py b/tests/unit/test_save_chat_ui.py index 9014e98..06dfc4e 100644 --- a/tests/unit/test_save_chat_ui.py +++ b/tests/unit/test_save_chat_ui.py @@ -292,12 +292,15 @@ def test_boot_load_precedence_saved_chat_over_local_restore() -> None: boot_start = js.find("(async () => {") assert boot_start != -1, "the boot IIFE must exist" boot = js[boot_start:] + # Phase 79 (task 05): the token gate settles FIRST — a cached + # token's silent re-auth lands before the first whoami fires. + gate_i = boot.find("await mountGate(") init_i = boot.find("await initSharedHeader();") - admin_i = boot.find("isAdmin = await fetchIsAdmin();") + admin_i = boot.find('who.role === "admin";') saved_i = boot.find("await restoreSavedChatFromUrl();") local_i = boot.find("restoreConversation();") - assert -1 < init_i < admin_i < saved_i < local_i, ( - "boot order: header init → whoami → ?chat= load → local fallback" + assert -1 < gate_i < init_i < admin_i < saved_i < local_i, ( + "boot order: token gate → header init → whoami → ?chat= load → local fallback" ) assert "shareBtn.hidden" not in boot, ("no Share-reveal line left in boot (phase 55 task 03)") assert "if (!openedSaved) restoreConversation();" in boot, ( diff --git a/tests/unit/test_shared_header.py b/tests/unit/test_shared_header.py index c74b310..8fb67d0 100644 --- a/tests/unit/test_shared_header.py +++ b/tests/unit/test_shared_header.py @@ -44,29 +44,69 @@ def _script_srcs(path: Path) -> list[str]: # ---------- header.js: the module itself ---------- -def test_header_module_exports_the_three_functions() -> None: - """header.js must export the three functions every page script - imports (fetchIsAdmin / initSharedHeader / clearChatStorage).""" +def test_header_module_exports_the_header_functions() -> None: + """header.js must export the functions every page script imports + (fetchWhoami — the phase-79 canonical call — fetchIsAdmin, its + phase-16/19 backward-compatible delegation, initSharedHeader, + clearChatStorage) plus resetWhoami (the phase-79 cache + invalidation the token gate uses after a mid-page auth).""" js = _text(HEADER_JS) + assert "export function fetchWhoami" in js assert "export function fetchIsAdmin" in js + assert "export function resetWhoami" in js assert "export async function initSharedHeader" in js assert "export function clearChatStorage" in js def test_whoami_fetch_is_cached_in_a_module_level_promise() -> None: - """The whoami fetch is cached in the module-level `adminPromise` - marker — first call stores the promise, later calls return it, so a - page makes exactly ONE /api/whoami request per load no matter how - many consumers await it. Anonymous-safe: a failure resolves to - false.""" + """The whoami fetch is cached in the module-level `whoamiPromise` + marker (phase 79, task 05: it stores the FULL response — + { authenticated, role } — not just the admin flag) — first call + stores the promise, later calls return it, so a page makes exactly + ONE /api/whoami request per load no matter how many consumers + await it. Anonymous-safe: non-2xx / network failure / malformed + body all resolve to { authenticated: false, role: "anonymous" }. + The string `fetch("/api/whoami")` appears in this file EXACTLY + ONCE — the single-request contract (the rest of the frontend goes + through fetchWhoami/fetchIsAdmin).""" js = _text(HEADER_JS) - assert re.search(r"let\s+adminPromise\s*=\s*null", js), ( - "module-level adminPromise marker missing" + assert re.search(r"let\s+whoamiPromise\s*=\s*null", js), ( + "module-level whoamiPromise marker missing" ) - assert 'fetch("/api/whoami")' in js - assert "if (!adminPromise)" in js, "fetchIsAdmin must reuse the stored promise" - assert "return adminPromise" in js - assert ".catch(() => false)" in js, "network failure must resolve to anonymous" + assert js.count('fetch("/api/whoami")') == 1, ( + "the SINGLE /api/whoami call site lives in header.js exactly once" + ) + assert "if (!whoamiPromise)" in js, "fetchWhoami must reuse the stored promise" + assert "return whoamiPromise" in js + assert 'role: "anonymous"' in js, "the anonymous fallback carries the role" + assert ".catch(() => ANONYMOUS_WHOAMI)" in js, ( + "network failure must resolve to the anonymous role" + ) + + +def test_fetch_is_admin_delegates_to_fetch_whoami() -> None: + """Phase 79 (task 05): fetchIsAdmin() is a thin delegation — + fetchWhoami().then(w => w.role === "admin"): SAME single request, + all phase-16/19 callers keep working, and a token user (role + "user") reads FALSE here (the admin-only surfaces key off + role === "admin", never off `authenticated`).""" + js = _text(HEADER_JS) + fn = js.find("export function fetchIsAdmin") + assert fn != -1 + body = js[fn : js.find("\n}", fn)] + assert "fetchWhoami().then((w) => w.role === \"admin\")" in body + + +def test_reset_whoami_clears_the_module_cache() -> None: + """Phase 79 (task 05): the token gate changes the session MID-PAGE + (silent re-auth / interactive login) — resetWhoami() drops the + cached promise so the NEXT fetchWhoami() is a fresh post-auth + request (a boot-fired pre-auth whoami would still say anonymous).""" + js = _text(HEADER_JS) + fn = js.find("export function resetWhoami") + assert fn != -1 + body = js[fn : js.find("\n}", fn)] + assert "whoamiPromise = null" in body def test_init_shared_header_toggles_only_elements_that_exist() -> None: @@ -77,7 +117,11 @@ def test_init_shared_header_toggles_only_elements_that_exist() -> None: fn = js.find("function initSharedHeader") assert fn != -1 body = js[fn : js.find("\n}", fn)] - assert "await fetchIsAdmin()" in body + # Phase 79 (task 05): the header boots on the FULL whoami — the + # auth pair keys off the authenticated role (admin OR token user), + # the admin-only surfaces off role === "admin". + assert "await fetchWhoami()" in body + assert 'whoami.role === "admin"' in body for selector in ("#nav-sources", "#nav-git-sources", "#nav-tuning"): assert f'querySelector("{selector}")' in body # Sign in: both the bar copy AND the mobile dropdown copy (phase 46) @@ -410,7 +454,10 @@ def test_app_js_delegates_the_shared_controls_to_header_module() -> None: (`./header.js`) so esbuild can bundle it into the image.""" js = _text(APP_JS) assert 'from "./header.js"' in js - assert "fetchIsAdmin" in js and "initSharedHeader" in js + # Phase 79 (task 05): app.js reads the FULL whoami (the same cached + # promise) — the auth pair off `authenticated`, the admin-only + # surfaces off role === "admin". + assert "fetchWhoami" in js and "initSharedHeader" in js assert "signOutBtn.addEventListener" not in js, ( "the sign-out binding moved to header.js" ) @@ -419,7 +466,7 @@ def test_app_js_delegates_the_shared_controls_to_header_module() -> None: ) assert "loadAuthState" not in js, "loadAuthState was deleted in phase 19" assert "function applyAuthState" in js, "chat-page tuning gate stays" - assert "isAdmin = await fetchIsAdmin();" in js + assert 'who.role === "admin"' in js and "who.authenticated" in js init_idx = js.find("await initSharedHeader();") restore_idx = js.find("restoreConversation();") assert -1 < init_idx < restore_idx, ( diff --git a/tests/unit/test_token_gate.py b/tests/unit/test_token_gate.py new file mode 100644 index 0000000..5495275 --- /dev/null +++ b/tests/unit/test_token_gate.py @@ -0,0 +1,369 @@ +"""Unit: the in-app token gate (phase 79, task 05). + +Source-level house pattern (read the JS sources, no browser): pins the +gate module's wiring — the ``bor.token`` localStorage key, the +SILENT-RE-AUTH-BEFORE-WHOAMI order, the failed-re-auth key drop, the +cache-invalidation choice — the header's full-whoami plumbing +(``fetchWhoami`` exported, ``fetchIsAdmin`` delegating, the SINGLE +``fetch("/api/whoami")`` call site, the sign-out binding dropping the +cached token), and the shell/viewer HTML wiring (the gate ships hidden ++ inert, the form/input/error ids, the admin link, the boot order +token-gate.js AFTER router.js). The browser flows (gate → unlock → +cached reload → revoked drop → sign-out) are E2E-pinned by +``tests/e2e/test_api_tokens.py`` (phase 79, task 07). +""" +from __future__ import annotations + +import re +from pathlib import Path + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +ASSETS = FRONTEND / "assets" + +HEADER_JS = ASSETS / "header.js" +APP_JS = ASSETS / "app.js" +DOCUMENT_JS = ASSETS / "document.js" +TOKEN_GATE_JS = ASSETS / "token-gate.js" +INDEX_HTML = FRONTEND / "index.html" +DOCUMENT_HTML = FRONTEND / "document.html" + + +def _text(path: Path) -> str: + assert path.is_file(), f"missing frontend file: {path}" + return path.read_text(encoding="utf-8") + + +# ---------- token-gate.js: the module itself ---------- + + +def test_token_gate_module_exists_and_exports_mount_gate() -> None: + """token-gate.js is an ES module exposing mountGate(lockRoot, + onAuthed) — the reusable mount point (the shell passes #main, the + viewer passes its content wrapper).""" + js = _text(TOKEN_GATE_JS) + assert "export async function mountGate" in js + # Relative import for the single-evaluation design (esbuild inlines + # it into the page bundles; the Containerfile parity pin covers the + # image build). + assert 'from "./header.js"' in js + assert '"/assets/header.js"' not in js + + +def test_token_gate_uses_the_bor_token_localstorage_key() -> None: + """The owner's sentence: "cache that token in browser storage". The + cached key is the LITERAL bor.token — read at mount (silent + re-auth), written on a successful login, dropped on a failed + re-auth and on sign out (the header binding). Every localStorage + access is try/catch (the fail-silence storage contract).""" + js = _text(TOKEN_GATE_JS) + assert '"bor.token"' in js, "the bor.token localStorage key literal" + # The three accesses (read at mount, write on login, drop on a + # failed re-auth) all go through the key constant — try/catch each + # (the fail-silence storage contract: private mode degrades to + # "re-enter the token each visit", never to a broken gate). + assert "localStorage.getItem(TOKEN_KEY)" in js + assert "localStorage.setItem(TOKEN_KEY" in js + assert "localStorage.removeItem(TOKEN_KEY)" in js + assert js.count("try {") >= 3 + assert js.count("catch") >= 3 + + +def test_silent_reauth_happens_before_the_whoami_check() -> None: + """Source order: the cached token is re-sent to POST /api/token-auth + BEFORE the whoami role check — the re-auth (re)sets the session + cookie before any whoami settles, so the role check sees the + post-auth role (no stale anonymous for a returning token user).""" + js = _text(TOKEN_GATE_JS) + reauth = js.find("(1) SILENT RE-AUTH — before the whoami check") + role_check = js.find("(2) ROLE CHECK — header.js's fetchWhoami()") + assert -1 < reauth < role_check, "the silent re-auth must precede the whoami check" + assert 'fetch("/api/token-auth"' in js + # The re-auth block reads the cached token and posts it, all before + # the role check's fetchWhoami. + cached_read = js.find("readCachedToken()") + assert -1 < cached_read < role_check + # The role check goes through header.js's SHARED cached promise + # (one /api/whoami per page load in dev). + assert "await fetchWhoami()" in js + + +def test_failed_silent_reauth_drops_the_cached_key() -> None: + """A failed silent re-auth (revoked / unknown / network) removes + the key — the token may have been revoked — before the mount falls + through to the role check. The remove call sits in the (1) block, + so a dead cached token can never linger in localStorage.""" + js = _text(TOKEN_GATE_JS) + reauth = js.find("(1) SILENT RE-AUTH — before the whoami check") + role_check = js.find("(2) ROLE CHECK — header.js's fetchWhoami()") + assert -1 < reauth < role_check + block = js[reauth:role_check] + assert "removeToken()" in block, "the failure path must drop the key" + # removeToken itself hits the real localStorage.removeItem (inside + # its own try/catch). + fn = js.find("const removeToken") + body = js[fn : js.find("\n};", fn)] + assert "localStorage.removeItem(TOKEN_KEY)" in body + + +def test_gate_ships_hidden_and_revealed_as_an_inert_pair() -> None: + """The gate ships hidden + inert (the phase-16 ship-hidden pattern + — an authenticated boot never shows it for a frame) and the JS + always toggles hidden AND inert together (the WCAG inert-pair + contract): revealing drops BOTH, hiding re-adds BOTH.""" + js = _text(TOKEN_GATE_JS) + fn = js.find("export async function mountGate") + assert fn != -1 + body = js[fn:] + # Reveal: drop hidden AND inert. + assert "gate.hidden = false" in body + assert "gate.inert = false" in body + # Hide: re-add hidden AND inert. + assert "gate.hidden = true" in body + assert "gate.inert = true" in body + # The lock root is locked (inert) when the gate shows and unlocked + # when auth settles — the locked app never receives focus. + assert "lockRoot.inert = true" in body + assert "lockRoot.inert = false" in body + + +def test_gate_submit_caches_then_invalidates_the_whoami_cache() -> None: + """Form submit: 204 → cache the token, THEN invalidate the module + whoami cache (resetWhoami) and re-fetch through fetchWhoami — the + documented choice (a direct re-fetch would leave header.js's + boot-fired anonymous cache stale for the header re-boot). 401 → + the role=alert error line, the input cleared + re-focused.""" + js = _text(TOKEN_GATE_JS) + fn = js.find("form.addEventListener(\"submit\"") + assert fn != -1 + block = js[fn:] + store = block.find("storeToken(token)") + reset = block.find("resetWhoami()") + refetch = block.find("await fetchWhoami()") + assert -1 < store < reset < refetch, ( + "cache → invalidate → re-fetch: the order the contract pins" + ) + # The error path: 401 → showError() — the role=alert line revealed, + # the input cleared + re-focused (defined once at mount, called from + # the failure branch). + assert "showError()" in block + fn_show = js.find("const showError") + show = js[fn_show : js.find("\n };", fn_show)] + assert "error.hidden = false" in show + assert "input.value = \"\"" in show + assert "input.focus()" in show + + +def test_gate_finds_its_markup_by_class() -> None: + """The gate markup differs only in ids across the two pages + (#auth-gate / #doc-auth-gate) — the module finds it by the shared + .auth-gate CLASS (the ONE section on the page), and the token + input by name (the form field, not the id).""" + js = _text(TOKEN_GATE_JS) + assert 'querySelector(".auth-gate")' in js + assert 'input[name="token"]' in js + + +# ---------- header.js: the full-whoami plumbing ---------- + + +def test_header_fetch_whoami_is_the_single_call_site() -> None: + """The string fetch("/api/whoami") appears in header.js EXACTLY + ONCE (the single-request contract — the file's comments also + mention whoami, so the pin is on the fetch call, not the word); + fetchWhoami is exported and fetchIsAdmin delegates to it (a token + user reads false from fetchIsAdmin — the admin surfaces key off + role === "admin").""" + js = _text(HEADER_JS) + assert 'fetch("/api/whoami")' in js + assert js.count('fetch("/api/whoami")') == 1, ( + "no second whoami call site may enter header.js" + ) + assert "export function fetchWhoami" in js + fn = js.find("export function fetchIsAdmin") + assert fn != -1 + body = js[fn : js.find("\n}", fn)] + assert "fetchWhoami()" in body, "fetchIsAdmin must delegate to fetchWhoami" + assert 'w.role === "admin"' in body + + +def test_sign_out_binding_drops_the_cached_token_before_reload() -> None: + """The sign-out binding (header.js, module-owned) removes + localStorage["bor.token"] — try/catch, the fail-silence storage + contract — AFTER the logout POST and BEFORE the reload: one + logout clears the server session AND the cached token, so a + signing-out token user meets the gate again on the next load.""" + js = _text(HEADER_JS) + logout = js.find('fetch("/api/logout", { method: "POST" })') + drop = js.find('localStorage.removeItem("bor.token")') + reload = js.find("window.location.reload()") + assert -1 < logout < drop < reload, ( + "sign out: logout → drop bor.token → reload (the order the contract pins)" + ) + + +def test_init_shared_header_keys_the_pair_off_authenticated() -> None: + """Phase 79: initSharedHeader's auth PAIR (Sign in / Sign out) keys + off the authenticated role — a token user (role "user") gets + sign-in hidden + sign-out visible; the admin-ONLY surfaces (nav + links, steering refresh) still key off role === "admin" (a user + gets the anonymous branch: links hidden, the panel REMOVED, + /api/steering never fetched). Admin/anonymous stays + byte-identical to phase 16/19.""" + js = _text(HEADER_JS) + fn = js.find("function initSharedHeader") + assert fn != -1 + body = js[fn : js.find("\n}", fn)] + assert 'link.hidden = signedIn' in body + assert "btn.hidden = !signedIn" in body + assert "navSources.hidden = !admin" in body + assert "steeringPanel?.remove();" in body + + +# ---------- index.html: the shell gate + boot order ---------- + + +def _script_srcs(html: str) -> list[str]: + return re.findall(r'<script[^>]*src="([^"]+)"', html) + + +def test_shell_loads_token_gate_after_router() -> None: + """index.html loads token-gate.js as a module AFTER app.js and + router.js (boot order: brand.js classic → app.js module → + router.js module → token-gate.js module) — the Containerfile + bundles it (the parity pin in + tests/integration/test_containerfile_assets.py covers the image). + No page loads it BEFORE app.js (the gate's boot call is awaited + by app.js's boot IIFE).""" + html = _text(INDEX_HTML) + srcs = _script_srcs(html) + tag = [s for s in srcs if "token-gate.js" in s] + assert tag, "the shell must load the token-gate module" + order = [ + srcs.index("assets/brand.js"), + srcs.index("/assets/app.js"), + srcs.index("/assets/router.js"), + srcs.index(tag[0]), + ] + assert order == sorted(order), ( + f"boot order brand.js → app.js → router.js → token-gate.js broken: {srcs}" + ) + m = re.search(r'<script[^>]*src="[^"]*token-gate\.js"[^>]*>', html) + assert m and 'type="module"' in m.group(0), "token-gate.js is an ES module" + + +def test_shell_gate_markup_ships_hidden_inert() -> None: + """The shell's gate (body-level, AFTER #main) ships hidden + inert + with the full contract: the #auth-gate section labelled by its h2 + ("Enter your access token"), the sub line, the labelled form with + the mono token input (autocomplete off — a token must never be + offered by the password manager) and the Sign in submit, the + role=alert error line (hidden, the owner-locked copy), and the + "Sign in as admin" link (the header's ?next= convention, the + no-JS fallback).""" + html = _text(INDEX_HTML) + # The section: body-level, after #main (before the footer). + tag = re.search(r'<section[^>]*id="auth-gate"[^>]*>', html) + assert tag, "the shell must carry the #auth-gate section" + assert "hidden" in tag.group(0) and "inert" in tag.group(0), ( + "the gate ships hidden + inert (the ship-hidden pattern)" + ) + assert 'class="auth-gate"' in tag.group(0) + assert 'aria-labelledby="auth-gate-title"' in tag.group(0) + main_end = html.find("</main>") + assert main_end < html.find('id="auth-gate"'), "the gate sits AFTER #main" + # The content (the #sources-gate visual language). + assert '<h2 id="auth-gate-title">Enter your access token</h2>' in html + assert "Shared chats stay open" in html + form = re.search(r'<form[^>]*id="auth-gate-form"[^>]*>', html) + assert form, "the gate form" + assert '<label class="visually-hidden" for="auth-gate-input">Access token</label>' in html + inp = re.search(r'<input[^>]*id="auth-gate-input"[^>]*>', html) + assert inp, "the token input" + for attr in ( + 'name="token"', + 'type="text"', + 'autocomplete="off"', + 'autocapitalize="none"', + 'spellcheck="false"', + "required", + ): + assert attr in inp.group(0), f"the token input must carry {attr}" + assert 'type="submit"' in html and "Sign in" in html + err = re.search(r'<p[^>]*class="auth-gate-error"[^>]*id="auth-gate-error"[^>]*>', html) + assert err, "the error line" + assert 'role="alert"' in err.group(0) and "hidden" in err.group(0) + assert "That token isn" in html, "the owner-locked error copy" + assert 'href="/login.html?next=/"' in html, "the admin link (?next= convention)" + + +def test_shell_boots_the_gate_from_app_js() -> None: + """app.js awaits mountGate(#main, no-op) at boot — BEFORE its + initSharedHeader — so a silent re-auth lands before the first + whoami fires (the header sees the post-auth role deterministically). + In the shell, onAuthed needs no view work: the lazy views mount on + first show exactly as today (mount-once, hide-forever untouched).""" + js = _text(APP_JS) + assert 'from "./token-gate.js"' in js + gate_i = js.find('mountGate(document.getElementById("main"), () => {})') + assert gate_i != -1, "the shell's boot call (no-op onAuthed)" + init_i = js.find("await initSharedHeader();", gate_i) + assert -1 < gate_i < init_i, "the gate settles BEFORE the header boots" + + +# ---------- document.html / document.js: the viewer gate ---------- + + +def test_viewer_gate_markup_reuses_the_shell_copy_renamed() -> None: + """document.html carries the SAME gate markup as the shell, the ids + renamed (#doc-auth-gate / #doc-auth-gate-form / #doc-auth-gate-input + / #doc-auth-gate-error / #doc-auth-gate-title) — hidden + inert, + the labelled form + input + role=alert error + admin link.""" + html = _text(DOCUMENT_HTML) + tag = re.search(r'<section[^>]*id="doc-auth-gate"[^>]*>', html) + assert tag, "the viewer must carry the #doc-auth-gate section" + assert "hidden" in tag.group(0) and "inert" in tag.group(0) + assert 'class="auth-gate"' in tag.group(0) + assert 'aria-labelledby="doc-auth-gate-title"' in tag.group(0) + assert '<h2 id="doc-auth-gate-title">Enter your access token</h2>' in html + assert re.search(r'<form[^>]*id="doc-auth-gate-form"[^>]*>', html) + assert re.search( + r'<input[^>]*id="doc-auth-gate-input"[^>]*name="token"[^>]*>', html + ) or re.search( + r'<input[^>]*name="token"[^>]*id="doc-auth-gate-input"[^>]*>', html + ), "the viewer's token input" + assert re.search(r'id="doc-auth-gate-error"[^>]*role="alert"', html) or re.search( + r'role="alert"[^>]*id="doc-auth-gate-error"', html + ) + + +def test_viewer_wires_the_gate_around_the_existing_boot() -> None: + """document.js wires mountGate(#main, onAuthed) — onAuthed runs the + content load for a SIGNED-IN role only (anonymous never fetches the + gated content; the inline gate is the surface) — and the shared + header boots in the .then AFTER the gate settles, for EVERY role + (the gate locks #main, not the header — the anonymous contract is + byte-identical to the shell). Awaiting the gate first is what makes + the header race-free: the settled whoami is the single request both + the gate and the header reuse (no second whoami, no stale bar).""" + js = _text(DOCUMENT_JS) + assert 'from "./token-gate.js"' in js + gate_i = js.find('mountGate(document.getElementById("main")') + assert gate_i != -1 + boot = js[gate_i : gate_i + 400] + assert "load()" in boot, "onAuthed runs the content load (signed-in role only)" + # The header boots AFTER the gate settles (the .then) — not before + # it, not inside onAuthed: one settled whoami for gate + header. + load_i = boot.find("load()") + then_i = boot.find(".then(") + assert -1 < load_i < then_i, ("onAuthed (load) comes before the header .then") + assert "initSharedHeader()" in boot[then_i:], ( + "the header must boot on the settled whoami, after the gate" + ) + # The bare boot call is gone — the ONLY load(); statement in the + # file is the one inside the gate's onAuthed callback. + assert js.count("load();") == 1, ( + "the un-gated load() call must be gone (onAuthed is the only caller)" + ) + # The whoami single-request contract survives: no direct whoami + # fetch in the viewer script (header.js's cached promise). + assert 'fetch("/api/whoami")' not in js diff --git a/tests/unit/test_tokens.py b/tests/unit/test_tokens.py new file mode 100644 index 0000000..14ee646 --- /dev/null +++ b/tests/unit/test_tokens.py @@ -0,0 +1,174 @@ +"""Unit: the API-token service (phase 79, task 02). + +Covers ``app.core.tokens`` — the create/lookup/revoke service behind the +admin API (task 02's endpoints) and the future token-auth login (task +03): + +* ``generate_token`` — the ``bor_`` + 32-hex shape, two calls differ; +* ``hash_token`` — deterministic 64-hex digest of the FULL string (a + stripped prefix can never collide); +* ``create_token`` / ``find_active_by_token`` — the round-trip (the + plaintext resolves to its active row) and the GENERIC-MISS contract: + revoked, unknown, empty, short and wrong-prefix candidates all return + the same ``None`` (hash matches no row — there is no "almost" path, + which is what makes the task-03 one-generic-401 safe); +* ``revoke`` — stamps ``revoked_at`` once (idempotent re-call keeps the + original stamp; False only for a missing id); +* ``mark_used`` — bumps ``last_used_at``. + +House DB-test pattern (the ``test_sources_meta`` precedent): the service +is a thin session wrapper whose contract (server-default ``created_at``, +the unique hash index) only holds against a real database — runs against +the local compose Postgres, skips with clear instructions when the stack +is not up. The service flushes, never commits: the tests commit, as the +endpoints do. +""" +from __future__ import annotations + +import re +import uuid +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core import tokens as tok +from app.models import ApiToken + +TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$") + + +@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_and_commit(db: Session, label: str = "alice") -> tuple[ApiToken, str]: + """Service create + the endpoint's commit/refresh (the house split).""" + row, plaintext = tok.create_token(db, label) + db.commit() + db.refresh(row) + return row, plaintext + + +def test_generate_token_shape_and_uniqueness() -> None: + """``bor_`` + exactly 32 lowercase hex chars (128-bit), no reuse.""" + a = tok.generate_token() + b = tok.generate_token() + assert TOKEN_SHAPE.fullmatch(a), a + assert TOKEN_SHAPE.fullmatch(b), b + assert a != b + + +def test_hash_token_deterministic_64_hex_full_string() -> None: + """Same token → same digest; 64 hex chars; the FULL string is hashed + (hashing ``bor_X`` ≠ hashing ``X`` — a stripped prefix can never + collide with another token's hash).""" + plain = tok.generate_token() + h1 = tok.hash_token(plain) + h2 = tok.hash_token(plain) + assert h1 == h2 + assert re.fullmatch(r"[0-9a-f]{64}", h1), h1 + assert tok.hash_token(plain.removeprefix("bor_")) != h1 + + +def test_create_and_find_round_trip(db: Session) -> None: + """create → the row carries ONLY the hash; the plaintext resolves + back to the same active row (the task-03 login path).""" + row, plaintext = _create_and_commit(db, " alice ") + + # The service strips the label; the row only ever carries the hash. + assert row.label == "alice" + assert row.token_hash == tok.hash_token(plaintext) + assert row.token_hash != plaintext + assert row.last_used_at is None + assert row.revoked_at is None + + hit = tok.find_active_by_token(db, plaintext) + assert hit is not None + assert hit.id == row.id + assert hit.label == "alice" + + +@pytest.mark.parametrize( + ("candidate", "why"), + [ + ("", "empty string"), + ("bor_ab", "short string"), + ("bor_" + "f" * 31, "31 hex chars (one short)"), + ( + "zzz_" + "0" * 32, + "wrong prefix", + ), + ], + ids=["empty", "short", "31-hex", "wrong-prefix"], +) +def test_find_active_by_token_generic_miss(db: Session, candidate: str, why: str) -> None: + """Every malformed/unknown candidate is the SAME miss — the hash + simply matches no row (no "almost" path, no per-shape error).""" + _, plaintext = _create_and_commit(db) # the table is NOT empty + assert tok.find_active_by_token(db, candidate) is None, why + # …while the stored token still resolves (the miss is specific). + assert tok.find_active_by_token(db, plaintext) is not None + + +def test_find_active_by_token_unknown_well_formed(db: Session) -> None: + """A well-formed token that was never stored (or belongs to another + admin) misses — no enumeration surface beyond the unique-index hit.""" + unknown = tok.generate_token() # never persisted + assert tok.find_active_by_token(db, unknown) is None + + +def test_find_active_by_token_revoked_misses(db: Session) -> None: + """A revoked row never resolves: the hash still matches the row, but + ``revoked_at IS NULL`` is part of the contract (A4 — dead is dead, + enforced immediately).""" + row, plaintext = _create_and_commit(db) + assert tok.revoke(db, row.id) is True + db.commit() + assert tok.find_active_by_token(db, plaintext) is None + + +def test_revoke_stamps_once_and_false_only_for_missing(db: Session) -> None: + """First revoke stamps ``revoked_at`` (True); the second call is + idempotent (True, the ORIGINAL stamp kept — no re-stamp); False only + when the row does not exist.""" + row, _ = _create_and_commit(db) + assert tok.revoke(db, row.id) is True + db.commit() + db.refresh(row) + first_stamp = row.revoked_at + assert first_stamp is not None + + # Idempotent: True again, and the first stamp survives. + assert tok.revoke(db, row.id) is True + db.commit() + db.refresh(row) + assert row.revoked_at == first_stamp + + # A missing id is the only False. + assert tok.revoke(db, uuid.uuid4()) is False + + +def test_mark_used_stamps_last_used_at(db: Session) -> None: + """NULL until first use, then "now" (UTC, tz-aware).""" + row, _ = _create_and_commit(db) + assert row.last_used_at is None + + before = datetime.now(UTC) + tok.mark_used(row) + after = datetime.now(UTC) + + assert row.last_used_at is not None + assert row.last_used_at.tzinfo is not None + assert before - timedelta(seconds=1) <= row.last_used_at <= after + timedelta( + seconds=1 + ) + db.commit()