feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public
This commit is contained in:
Binary file not shown.
@@ -1,76 +0,0 @@
|
|||||||
# Phase 02 — Story: Import Documents
|
|
||||||
|
|
||||||
**Story:** `.agent/user_stories/import-documents.md`
|
|
||||||
**Context:** `.agent/PLAN.md` §5 (data model), §9 (logging), §11 (import workflow)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
The importer (`scripts/import_docs.py`) + `GET /api/docs` + the Sources page
|
|
||||||
rendering the indexed documents — the knowledge base becomes refreshable.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
1. `app/rag/__init__.py`, `app/rag/chunker.py` — markdown-aware chunker
|
|
||||||
(PLAN §5 policy: heading splits, 2000-char target, 200 overlap, keep
|
|
||||||
nearest heading). Pure functions, fully unit-testable.
|
|
||||||
2. `app/rag/llm.py` — `LLMClient` (openai async) with `embed(texts) ->
|
|
||||||
list[list[float]]` (batched, `BOR_EMBED_BATCH_SIZE`) and a
|
|
||||||
`embed_one`; dimension check vs `settings.embedding_dim` with a loud,
|
|
||||||
actionable error. (Chat streaming is added in Phase 03 on this client.)
|
|
||||||
3. `app/rag/importer.py` — the core: directory walk (exclusion list, PLAN
|
|
||||||
A9; `*.md` only), sha256 delta vs `documents.content_hash`,
|
|
||||||
upsert-or-skip, two-phase chunk replace (insert doc → replace chunks →
|
|
||||||
embed → commit), `--prune` support, per-file + summary logging.
|
|
||||||
4. `scripts/import_docs.py` — CLI wrapper (argparse): repeatable
|
|
||||||
`--source` (default `~/Homelab` `~/Deployments`, `expanduser`),
|
|
||||||
`--prune`, `--limit`.
|
|
||||||
5. `app/api/docs.py` — `GET /api/docs` → `{"documents": [DocSummary]}`
|
|
||||||
(include `chunks` count via `func.count`); mount in `app/main.py`
|
|
||||||
**before** the static mount.
|
|
||||||
6. `frontend/assets/sources.js` + `sources.html` polish — wire the real
|
|
||||||
endpoint (already scaffolded to expect this shape); keep the empty state.
|
|
||||||
7. Update `README.md` §Knowledge Base Import with the final commands +
|
|
||||||
exclusion list + "update your docs → re-run the script" workflow.
|
|
||||||
|
|
||||||
## UI Verification
|
|
||||||
Compare `/sources.html` against the story's "UI Visualization & Structure":
|
|
||||||
stat cards `auto-fit minmax(170px,1fr)`; full-width table (≥85% container);
|
|
||||||
mono path column with `title` ellipsis; empty state with the exact command;
|
|
||||||
`<caption class="visually-hidden">`, `scope="col"`, scroll wrapper
|
|
||||||
`role="region" tabindex="0"`. No CDN refs. Take a 1280px and 375px
|
|
||||||
screenshot pass before finishing.
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- Unit: chunker (heading splits, overlap, short-doc single chunk, code
|
|
||||||
fences kept intact), exclusion walk (temp tree with `.venv` junk),
|
|
||||||
delta logic (unchanged/changed/pruned via tmp Postgres or in-memory fakes
|
|
||||||
— real DB preferred since compose runs locally).
|
|
||||||
- Integration: `GET /api/docs` empty shape + populated shape; importer
|
|
||||||
end-to-end against `tests/fixtures/docs/` into a test schema.
|
|
||||||
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%**
|
|
||||||
on `app/` (importer + chunker + client are the bulk; test them hard).
|
|
||||||
|
|
||||||
## Playwright Execution Phase
|
|
||||||
Run ONLY this story's suite (DB must be up: `podman compose up -d db`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/e2e/test_import_documents.py -v --no-cov
|
|
||||||
```
|
|
||||||
|
|
||||||
The test file implements the story's Playwright Mapping Rule (seed via the
|
|
||||||
import function against `tests/fixtures/docs/` with the mock LLM; assert
|
|
||||||
Sources page rows, layout width, and the empty state).
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- [ ] `uv run python -m scripts.import_docs` (fixtures) imports all 3 docs,
|
|
||||||
re-run reports `unchanged`
|
|
||||||
- [ ] `GET /api/docs` + Sources page show the docs (real run: `~/Homelab`
|
|
||||||
+ `~/Deployments` counts logged)
|
|
||||||
- [ ] unit + integration green, coverage >90%
|
|
||||||
- [ ] UI verification passed (screenshots attached to the phase record)
|
|
||||||
- [ ] story E2E green in isolation
|
|
||||||
- [ ] README import section updated
|
|
||||||
- [ ] committed
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
```bash
|
|
||||||
git add -A && git commit --no-gpg-sign -m "feat(kb): markdown importer with sha256 deltas, chunking, batched embeddings, and Sources page"
|
|
||||||
```
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Phase 03 — Story: Chat RAG Answer (happy path)
|
|
||||||
|
|
||||||
**Story:** `.agent/user_stories/chat-rag-answer.md`
|
|
||||||
**Context:** `.agent/PLAN.md` §3 (data flow), §4 (SSE contract), §6 (persona), §9 (logging)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
The core product loop: question → embed → cosine top-4 → full top-2
|
|
||||||
documents → `turbo` (streamed) → chippy grounded answer with source chips.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
1. `app/rag/retriever.py` — `retrieve(db, question_embedding) ->
|
|
||||||
list[RetrievedChunk]` (score = 1 − distance, `ORDER BY embedding <=> $1
|
|
||||||
LIMIT BOR_TOP_K_CHUNKS`) + `select_documents(chunks, n) -> list[Document]`
|
|
||||||
(distinct by `document_id`, ranked by best chunk score, cap content at
|
|
||||||
`BOR_MAX_CONTEXT_CHARS` with `[…truncated…]`).
|
|
||||||
2. `app/rag/prompts.py` — locked persona + HONESTY GATE prompt builder
|
|
||||||
(PLAN §6 verbatim, `<relevance>HIGH|LOW</relevance>`, `<documents>`
|
|
||||||
block; LOW mode includes the `DEFLECT_MODE` marker + weak-hit titles).
|
|
||||||
3. `app/rag/llm.py` — add `chat_stream(messages) -> AsyncIterator[str]`
|
|
||||||
(openai async, `stream=True`, `model=turbo`, temperature 0.4,
|
|
||||||
max_tokens ~700).
|
|
||||||
4. `app/api/chat.py` — `POST /api/chat` (ChatRequest) → `StreamingResponse`
|
|
||||||
(SSE): emit `delta` events from the stream, then the `done` event
|
|
||||||
(deflected, sources, suggestions); insert `query_log` row (deflected=
|
|
||||||
false this phase); per-turn log line (PLAN §9); structured error events
|
|
||||||
(`{"type":"error","detail":…}`) on LLM/DB failure.
|
|
||||||
5. `frontend/assets/app.js` — replace the placeholder handler: `fetch` +
|
|
||||||
`ReadableStream` SSE parser; render deltas live into a brain bubble
|
|
||||||
(reuse the typing-indicator → streaming handoff); on `done`, append
|
|
||||||
`.source-chip`s under the bubble; on error, show the banner (full
|
|
||||||
state machine is Phase 06 — keep it simple-correct here).
|
|
||||||
6. Tune `settings.suggestions` if the real Homelab import revealed better
|
|
||||||
defaults (optional here; Phase 05 owns the chips).
|
|
||||||
|
|
||||||
## UI Verification
|
|
||||||
Against the story's "UI Visualization & Structure": bubbles right/left
|
|
||||||
(brand vs surface, ≥4.5:1 text), avatar 🧠, source chips mono/brand-soft
|
|
||||||
with `source/path` and ellipsis, safe markdown (paste an answer containing
|
|
||||||
`<script>alert(1)</script>` from the mock to prove it's escaped). Chat
|
|
||||||
column 46rem centered. 1280px + 375px screenshot pass.
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- Unit: retriever ordering/dedup/cap (fake rows), prompt builder (HIGH
|
|
||||||
contains documents + `HIGH`, LOW contains `DEFLECT_MODE` + titles only,
|
|
||||||
persona rules present verbatim), SSE event serialization.
|
|
||||||
- Integration: `/api/chat` against the mock LLM with a seeded temp schema —
|
|
||||||
assert SSE delta sequence, `done` payload (sources non-empty,
|
|
||||||
deflected false), `query_log` row, error event when LLM unreachable.
|
|
||||||
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%**.
|
|
||||||
|
|
||||||
## Playwright Execution Phase
|
|
||||||
Run ONLY this story's suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/e2e/test_chat_rag.py -v --no-cov
|
|
||||||
```
|
|
||||||
|
|
||||||
Implements the story mapping: streamed grounded answer + `kubernetes.md`
|
|
||||||
source chip + button recovery; DB `query_log` assertion; raw SSE shape
|
|
||||||
check via `httpx`.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- [ ] end-to-end: question → streamed chippy answer citing `kubernetes.md`
|
|
||||||
- [ ] `query_log` row per turn; per-turn log line in stdout
|
|
||||||
- [ ] LLM-down path shows error banner, no stuck button
|
|
||||||
- [ ] unit + integration green, coverage >90%
|
|
||||||
- [ ] UI verification passed
|
|
||||||
- [ ] story E2E green in isolation
|
|
||||||
- [ ] committed
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
```bash
|
|
||||||
git add -A && git commit --no-gpg-sign -m "feat(rag): stream grounded chat answers via pgvector cosine retrieval with source citations"
|
|
||||||
```
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Phase 04 — Story: Honest Deflection
|
|
||||||
|
|
||||||
**Story:** `.agent/user_stories/honest-deflection.md`
|
|
||||||
**Context:** `.agent/PLAN.md` §4, §6 (honesty gate), §9
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
When retrieval finds nothing relevant, Brain says so — plainly, chippily —
|
|
||||||
and offers real alternatives. No hallucinated confidence.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
1. `app/api/chat.py` — apply the gate: `best_score < settings.relevance_
|
|
||||||
threshold` ⇒ build LOW prompt (`DEFLECT_MODE`, weak-hit titles only),
|
|
||||||
else HIGH prompt. Set `deflected` on the `done` event + `query_log`.
|
|
||||||
2. Deflection `suggestions[]`: ask `turbo` (same stream) to include 2–3
|
|
||||||
alternative questions; simplest robust approach — have the LLM emit them
|
|
||||||
inline in the answer AND have the server derive 2–3 chips from the
|
|
||||||
weak-hit document titles (deterministic fallback if the model doesn't
|
|
||||||
produce a parsable list). Ship the deterministic title-derived chips as
|
|
||||||
the v1 behavior; model-generated list is a bonus if trivially parseable.
|
|
||||||
3. `frontend/assets/app.js` — on `done.deflected`: add `.is-deflected`
|
|
||||||
class to the bubble, render "Maybe try:" chips below it (same
|
|
||||||
`.suggestion-chip` component; clicking fills the input — full submit
|
|
||||||
behavior lands with Phase 05's chip component; wire what exists).
|
|
||||||
4. `README.md` — document `BOR_RELEVANCE_THRESHOLD` tuning + the
|
|
||||||
deflection behavior in Troubleshooting.
|
|
||||||
|
|
||||||
## UI Verification
|
|
||||||
Against the story: amber bubble (`#fff7e8` bg / `#f59e0b` border) distinct
|
|
||||||
from normal answers; "Maybe try:" chips ≥44px, brand-soft/brand-ink;
|
|
||||||
contrast pairs verified (ink on accent-bg ≥ 9:1, accent-ink ≥ 8:1);
|
|
||||||
chip group has an accessible name; mobile wraps cleanly.
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- Unit: gate boundary with a fake retriever — score exactly 0.30 → HIGH;
|
|
||||||
0.2999 → LOW; LOW prompt contains `DEFLECT_MODE` + titles, no full docs;
|
|
||||||
HIGH unaffected. Suggestions derivation (2–3, non-empty, derived from
|
|
||||||
titles).
|
|
||||||
- Integration: mock LLM — off-topic question ("sourdough") ⇒ `done`
|
|
||||||
`deflected: true`, `query_log.deflected=true`, weak `top_score` stored;
|
|
||||||
on-topic question ⇒ `deflected: false`.
|
|
||||||
- Coverage: **>90%** on `app/`.
|
|
||||||
|
|
||||||
## Playwright Execution Phase
|
|
||||||
Run ONLY this story's suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/e2e/test_honest_deflection.py -v --no-cov
|
|
||||||
```
|
|
||||||
|
|
||||||
Implements the story mapping: off-topic question ⇒ `.is-deflected` bubble
|
|
||||||
matching /haven't done anything like that/i + ≥2 "Maybe try:" chips; chip
|
|
||||||
click behavior; (unit boundary test lives in pytest, not here).
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- [ ] off-topic question never gets a confident fake answer
|
|
||||||
- [ ] deflected bubble visually distinct + alternative chips render
|
|
||||||
- [ ] `query_log.deflected` accurate; threshold env-tunable
|
|
||||||
- [ ] unit + integration green, coverage >90%
|
|
||||||
- [ ] UI verification passed
|
|
||||||
- [ ] story E2E green in isolation
|
|
||||||
- [ ] committed
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
```bash
|
|
||||||
git add -A && git commit --no-gpg-sign -m "feat(rag): honest deflection gate with amber UI state and alternative-question chips"
|
|
||||||
```
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Phase 05 — Story: Suggestion Chips
|
|
||||||
|
|
||||||
**Story:** `.agent/user_stories/suggestion-chips.md`
|
|
||||||
**Context:** `.agent/PLAN.md` §7 (UI/UX), story file for chip spec
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Zero-friction onboarding: 3–4 real example questions on first load,
|
|
||||||
clickable → filled → submitted, keyboard-first, mobile-scrollable.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
1. `app/config.py` — confirm `suggestions` is env-overridable
|
|
||||||
(`BOR_SUGGESTIONS` as JSON list via pydantic-settings) and tune the
|
|
||||||
defaults against the *actually imported* Homelab/Deployments topics
|
|
||||||
(read a sample of `documents` titles; pick questions real answers
|
|
||||||
exist for).
|
|
||||||
2. `app.js` — extract a `renderChips(container, items, {onSelect})` helper;
|
|
||||||
real `<button type="button" class="suggestion-chip" role="listitem">`
|
|
||||||
inside `#suggestions[role="list"]`; onboarding `onSelect` = fill
|
|
||||||
`#message-input` + focus + `composer.requestSubmit()`. Reuse the same
|
|
||||||
helper for deflection chips (Phase 04) with the same submit behavior.
|
|
||||||
3. Empty-state lifecycle: first user message hides `#empty-state` (already
|
|
||||||
done in `addMessage`) — verify chips don't linger in the conversation.
|
|
||||||
4. Mobile CSS check: chip row `nowrap + overflow-x auto` at ≤640px (tokens
|
|
||||||
already exist — verify, don't duplicate).
|
|
||||||
|
|
||||||
## UI Verification
|
|
||||||
Against the story: pills 999px radius, ≥44px, brand-soft/brand-ink (≥6:1),
|
|
||||||
hover/active states; desktop centered wrap vs mobile single scroll row;
|
|
||||||
Tab order reaches chips before the composer input is required; screen
|
|
||||||
reader: group labeled "Suggested questions". Screenshot pass 1280px + 375px.
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- Unit/integration: `GET /api/suggestions` honors `BOR_SUGGESTIONS` env
|
|
||||||
override (JSON list); default list has ≥3 non-empty strings.
|
|
||||||
- Coverage: **>90%** on `app/` (JS is covered by E2E).
|
|
||||||
|
|
||||||
## Playwright Execution Phase
|
|
||||||
Run ONLY this story's suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov
|
|
||||||
```
|
|
||||||
|
|
||||||
Implements the story mapping: chips render (≥3, role=list); chip click
|
|
||||||
submits (user bubble with exact chip text + mock reply); keyboard Tab+Enter
|
|
||||||
activates; 375px chip row is a horizontal scroll row.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- [ ] onboarding chips render from the API; click = one-tap question
|
|
||||||
- [ ] keyboard + SR usable; mobile scroll row
|
|
||||||
- [ ] deflection chips share the component + submit behavior
|
|
||||||
- [ ] unit + integration green, coverage >90%
|
|
||||||
- [ ] story E2E green in isolation
|
|
||||||
- [ ] committed
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
```bash
|
|
||||||
git add -A && git commit --no-gpg-sign -m "feat(ui): onboarding suggestion chips with one-tap submit, keyboard access, and mobile scroll row"
|
|
||||||
```
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Phase 06 — Story: Loading Feedback & Progress
|
|
||||||
|
|
||||||
**Story:** `.agent/user_stories/loading-feedback.md`
|
|
||||||
**Context:** `.agent/PLAN.md` §7.4 ("never stale" contract), §9
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
An unambiguous state machine — `idle → thinking → streaming → done |
|
|
||||||
error → idle` — so the user always knows what's happening, and a stale
|
|
||||||
Send button is impossible.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
1. `app.js` — formalize the state machine (single `setUiState(state)`
|
|
||||||
function driving: typing indicator, send button disabled/spinner/label,
|
|
||||||
`#send-status` live text). Replace ad-hoc busy handling from Phase 03.
|
|
||||||
2. Pre-token: typing indicator (`role="status"`,
|
|
||||||
`aria-label="Brain of Reese is thinking"`); after 10s pre-token, update
|
|
||||||
the label with elapsed seconds (setInterval, cleared on state change).
|
|
||||||
3. Streaming: first `delta` removes the typing indicator and starts
|
|
||||||
appending to the answer bubble; button stays busy.
|
|
||||||
4. Error paths: `{"type":"error"}` SSE event, non-2xx response, or
|
|
||||||
**120s client-side timeout** (clear on first delta) → red banner
|
|
||||||
`role="alert"` ("Try again — if this persists, check the LLM is
|
|
||||||
reachable") + state → idle.
|
|
||||||
5. `prefers-reduced-motion`: CSS already slows animations — verify; add a
|
|
||||||
static fallback for the dots if needed.
|
|
||||||
6. Server: confirm the per-turn log line includes `embed_ms` and
|
|
||||||
`total_ms` (add if Phase 03 omitted it).
|
|
||||||
|
|
||||||
## UI Verification
|
|
||||||
Walk the full state machine by hand (dev server + mock LLM slow path):
|
|
||||||
submit → indicator + "Thinking…" disabled button → live tokens → done
|
|
||||||
(enabled, focused input). Kill the mock mid-stream → banner + recovery.
|
|
||||||
Contrast of disabled button + spinner OK; reduced-motion pass.
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- Unit/integration: SSE error event serialization; timeout constant
|
|
||||||
exported/testable; (JS logic is E2E-covered).
|
|
||||||
- Coverage: **>90%** on `app/`.
|
|
||||||
|
|
||||||
## Playwright Execution Phase
|
|
||||||
Run ONLY this story's suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/e2e/test_loading_feedback.py -v --no-cov
|
|
||||||
```
|
|
||||||
|
|
||||||
Implements the story mapping (mock LLM's 3s "pretend to think slowly"
|
|
||||||
warm-up + a fixture that stops the mock): typing indicator visible during
|
|
||||||
pre-token and gone by answer; button disabled→"Thinking…"→enabled "Send";
|
|
||||||
streaming appends (two-timestamp length check); LLM-down ⇒ `role=alert`
|
|
||||||
banner + button recovered.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- [ ] every in-flight state has a visible indicator; button never zombies
|
|
||||||
- [ ] error + 120s timeout paths both recover cleanly
|
|
||||||
- [ ] reduced-motion respected
|
|
||||||
- [ ] unit + integration green, coverage >90%
|
|
||||||
- [ ] story E2E green in isolation
|
|
||||||
- [ ] committed
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
```bash
|
|
||||||
git add -A && git commit --no-gpg-sign -m "feat(ui): explicit chat state machine — typing indicator, streaming progress, timeout and error recovery"
|
|
||||||
```
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Phase 07 — Story: Responsive, Polished, Accessible UI
|
|
||||||
|
|
||||||
**Story:** `.agent/user_stories/responsive-polish.md`
|
|
||||||
**Context:** `.agent/PLAN.md` §7 (the whole UI/UX strategy)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
The final visual + accessibility audit pass across chat and Sources. No
|
|
||||||
new features — enforce PLAN §7 end-to-end and fix every deviation.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
1. Viewport sweep (360 / 375 / 768 / 1280 / 1600) on both pages: fix
|
|
||||||
overflow, pinched columns, dead whitespace. Chat column stays ≤46rem
|
|
||||||
centered; Sources table full-width with horizontal scroll <640px.
|
|
||||||
2. A11y sweep (both pages): landmarks, skip link, labels on every input,
|
|
||||||
`aria-label` on every icon-only control, `:focus-visible` outline on
|
|
||||||
every focusable, `aria-live` regions intact, no contrast <4.5:1
|
|
||||||
(compute, don't eyeball — use the E2E helper).
|
|
||||||
3. Reduced-motion + long-content pass (60-char paths, long answers).
|
|
||||||
4. No-CDN re-verification on **both** pages (extend the integration test
|
|
||||||
to `/sources.html` if it only covers `/`).
|
|
||||||
5. Final README polish pass: screenshots section (optional), quickstart
|
|
||||||
sanity, "Update your documents" workflow prominent.
|
|
||||||
|
|
||||||
## UI Verification
|
|
||||||
This phase IS the verification: the E2E below is the acceptance test.
|
|
||||||
Additionally, manual screenshot pass at 1280px + 375px for both pages,
|
|
||||||
reviewed against PLAN §7.1–7.4 before committing.
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- Integration: no-CDN check extended to Sources page; health unchanged.
|
|
||||||
- Coverage: **>90%** on `app/` (final state of the whole app).
|
|
||||||
- Whole suite green: `uv run pytest` (unit+integration) — the entire
|
|
||||||
repo must be green at this phase.
|
|
||||||
|
|
||||||
## Playwright Execution Phase
|
|
||||||
Run ONLY this story's suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov
|
|
||||||
```
|
|
||||||
|
|
||||||
Implements the story mapping: no horizontal overflow at 5 viewports (both
|
|
||||||
pages); chat column capped + centered at 1600px; Sources table ≥80%
|
|
||||||
container at 1280px; landmarks/labels/skip-link sweep; WCAG contrast
|
|
||||||
pairs ≥4.5:1 (computed); reduced-motion honored.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- [ ] all six mapping tests pass at every viewport
|
|
||||||
- [ ] zero known a11y deviations against PLAN §7.2
|
|
||||||
- [ ] whole pytest suite green + coverage >90%
|
|
||||||
- [ ] README polished
|
|
||||||
- [ ] committed (this commit marks v1.0 feature-complete)
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
```bash
|
|
||||||
git add -A && git commit --no-gpg-sign -m "feat(ui): responsive + WCAG AA polish pass across chat and sources — v1 feature complete"
|
|
||||||
```
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
# Phase 09 — Story: Retrieval Quality — Multi-Format Ingestion + Hybrid Search
|
|
||||||
|
|
||||||
**Story:** `.agent/user_stories/retrieval-quality.md`
|
|
||||||
**Context:** `.agent/PLAN.md` §3 (data flow), §5 (data model), §6 (retrieval), §11 (import)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Fix "RAG retrieval is terrible": ingest the full text-format set (not
|
|
||||||
just `.md`), purge vendored-cache junk from the index, and replace
|
|
||||||
pure-cosine top-4 with hybrid (vector + Postgres FTS, RRF-fused)
|
|
||||||
retrieval so name-your-tool questions find the right document.
|
|
||||||
|
|
||||||
## Owner permission (recorded per phase protocol)
|
|
||||||
> "I'm giving you explicit permission to update the locked decisions and
|
|
||||||
> proceed with writing all 3 of these phases" — Reese, 2026-08-21.
|
|
||||||
|
|
||||||
This phase revises anchors **A9** (content scope: `*.md` only →
|
|
||||||
`md, markdown, txt, yaml, yml, json, py` + hidden-dir skip), **A7**
|
|
||||||
(pure-cosine top-4 → hybrid RRF retrieval; the whole-document context
|
|
||||||
contract is preserved), **A8** (gate: LOW only when best cosine <
|
|
||||||
threshold **and** zero FTS hits; threshold re-tuned 0.30 → 0.62 default).
|
|
||||||
`PLAN.md` anchors were updated 2026-08-21 under this permission.
|
|
||||||
|
|
||||||
## Evidence (measured 2026-08-21 against the live KB + `embed` model)
|
|
||||||
- "How did I install gitlab?": the best `gitlab.md` chunk ranks **7th**
|
|
||||||
(cosine 0.804) — outside the top-4 window. Ranks 1–6: a vendored-cache
|
|
||||||
README (`.esphome/.espressif/…/esp-tflite-micro/README.md`, 0.838) and
|
|
||||||
generic templates (`project_readme_template.md`, `templates/…/foobar.
|
|
||||||
md`, 0.81–0.82). The LLM therefore answered from junk docs and honestly
|
|
||||||
reported "no notes on gitlab".
|
|
||||||
- Corpus cosine range: **0.41–0.84** — the old 0.30 gate never
|
|
||||||
discriminated.
|
|
||||||
- FTS: `plainto_tsquery('english','gitlab')` matches **exactly**
|
|
||||||
gitlab.md's 4 chunks and nothing else.
|
|
||||||
- ~470 of 672 indexed docs live under dot-prefixed path components
|
|
||||||
(vendored caches) that A9's exclusion list doesn't cover.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
Phases 01–07 (02 importer, 03 retriever, 04 gate especially).
|
|
||||||
Independent of 08 (backend + E2E only). Phase 10 builds on the new
|
|
||||||
multi-format corpus.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
1. **Config** (`app/config.py`): `import_extensions` (csv, default
|
|
||||||
`md,markdown,txt,yaml,yml,json,py`; env `BOR_IMPORT_EXTENSIONS`),
|
|
||||||
`hybrid_vector_candidates` (30, `BOR_HYBRID_VECTOR_CANDIDATES`),
|
|
||||||
`hybrid_lexical_candidates` (30, `BOR_HYBRID_LEXICAL_CANDIDATES`),
|
|
||||||
`rrf_k` (60, `BOR_RRF_K`), `relevance_threshold` default **0.62**
|
|
||||||
(re-tuned; env override stays). In `tests/e2e/conftest.py`'s
|
|
||||||
`app_server` fixture set `BOR_RELEVANCE_THRESHOLD=0.30` — the mock's
|
|
||||||
token-overlap embeddings need their own calibration; this keeps
|
|
||||||
stories 02–07's E2E suites green.
|
|
||||||
2. **Chunker** (`app/rag/chunker.py`): add a `chunk_document(content,
|
|
||||||
path)` dispatcher by lowercased suffix + per-format functions —
|
|
||||||
**stdlib only, no new dependencies**:
|
|
||||||
- `yaml`/`yml`: split on `---` document separators and top-level keys
|
|
||||||
(indent-0 `key:` lines); every chunk keeps its key line as anchor.
|
|
||||||
- `json`: `json.dumps(obj, indent=2)` then split at top-level keys
|
|
||||||
(track brace depth); unparseable JSON → paragraph packing.
|
|
||||||
- `py`: stdlib `ast` top-level node line ranges → split at
|
|
||||||
defs/classes; oversized functions fall back to line packing.
|
|
||||||
- `txt`: paragraph packing (reuse `_paragraph_blocks`/`_pack_blocks`).
|
|
||||||
- All formats honor `HARD_MAX_CHARS` (1200 — the aipi ~1024-token
|
|
||||||
request cap) and the target/overlap settings; the `md` path stays
|
|
||||||
byte-for-byte unchanged (existing chunker tests must stay green).
|
|
||||||
3. **Importer** (`app/rag/importer.py`, `scripts/import_docs.py`):
|
|
||||||
extension filter (case-insensitive, config-driven); **skip any path
|
|
||||||
containing a dot-prefixed component** (hidden dirs); chunker dispatch
|
|
||||||
by suffix; `--prune` now also drops docs whose files **no longer
|
|
||||||
match the filter** (this is how the ~470 junk docs leave the index);
|
|
||||||
summary log gains per-format counts
|
|
||||||
(`formats=md:203,yaml:267,…`).
|
|
||||||
4. **Migration `0002_hybrid_retrieval.py`** (alembic):
|
|
||||||
- `chunks.tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english',
|
|
||||||
content)) STORED` + `CREATE INDEX … USING gin (chunks.tsv)`.
|
|
||||||
- `query_log.fts_hits INT` (nullable; pre-existing rows stay NULL).
|
|
||||||
5. **Retriever** (`app/rag/retriever.py`) — hybrid path:
|
|
||||||
- `retrieve(db, question, question_embedding)`: vector top-N (cosine,
|
|
||||||
as today) ∪ lexical top-N — `to_tsquery('english', <OR-joined
|
|
||||||
stemmed tokens of the question>)` (skip pure-stopword/no-token
|
|
||||||
questions → empty lexical list), ordered by `ts_rank` — fused with
|
|
||||||
RRF: `score = Σ 1/(k + rank)` over the lists a chunk appears in
|
|
||||||
(single-list chunks get one term; k from config).
|
|
||||||
- `RetrievedChunk` gains `cosine` (for the gate) and `fts_hit: bool`
|
|
||||||
alongside `score` (now the fused score, used for ranking);
|
|
||||||
`select_documents` / `weak_hit_titles` keep working off `score`.
|
|
||||||
- Deterministic tie-break: `(−fused, −cosine, document.path,
|
|
||||||
chunk.position)`.
|
|
||||||
6. **Chat flow + gate** (`app/api/chat.py`): pass the raw question into
|
|
||||||
`retrieve`; **LOW only when `best_cosine < threshold and fts_hits ==
|
|
||||||
0`** (`fts_hits` = count of lexical candidates matched); per-turn log
|
|
||||||
line gains `fts_hits=…` (PLAN §9); `query_log` row stores `fts_hits`.
|
|
||||||
7. **Eval script** `scripts/eval_retrieval.py`:
|
|
||||||
`uv run python -m scripts.eval_retrieval "q1" "q2" …` (or
|
|
||||||
`--from-file questions.txt`) — embeds via aipi, runs the hybrid
|
|
||||||
search, prints top-5 docs per question with cosine/fts/fused scores +
|
|
||||||
the gate verdict. Requires `AIPI_KEY` in the environment (same
|
|
||||||
convention as `llm_probe.py`).
|
|
||||||
8. **Re-import the live KB** (one-time; expect ~15–40 min of embedding
|
|
||||||
batches — the importer logs per file):
|
|
||||||
`uv run python -m scripts.import_docs --prune`. Expect ~470
|
|
||||||
hidden-dir docs pruned and ~500 docs indexed (md + new formats). Then
|
|
||||||
verify with the eval script:
|
|
||||||
- "How did I install gitlab?" → top doc `active/container_gitlab/
|
|
||||||
gitlab.md` (the compose yaml should land in the top-2).
|
|
||||||
- "How is my Kubernetes cluster set up?" → kubernetes docs.
|
|
||||||
- "sourdough starter" → LOW (deflect).
|
|
||||||
If the gitlab case isn't #1, iterate the **fusion** (k, candidate
|
|
||||||
counts, token handling) — not the threshold — until it is, and record
|
|
||||||
the final numbers in the phase report.
|
|
||||||
9. **E2E fixtures** (`tests/fixtures/docs/`): add
|
|
||||||
`homelab/container_gitlab/gitlab.md` (H1 "Gitlab", docker install
|
|
||||||
steps, "gitlab" repeated), `homelab/container_gitlab/
|
|
||||||
gitlab-compose.yaml` (`services: gitlab: …`), a `.py` note, a `.json`
|
|
||||||
note, a `.txt` note, and `.hidden/junk.md` (must never be imported).
|
|
||||||
Follow the existing in-process seeding pattern from
|
|
||||||
`tests/e2e/test_import_documents.py`.
|
|
||||||
10. **README**: import workflow section — supported formats, hidden-dir
|
|
||||||
skip, `scripts/eval_retrieval.py`, threshold tuning; note that the
|
|
||||||
Sources count drops after the prune (intended cleanup).
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- **Unit:** chunker per format (yaml top-level + `---` split, json
|
|
||||||
top-level keys + pretty-print + unparseable fallback, py ast split +
|
|
||||||
oversized-func fallback, txt paragraphs, dispatch, 1200-cap) with md
|
|
||||||
output unchanged; importer (hidden-dir skip, extension filter,
|
|
||||||
prune-when-filtered-out, per-format summary); retriever (RRF math:
|
|
||||||
both-lists / one-list / tie-break; OR tsquery construction incl.
|
|
||||||
no-token and stopword-only questions; gate: `cosine ≥ T` → HIGH;
|
|
||||||
`cosine < T` + `fts>0` → HIGH; `cosine < T` + `fts=0` → LOW; boundary
|
|
||||||
exactly `T` → HIGH).
|
|
||||||
- **Integration:** `/api/chat` hybrid against a seeded temp schema —
|
|
||||||
keyword question grounded + `fts_hits` in `query_log`; off-topic
|
|
||||||
deflected with `fts_hits=0`; migration up clean.
|
|
||||||
- **Coverage:** `uv run pytest --cov=app --cov-report=term-missing` —
|
|
||||||
**>90%** on `app/`.
|
|
||||||
- **No regressions:** existing story E2E suites (02–07) green in
|
|
||||||
isolation after the change (the conftest threshold override is what
|
|
||||||
keeps them green — verify each one).
|
|
||||||
|
|
||||||
## Playwright Execution Phase
|
|
||||||
Run ONLY this story's suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/e2e/test_retrieval_quality.py -v --no-cov
|
|
||||||
```
|
|
||||||
|
|
||||||
Implements the story mapping: multi-format fixture import (hidden doc
|
|
||||||
excluded, `/api/docs` counts); "How did I install gitlab?" → grounded,
|
|
||||||
not deflected, gitlab chip, `query_log` row; keyword-only question beats
|
|
||||||
vector ranking (FTS-OR gate end to end); "sourdough" → deflected bubble +
|
|
||||||
≥2 chips.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- [ ] live eval: "How did I install gitlab?" → `gitlab.md` is the top doc
|
|
||||||
- [ ] zero dot-prefixed path components in `documents` after re-import
|
|
||||||
- [ ] off-topic still deflects; on-topic still grounds (new + existing E2E)
|
|
||||||
- [ ] unit + integration green, coverage >90%, ruff + pyright green
|
|
||||||
- [ ] README documents formats / hidden-dir skip / eval / tuning
|
|
||||||
- [ ] committed
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
```bash
|
|
||||||
git add -A && git commit --no-gpg-sign -m "feat(rag): hybrid FTS+vector retrieval and multi-format ingestion — name-your-tool questions find the right document"
|
|
||||||
```
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Phase 11 — Long Answers (No Truncation)
|
|
||||||
|
|
||||||
**Story:** `.agent/user_stories/long-answers.md`
|
|
||||||
**Context:** owner report 2026-08-22 — "responses keep getting cut off.
|
|
||||||
It should be allowed to respond up to 32768 tokens."
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Remove the hard 700-token output cap on chat answers; the model may
|
|
||||||
respond up to **32 768** tokens (`BOR_MAX_OUTPUT_TOKENS`, default
|
|
||||||
32 768).
|
|
||||||
|
|
||||||
## Diagnosis
|
|
||||||
`app/rag/llm.py::chat_stream` calls `chat.completions.create(...,
|
|
||||||
max_tokens=700, ...)`. Long answers die mid-sentence at ~700 tokens.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
1. **Config** (`app/config.py`): `max_output_tokens: int = 32_768` in the
|
|
||||||
RAG-tuning section (env `BOR_MAX_OUTPUT_TOKENS`); document in
|
|
||||||
`.env.example`.
|
|
||||||
2. **LLM client** (`app/rag/llm.py`): `chat_stream` passes
|
|
||||||
`max_tokens=self.settings.max_output_tokens`.
|
|
||||||
3. **E2E mock** (`tests/e2e/mock_llm.py`):
|
|
||||||
- Honor `max_tokens` deterministically: token ≈ whitespace word; if
|
|
||||||
the composed answer is longer, truncate to the first N words.
|
|
||||||
(With the old 700 cap a long answer loses its tail — the mock now
|
|
||||||
behaves like the real endpoint.)
|
|
||||||
- New trigger: user message containing `write a long answer` →
|
|
||||||
deterministic ~4 000-word numbered answer ending in a unique final
|
|
||||||
line (`LONG-ANSWER-END`).
|
|
||||||
- No behavior change for existing (short) answers: they fit under any
|
|
||||||
sane cap.
|
|
||||||
4. **Tests:**
|
|
||||||
- Unit: settings default + env override (`test_config.py`);
|
|
||||||
`chat_stream` forwards the configured `max_tokens` (fake client in
|
|
||||||
`test_llm_client.py`).
|
|
||||||
- E2E: `tests/e2e/test_long_answers.py` per the story mapping.
|
|
||||||
|
|
||||||
## Locked decisions
|
|
||||||
None touched. A5 (aipi endpoint) unchanged; `turbo` accepts the larger
|
|
||||||
cap per owner instruction.
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- Unit + integration green; `uv run pytest --cov=app --cov-report=term-missing`
|
|
||||||
**>90%**; E2E in isolation:
|
|
||||||
`uv run pytest tests/e2e/test_long_answers.py -v --no-cov`.
|
|
||||||
- No regressions: `test_chat_rag.py` + `test_chat_api.py` green
|
|
||||||
(short answers unaffected by the mock's new `max_tokens` honoring).
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
```bash
|
|
||||||
git add -A .agent/ app/ tests/ .env.example && git commit --no-gpg-sign -m "fix(rag): lift chat output cap to 32768 tokens — long answers no longer cut off"
|
|
||||||
```
|
|
||||||
@@ -36,6 +36,14 @@ BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
|
|||||||
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py
|
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py
|
||||||
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
|
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
|
||||||
|
|
||||||
|
# --- Admin & sign-in (single-admin password login; BOTH required) ---
|
||||||
|
# The app refuses to start while either is empty (names the missing
|
||||||
|
# variable(s) — README "Admin & sign-in"). Generate the secret with:
|
||||||
|
# python -c 'import secrets;print(secrets.token_hex(32))'
|
||||||
|
BOR_ADMIN_PASSWORD=
|
||||||
|
BOR_SESSION_SECRET=
|
||||||
|
# BOR_SESSION_MAX_AGE=43200 # signed-cookie lifetime, seconds (default 12 h, sliding)
|
||||||
|
|
||||||
# --- Debugging (0/1 — 1 enables attach-on-demand debugpy on port 5678) ---
|
# --- Debugging (0/1 — 1 enables attach-on-demand debugpy on port 5678) ---
|
||||||
DEBUGPY=0
|
DEBUGPY=0
|
||||||
# DEBUGPY_PORT=5678
|
# DEBUGPY_PORT=5678
|
||||||
|
|||||||
@@ -83,10 +83,72 @@ uv run uvicorn app.main:app --reload
|
|||||||
is shown as escaped monospace text. Unknown documents get a designed
|
is shown as escaped monospace text. Unknown documents get a designed
|
||||||
not-found state with a link back to the index.
|
not-found state with a link back to the index.
|
||||||
- **Sources** (`/sources.html`) — the indexed document list; the *Path*
|
- **Sources** (`/sources.html`) — the indexed document list; the *Path*
|
||||||
column links each document to the viewer in a new tab.
|
column links each document to the viewer in a new tab. **Admin-only** —
|
||||||
|
anonymous visitors see a sign-in gate instead (the catalog is what the
|
||||||
|
login locks; the document viewer itself stays open to everyone).
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
### Setup (one-time)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c 'import secrets;print(secrets.token_hex(32))' # → paste into .env
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
BOR_ADMIN_PASSWORD=your-password # plaintext — homelab scope, by design
|
||||||
|
BOR_SESSION_SECRET=<the hex from above> # signs the session cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fail-loud:** while either variable is empty the app refuses to start,
|
||||||
|
naming the missing one(s):
|
||||||
|
|
||||||
|
```
|
||||||
|
RuntimeError: Brain of Reese cannot start: admin auth is not configured.
|
||||||
|
Set the missing variable(s): BOR_ADMIN_PASSWORD, BOR_SESSION_SECRET …
|
||||||
|
```
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
- `POST /api/login {"password": …}` → `204` + signed `bor_session` cookie
|
||||||
|
(Starlette `SessionMiddleware` — an itsdangerous-signed cookie, no
|
||||||
|
server-side store, no new service, no DB table); any mismatch → `401`
|
||||||
|
`{"detail": "invalid password"}` (constant-time compare, one generic
|
||||||
|
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.
|
||||||
|
- 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`
|
||||||
|
(default `43200` = 12 h, refreshed while active).
|
||||||
|
- Sign in from the chat header (**Sign in**) or `/login.html` directly;
|
||||||
|
the header then offers **Sign out** (logout + reload).
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
The public API endpoints stay stateless — the signed cookie is the only
|
||||||
|
session state in the system.
|
||||||
|
|
||||||
## Tuning your answers
|
## Tuning your answers
|
||||||
|
|
||||||
|
*Admin-only* — sign in first (see **Admin & sign-in** above); anonymous
|
||||||
|
visitors never see the Tune button or the Tuning panel.
|
||||||
|
|
||||||
If an answer isn't quite right — too chatty, wrong assumption, missing
|
If an answer isn't quite right — too chatty, wrong assumption, missing
|
||||||
context — **tune** Brain right there:
|
context — **tune** Brain right there:
|
||||||
|
|
||||||
@@ -300,6 +362,9 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
|||||||
| `BOR_MAX_CONTEXT_CHARS` | `24000` | cap on total document text sent to the LLM |
|
| `BOR_MAX_CONTEXT_CHARS` | `24000` | cap on total document text sent to the LLM |
|
||||||
| `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `<tuning>` (steering notes) prompt section |
|
| `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `<tuning>` (steering notes) prompt section |
|
||||||
| `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips |
|
| `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips |
|
||||||
|
| `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) |
|
||||||
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
|
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
|
||||||
| `BOR_LOG_LEVEL` | `INFO` | app log level |
|
| `BOR_LOG_LEVEL` | `INFO` | app log level |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Auth API — single-admin sign-in (phase 16; A10 revised 2026-08-22).
|
||||||
|
|
||||||
|
* ``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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", status_code=204)
|
||||||
|
def login(payload: LoginRequest, request: Request) -> Response:
|
||||||
|
"""Sign in the single admin.
|
||||||
|
|
||||||
|
Success: 204 + the signed ``bor_session`` cookie (``same_site=lax``,
|
||||||
|
12 h default lifetime). Failure: one generic 401 — the admin count is
|
||||||
|
one, so there is nothing else to leak, and a wrong password must not
|
||||||
|
set any session state.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
if check_password(payload.password, settings.admin_password):
|
||||||
|
sign_in(request.session)
|
||||||
|
return Response(status_code=204)
|
||||||
|
raise HTTPException(status_code=401, detail="invalid password")
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
sign_out(request.session)
|
||||||
|
response.delete_cookie(get_settings().session_cookie, path="/")
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@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",
|
||||||
|
)
|
||||||
+15
-3
@@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.auth import require_admin
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Chunk, Document
|
from app.models import Chunk, Document
|
||||||
from app.schemas import DocContent, DocList, DocSummary
|
from app.schemas import DocContent, DocList, DocSummary
|
||||||
@@ -28,11 +29,17 @@ def doc_format(path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/docs", response_model=DocList)
|
@router.get("/docs", response_model=DocList)
|
||||||
def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008
|
def list_documents(
|
||||||
|
db: Session = Depends(get_db), # noqa: B008
|
||||||
|
_admin: None = Depends(require_admin), # noqa: B008
|
||||||
|
) -> DocList:
|
||||||
"""All indexed documents with per-document chunk counts.
|
"""All indexed documents with per-document chunk counts.
|
||||||
|
|
||||||
An empty list means the knowledge base has not been imported yet —
|
Admin-only (phase 16 — the catalog is what the sign-in gates; the
|
||||||
the Sources page renders its designed empty state in that case.
|
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.
|
||||||
"""
|
"""
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(
|
select(
|
||||||
@@ -71,6 +78,11 @@ def get_document_content(
|
|||||||
Stateless (A10) and database-only: unknown pairs — including traversal
|
Stateless (A10) and database-only: unknown pairs — including traversal
|
||||||
strings such as ``../../etc/passwd`` — are just non-existent rows and
|
strings such as ``../../etc/passwd`` — are just non-existent rows and
|
||||||
map to 404 ``{detail: "document not found"}``.
|
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.
|
||||||
"""
|
"""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
select(Document, func.count(Chunk.id).label("chunks"))
|
select(Document, func.count(Chunk.id).label("chunks"))
|
||||||
|
|||||||
+14
-6
@@ -1,11 +1,14 @@
|
|||||||
"""Steering notes API — tune how Brain answers (phase 15, story
|
"""Steering notes API — tune how Brain answers (phase 15, story
|
||||||
``steering-notes``).
|
``steering-notes``).
|
||||||
|
|
||||||
Stateless CRUD under ``/api/steering`` (A10): notes are owner instructions
|
Admin-only CRUD under ``/api/steering`` (phase 16, A10 revised): notes
|
||||||
stored in Postgres (``steering_notes``) and read into the system prompt of
|
are owner instructions stored in Postgres (``steering_notes``) and read
|
||||||
**every** chat turn as the ``<tuning>`` section (see
|
into the system prompt of **every** chat turn as the ``<tuning>`` section
|
||||||
:func:`app.rag.prompts.build_steering_section` and
|
(see :func:`app.rag.prompts.build_steering_section` and
|
||||||
:func:`app.api.chat.chat`).
|
:func:`app.api.chat.chat`). 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).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,12 +18,17 @@ from fastapi import APIRouter, Depends, HTTPException, Response
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.auth import require_admin
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import SteeringNote
|
from app.models import SteeringNote
|
||||||
from app.schemas import SteeringNote as SteeringNoteOut
|
from app.schemas import SteeringNote as SteeringNoteOut
|
||||||
from app.schemas import SteeringNoteIn, SteeringNoteList
|
from app.schemas import SteeringNoteIn, SteeringNoteList
|
||||||
|
|
||||||
router = APIRouter(prefix="/steering", tags=["steering"])
|
router = APIRouter(
|
||||||
|
prefix="/steering",
|
||||||
|
tags=["steering"],
|
||||||
|
dependencies=[Depends(require_admin)], # phase 16: tuning is admin-only
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_steering_notes(db: Session) -> list[str]:
|
def load_steering_notes(db: Session) -> list[str]:
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ class Settings(BaseSettings):
|
|||||||
hybrid_lexical_candidates: int = 30
|
hybrid_lexical_candidates: int = 30
|
||||||
rrf_k: int = 60
|
rrf_k: int = 60
|
||||||
|
|
||||||
|
# --- Admin & sign-in (phase 16; A10 revised 2026-08-22) ---
|
||||||
|
# Single-admin auth via a signed session cookie (Starlette
|
||||||
|
# SessionMiddleware — no new services, no DB tables). Both secrets are
|
||||||
|
# REQUIRED at startup: ``create_app()`` refuses to boot when either is
|
||||||
|
# empty (``app.core.auth.ensure_admin_configured``). The password is
|
||||||
|
# plaintext on purpose (homelab scope, owner decision 2026-08-22);
|
||||||
|
# the session secret signs the cookie (``secrets.token_hex(32)``).
|
||||||
|
admin_password: str = ""
|
||||||
|
session_secret: str = ""
|
||||||
|
#: Signed-cookie lifetime in seconds (default 12 h, refreshed on
|
||||||
|
#: session writes — sliding for an active admin).
|
||||||
|
session_max_age: int = 43_200
|
||||||
|
session_cookie: str = "bor_session"
|
||||||
|
|
||||||
# --- Import scope (A9, revised 2026-08-21) ---
|
# --- Import scope (A9, revised 2026-08-21) ---
|
||||||
# Comma-separated list of lowercased file extensions (no dot) imported
|
# Comma-separated list of lowercased file extensions (no dot) imported
|
||||||
# by ``scripts/import_docs.py``. Hidden (dot) path components are always
|
# by ``scripts/import_docs.py``. Hidden (dot) path components are always
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Single-admin authentication (phase 16; LOCKED A10 revised 2026-08-22).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Contract:
|
||||||
|
* ``ensure_admin_configured`` — fail-loud startup gate: the app must name
|
||||||
|
the missing ``BOR_`` variable(s) instead of serving anything.
|
||||||
|
* ``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).
|
||||||
|
* ``sign_in`` / ``sign_out`` — session-dict helpers for the API routes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from collections.abc import MutableMapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
#: The session key the single admin is stored under.
|
||||||
|
ADMIN_SESSION_KEY = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_admin_configured(settings: Settings) -> None:
|
||||||
|
"""Refuse to boot without admin auth (fail-loud, A6 spirit).
|
||||||
|
|
||||||
|
Raises :class:`RuntimeError` naming every missing ``BOR_`` variable so
|
||||||
|
the startup log tells the operator exactly what to set.
|
||||||
|
"""
|
||||||
|
missing = [
|
||||||
|
env_name
|
||||||
|
for env_name, value in (
|
||||||
|
("BOR_ADMIN_PASSWORD", settings.admin_password),
|
||||||
|
("BOR_SESSION_SECRET", settings.session_secret),
|
||||||
|
)
|
||||||
|
if not value.strip()
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Brain of Reese cannot start: admin auth is not configured. "
|
||||||
|
f"Set the missing variable(s): {', '.join(missing)} "
|
||||||
|
"(see .env.example and the README 'Admin & sign-in' section)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_password(candidate: str, expected: str) -> bool:
|
||||||
|
"""Constant-time password check (``secrets.compare_digest``).
|
||||||
|
|
||||||
|
One admin → one generic 401 on any mismatch: the response never reveals
|
||||||
|
whether the password was *close*, empty, or not (no user enumeration).
|
||||||
|
"""
|
||||||
|
return secrets.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(request: Request) -> None:
|
||||||
|
"""FastAPI dependency: allow the signed-in admin, else 403.
|
||||||
|
|
||||||
|
Reads the cookie-backed session installed by ``SessionMiddleware``;
|
||||||
|
a request without a valid admin session gets 403 ``admin only``.
|
||||||
|
"""
|
||||||
|
if not request.session.get(ADMIN_SESSION_KEY):
|
||||||
|
raise HTTPException(status_code=403, detail="admin only")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_in(session: MutableMapping[str, Any]) -> None:
|
||||||
|
"""Mark the (cookie-backed) session as the single admin.
|
||||||
|
|
||||||
|
Writing the key marks the session modified, so the middleware emits
|
||||||
|
the signed ``bor_session`` cookie with the configured Max-Age.
|
||||||
|
"""
|
||||||
|
session[ADMIN_SESSION_KEY] = True
|
||||||
|
|
||||||
|
|
||||||
|
def sign_out(session: MutableMapping[str, Any]) -> None:
|
||||||
|
"""Clear the session state (the route additionally expires the cookie).
|
||||||
|
|
||||||
|
An emptied dict is not re-persisted by the middleware, so the API
|
||||||
|
route pairs this with ``response.delete_cookie`` to make the browser
|
||||||
|
drop the signed cookie immediately.
|
||||||
|
"""
|
||||||
|
session.clear()
|
||||||
+26
@@ -4,6 +4,11 @@ Boots logging + conditional debugpy, then creates the FastAPI app:
|
|||||||
API routes first (so they win over the catch-all), and the static frontend
|
API routes first (so they win over the catch-all), and the static frontend
|
||||||
mounted last. No CDN: everything the browser needs is served by this
|
mounted last. No CDN: everything the browser needs is served by this
|
||||||
process from local files (see PLAN §UI/UX — No External Dependencies).
|
process from local files (see PLAN §UI/UX — No External Dependencies).
|
||||||
|
|
||||||
|
Phase 16 (A10 revised): before anything is served, admin auth must be
|
||||||
|
configured (fail-loud), and the app wraps every route in Starlette's
|
||||||
|
SessionMiddleware — a signed ``bor_session`` cookie is the only session
|
||||||
|
state in the system.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,13 +17,16 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from app.api.auth import router as auth_router
|
||||||
from app.api.chat import router as chat_router
|
from app.api.chat import router as chat_router
|
||||||
from app.api.docs import router as docs_router
|
from app.api.docs import router as docs_router
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.steering import router as steering_router
|
from app.api.steering import router as steering_router
|
||||||
from app.api.suggestions import router as suggestions_router
|
from app.api.suggestions import router as suggestions_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.core.auth import ensure_admin_configured
|
||||||
from app.core.debugging import configure_debugging
|
from app.core.debugging import configure_debugging
|
||||||
from app.core.logging import configure_logging
|
from app.core.logging import configure_logging
|
||||||
|
|
||||||
@@ -30,10 +38,28 @@ logger = logging.getLogger("app")
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
# Fail loud BEFORE serving anything (phase 16): missing
|
||||||
|
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
|
||||||
|
# variable(s) — the app never starts in a half-authenticated state.
|
||||||
|
ensure_admin_configured(settings)
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_name, version=settings.app_version)
|
app = FastAPI(title=settings.app_name, version=settings.app_version)
|
||||||
|
|
||||||
|
# Signed single-admin session cookie (Starlette middleware, itsdangerous
|
||||||
|
# signer — no server-side store, no new services). Homelab HTTP: same_site
|
||||||
|
# is "lax" and https_only stays off (documented in the README).
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=settings.session_secret,
|
||||||
|
session_cookie=settings.session_cookie,
|
||||||
|
max_age=settings.session_max_age,
|
||||||
|
same_site="lax",
|
||||||
|
https_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
# API routes first so they take precedence over the catch-all static mount.
|
# API routes first so they take precedence over the catch-all static mount.
|
||||||
app.include_router(health_router, prefix="/api")
|
app.include_router(health_router, prefix="/api")
|
||||||
|
app.include_router(auth_router, prefix="/api")
|
||||||
app.include_router(suggestions_router, prefix="/api")
|
app.include_router(suggestions_router, prefix="/api")
|
||||||
app.include_router(docs_router, prefix="/api")
|
app.include_router(docs_router, prefix="/api")
|
||||||
app.include_router(chat_router, prefix="/api")
|
app.include_router(chat_router, prefix="/api")
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ class ChatRequest(BaseModel):
|
|||||||
message: str = Field(min_length=1, max_length=4000)
|
message: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
"""``POST /api/login`` body (phase 16): the single admin's password.
|
||||||
|
|
||||||
|
An empty or wrong password is a 401 with one generic detail — never a
|
||||||
|
422 that would hint at input-shape differences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
password: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class WhoamiResponse(BaseModel):
|
||||||
|
"""``GET /api/whoami`` (phase 16) — drives all UI gating."""
|
||||||
|
|
||||||
|
authenticated: bool
|
||||||
|
role: str # "admin" | "anonymous"
|
||||||
|
|
||||||
|
|
||||||
class SourceRef(BaseModel):
|
class SourceRef(BaseModel):
|
||||||
source: str
|
source: str
|
||||||
path: str
|
path: str
|
||||||
|
|||||||
+58
-5
@@ -126,8 +126,11 @@ function announceSteering(message) {
|
|||||||
|
|
||||||
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
|
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
|
||||||
sources' .msg-meta row when it exists (role=list → the button joins as
|
sources' .msg-meta row when it exists (role=list → the button joins as
|
||||||
a listitem so ARIA stays valid); otherwise creates a plain meta row. */
|
a listitem so ARIA stays valid); otherwise creates a plain meta row.
|
||||||
|
Phase 16: anonymous visitors never get the button — this single guard
|
||||||
|
covers both fresh turns and the phase-14 restore path. */
|
||||||
function appendTuneButton(wrap) {
|
function appendTuneButton(wrap) {
|
||||||
|
if (!isAdmin) return; // phase 16: tuning is admin-only
|
||||||
const body = wrap.querySelector(".msg-body");
|
const body = wrap.querySelector(".msg-body");
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
let meta = body.querySelector(".msg-meta");
|
let meta = body.querySelector(".msg-meta");
|
||||||
@@ -656,6 +659,50 @@ function rememberBrainTurn(rawText, meta) {
|
|||||||
* empty state (suggestions included). Ignored while a turn is in flight —
|
* empty state (suggestions included). Ignored while a turn is in flight —
|
||||||
* a live stream must not be hijacked. Confirmation reuses the existing
|
* a live stream must not be hijacked. Confirmation reuses the existing
|
||||||
* #send-status live region (aria-live=polite). */
|
* #send-status live region (aria-live=polite). */
|
||||||
|
/* ---------- single-admin auth (phase 16, A10 revised) ----------
|
||||||
|
*
|
||||||
|
* /api/whoami decides the header: anonymous → the Sign in link and NO
|
||||||
|
* tuning surface at all — the Tuning toggle + panel are removed from the
|
||||||
|
* DOM (the story says "absent", not just hidden), /api/steering is never
|
||||||
|
* fetched, and appendTuneButton injects nothing (new or restored
|
||||||
|
* messages). Admin → Sign out (POST /api/logout + reload) + the full
|
||||||
|
* phase-15 UI. Whoami is awaited BEFORE the phase-14 restore, so restored
|
||||||
|
* brain bubbles never flash a Tune button that should not be there.
|
||||||
|
*/
|
||||||
|
const signInLink = document.querySelector("#sign-in-link");
|
||||||
|
const signOutBtn = document.querySelector("#sign-out-btn");
|
||||||
|
let isAdmin = false;
|
||||||
|
|
||||||
|
function applyAuthState() {
|
||||||
|
if (signInLink) signInLink.hidden = isAdmin;
|
||||||
|
if (signOutBtn) signOutBtn.hidden = !isAdmin;
|
||||||
|
if (!isAdmin && steeringToggle) {
|
||||||
|
steeringToggle.remove();
|
||||||
|
steeringPanel?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuthState() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/whoami");
|
||||||
|
if (r.ok) isAdmin = (await r.json()).authenticated === true;
|
||||||
|
} catch {
|
||||||
|
isAdmin = false; // API unreachable: anonymous-safe defaults
|
||||||
|
}
|
||||||
|
applyAuthState();
|
||||||
|
return isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signOutBtn) {
|
||||||
|
signOutBtn.addEventListener("click", async () => {
|
||||||
|
signOutBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await fetch("/api/logout", { method: "POST" });
|
||||||
|
} catch { /* the reload resets the UI either way */ }
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||||
function startNewChat() {
|
function startNewChat() {
|
||||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||||
@@ -799,7 +846,13 @@ input.addEventListener("keydown", (e) => {
|
|||||||
});
|
});
|
||||||
composer.addEventListener("submit", handleSend);
|
composer.addEventListener("submit", handleSend);
|
||||||
|
|
||||||
restoreConversation(); // phase 14: the conversation comes back as left
|
/* Boot: auth state FIRST — it decides whether the restored conversation
|
||||||
loadSuggestions();
|
gets Tune buttons and whether the steering UI exists at all (phase 16).
|
||||||
loadHealth();
|
Phase 14: the conversation then comes back exactly as left. */
|
||||||
loadSteering(); // phase 15: tuning notes (panel + count badge)
|
(async () => {
|
||||||
|
await loadAuthState();
|
||||||
|
restoreConversation();
|
||||||
|
loadSuggestions();
|
||||||
|
loadHealth();
|
||||||
|
if (isAdmin) loadSteering(); // phase 15: panel + count badge (admin only)
|
||||||
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* Brain of Reese — admin sign-in (phase 16, A10 revised).
|
||||||
|
*
|
||||||
|
* One admin, one password. On submit → POST /api/login: 204 sets the
|
||||||
|
* signed session cookie and we redirect to `?next` (same-origin relative
|
||||||
|
* URLs only — "/…" but never "//host" or an absolute URL; default
|
||||||
|
* /sources.html). A 401 keeps the form and announces through the
|
||||||
|
* role=alert error region. On load, /api/whoami already says admin →
|
||||||
|
* straight to `next`, no form.
|
||||||
|
*
|
||||||
|
* No CDN, no state in this file: the signed cookie is the whole session.
|
||||||
|
* All DOM ids match frontend/login.html.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const form = document.querySelector("#login-form");
|
||||||
|
const passwordInput = document.querySelector("#login-password");
|
||||||
|
const submitBtn = document.querySelector("#login-submit");
|
||||||
|
const errorEl = document.querySelector("#login-error");
|
||||||
|
|
||||||
|
const DEFAULT_NEXT = "/sources.html";
|
||||||
|
|
||||||
|
/* Same-origin relative URLs only: honor `?next=/…`, reject anything that
|
||||||
|
would leave the origin (protocol-relative "//…" or absolute). */
|
||||||
|
function safeNext() {
|
||||||
|
const next = new URLSearchParams(window.location.search).get("next") || DEFAULT_NEXT;
|
||||||
|
return next.startsWith("/") && !next.startsWith("//") ? next : DEFAULT_NEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.hidden = false;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
passwordInput.focus();
|
||||||
|
passwordInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function alreadySignedIn() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/whoami");
|
||||||
|
if (!r.ok) return false;
|
||||||
|
return (await r.json()).authenticated === true;
|
||||||
|
} catch {
|
||||||
|
return false; // API unreachable: stay on the form — submit will explain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorEl.hidden = true;
|
||||||
|
errorEl.textContent = "";
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password: passwordInput.value }),
|
||||||
|
});
|
||||||
|
if (r.status === 204) {
|
||||||
|
// Session cookie set — off to the requested page.
|
||||||
|
window.location.replace(safeNext());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// One generic failure (401); anything else is a server-side surprise.
|
||||||
|
const detail =
|
||||||
|
r.status === 401
|
||||||
|
? "Invalid password — try again."
|
||||||
|
: `Sign-in failed (HTTP ${r.status}) — try again.`;
|
||||||
|
showError(detail);
|
||||||
|
} catch {
|
||||||
|
showError("Could not reach the server — try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Already the admin? Skip the form and go straight to the target. */
|
||||||
|
(async () => {
|
||||||
|
if (await alreadySignedIn()) {
|
||||||
|
window.location.replace(safeNext());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
passwordInput.focus();
|
||||||
|
})();
|
||||||
@@ -9,10 +9,24 @@
|
|||||||
const tbody = document.querySelector("#docs-tbody");
|
const tbody = document.querySelector("#docs-tbody");
|
||||||
const emptyEl = document.querySelector("#sources-empty");
|
const emptyEl = document.querySelector("#sources-empty");
|
||||||
const tableWrap = document.querySelector(".table-wrap");
|
const tableWrap = document.querySelector(".table-wrap");
|
||||||
|
const statCards = document.querySelector("#stat-cards");
|
||||||
|
const gateEl = document.querySelector("#sources-gate");
|
||||||
const statDocs = document.querySelector("#stat-docs");
|
const statDocs = document.querySelector("#stat-docs");
|
||||||
const statChunks = document.querySelector("#stat-chunks");
|
const statChunks = document.querySelector("#stat-chunks");
|
||||||
const statLast = document.querySelector("#stat-last");
|
const statLast = document.querySelector("#stat-last");
|
||||||
|
|
||||||
|
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
|
||||||
|
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
|
||||||
|
* catalog is admin-only. The document viewer itself stays public (the
|
||||||
|
* soft rule), so the gate copy points at what keeps working. */
|
||||||
|
async function isAdmin() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/whoami");
|
||||||
|
if (r.ok) return (await r.json()).authenticated === true;
|
||||||
|
} catch { /* API unreachable: anonymous-safe gate */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso) {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString();
|
return new Date(iso).toLocaleString();
|
||||||
@@ -97,4 +111,15 @@ function showEmpty() {
|
|||||||
if (tableWrap) tableWrap.hidden = true;
|
if (tableWrap) tableWrap.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDocs();
|
(async () => {
|
||||||
|
if (!(await isAdmin())) {
|
||||||
|
// Anonymous: gate in, catalog out, and no /api/docs request at all.
|
||||||
|
if (statCards) statCards.hidden = true;
|
||||||
|
if (tableWrap) tableWrap.hidden = true;
|
||||||
|
if (emptyEl) emptyEl.hidden = true;
|
||||||
|
if (gateEl) gateEl.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gateEl) gateEl.hidden = true;
|
||||||
|
loadDocs();
|
||||||
|
})();
|
||||||
|
|||||||
@@ -241,6 +241,33 @@ body::after {
|
|||||||
whole control below 640px. */
|
whole control below 640px. */
|
||||||
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||||
|
|
||||||
|
/* Phase 16: header auth controls (Sign in link / Sign out button) — the
|
||||||
|
same ghost pill as New chat, so the bar keeps one visual language.
|
||||||
|
ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1.
|
||||||
|
Icon-only below 640px (aria-labels/labels keep the accessible names);
|
||||||
|
≥44px touch target at every width. Exactly one is ever visible. */
|
||||||
|
.auth-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.auth-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||||
|
.auth-link:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
.auth-link svg { width: 16px; height: 16px; display: none; }
|
||||||
|
|
||||||
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
|
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
|
||||||
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
|
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
|
||||||
(not removed) below 640px so the accessible name keeps the word.
|
(not removed) below 640px so the accessible name keeps the word.
|
||||||
@@ -724,6 +751,65 @@ body::after {
|
|||||||
.kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
.kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||||
.kb-banner svg { width: 18px; height: 18px; flex: 0 0 auto; display: block; }
|
.kb-banner svg { width: 18px; height: 18px; flex: 0 0 auto; display: block; }
|
||||||
|
|
||||||
|
/* ---------- Login page (phase 16) ---------- */
|
||||||
|
/* Centered card in the standard frame: one admin, one password. */
|
||||||
|
.login-shell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 26rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 2rem 2rem 2.25rem;
|
||||||
|
}
|
||||||
|
.login-card h1 { margin: 0 0 0.4rem; font-size: 1.6rem; }
|
||||||
|
.login-sub { margin: 0 0 1.5rem; color: var(--ink-soft); }
|
||||||
|
#login-form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
#login-password {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #0d1120;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
#login-password:focus-visible { border-color: var(--brand); }
|
||||||
|
/* Brand button: dark ink on brand 5.2:1 (never white on brand). */
|
||||||
|
.login-submit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--bg);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
.login-submit:hover:not(:disabled) { background: #7d88f5; }
|
||||||
|
.login-submit:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
/* Login failure (role=alert): err pair ≈9.1:1. */
|
||||||
|
.login-error {
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
background: var(--err-bg);
|
||||||
|
color: var(--err-ink);
|
||||||
|
border: 1px solid var(--err-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Sources page ---------- */
|
/* ---------- Sources page ---------- */
|
||||||
.sources-shell {
|
.sources-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -755,6 +841,39 @@ body::after {
|
|||||||
.stat-value-sm { font-size: 1.15rem; font-weight: 700; }
|
.stat-value-sm { font-size: 1.15rem; font-weight: 700; }
|
||||||
.stat-label { color: var(--ink-soft); font-size: 0.88rem; font-weight: 600; }
|
.stat-label { color: var(--ink-soft); font-size: 0.88rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Phase 16: anonymous sign-in gate — the designed replacement for the
|
||||||
|
catalog (stat cards + table) until the admin signs in. */
|
||||||
|
.sources-gate {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 2.5rem 1.75rem;
|
||||||
|
}
|
||||||
|
.sources-gate-glyph { color: var(--brand-ink); width: 44px; height: 44px; }
|
||||||
|
.sources-gate-glyph svg { width: 44px; height: 44px; display: block; }
|
||||||
|
.sources-gate h2 { margin: 0.6rem 0 0.3rem; font-size: 1.4rem; }
|
||||||
|
.sources-gate-sub { margin: 0 auto; max-width: 30rem; color: var(--ink-soft); }
|
||||||
|
.sources-gate-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem 1.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.sources-gate-link:hover { background: #7d88f5; }
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -1005,6 +1124,11 @@ body::after {
|
|||||||
.new-chat-btn { padding: 0.4rem 0.55rem; }
|
.new-chat-btn { padding: 0.4rem 0.55rem; }
|
||||||
.new-chat-label { display: none; }
|
.new-chat-label { display: none; }
|
||||||
.new-chat-btn svg { display: block; }
|
.new-chat-btn svg { display: block; }
|
||||||
|
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||||
|
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||||
|
.auth-link { padding: 0.4rem 0.55rem; }
|
||||||
|
.auth-label { display: none; }
|
||||||
|
.auth-link svg { display: block; }
|
||||||
.steering-toggle { padding: 0.4rem 0.55rem; }
|
.steering-toggle { padding: 0.4rem 0.55rem; }
|
||||||
/* Visually hidden, NOT display:none — the accessible name keeps the
|
/* Visually hidden, NOT display:none — the accessible name keeps the
|
||||||
word "Tuning" next to the count badge. */
|
word "Tuning" next to the count badge. */
|
||||||
|
|||||||
@@ -34,6 +34,17 @@
|
|||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
<span class="new-chat-label">New chat</span>
|
<span class="new-chat-label">New chat</span>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out
|
||||||
|
is visible; /api/whoami decides at load (app.js). Icon-only
|
||||||
|
below 640px (aria-labels keep the accessible names). -->
|
||||||
|
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign in</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="description" content="Admin sign-in for Brain of Reese — unlocks the full Sources catalog and answer tuning.">
|
||||||
|
<title>Sign in · Brain of Reese</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
|
|
||||||
|
<!-- Standard app frame + sticky header (phase 12 consistency). -->
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="container header-inner">
|
||||||
|
<span class="brand">
|
||||||
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
|
</span>
|
||||||
|
<nav class="app-nav" aria-label="Primary">
|
||||||
|
<a href="/" class="nav-link">Chat</a>
|
||||||
|
<a href="/sources.html" class="nav-link">Sources</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main" class="app-main" tabindex="-1">
|
||||||
|
<div class="container login-shell">
|
||||||
|
<!-- Centered sign-in card (phase 16): one admin, one password. -->
|
||||||
|
<section class="login-card" aria-labelledby="login-title">
|
||||||
|
<h1 id="login-title">Sign in</h1>
|
||||||
|
<p class="login-sub">
|
||||||
|
One admin account, one password. Signing in unlocks the full
|
||||||
|
Sources catalog and the answer-tuning controls — chat stays open
|
||||||
|
to everyone either way.
|
||||||
|
</p>
|
||||||
|
<form id="login-form">
|
||||||
|
<label class="visually-hidden" for="login-password">Admin password</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<button type="submit" class="login-submit" id="login-submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
<p id="login-error" class="login-error" role="alert" hidden></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="container footer-inner">
|
||||||
|
<span>Powered by Reese's self-hosted models</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="module" src="/assets/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -34,6 +34,21 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
|
||||||
|
login locks — the document viewer itself stays public (soft
|
||||||
|
rule), so the copy says what stays open. -->
|
||||||
|
<section class="sources-gate" id="sources-gate" aria-labelledby="sources-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="sources-gate-title">Sign in to view the full catalog</h2>
|
||||||
|
<p class="sources-gate-sub">
|
||||||
|
The complete list of indexed documents is admin-only. Chat — and
|
||||||
|
any document an answer cites — stays open to everyone.
|
||||||
|
</p>
|
||||||
|
<a class="sources-gate-link" href="/login.html?next=/sources.html">Sign in</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="stat-cards" id="stat-cards">
|
<div class="stat-cards" id="stat-cards">
|
||||||
<div class="stat-card" role="group" aria-label="Document statistics">
|
<div class="stat-card" role="group" aria-label="Document statistics">
|
||||||
<span class="stat-value" id="stat-docs">–</span>
|
<span class="stat-value" id="stat-docs">–</span>
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ dependencies = [
|
|||||||
# --- LLM client (OpenAI-compatible, self-hosted "aipi") ---
|
# --- LLM client (OpenAI-compatible, self-hosted "aipi") ---
|
||||||
"httpx>=0.27,<1.0",
|
"httpx>=0.27,<1.0",
|
||||||
"openai>=1.40,<3.0",
|
"openai>=1.40,<3.0",
|
||||||
|
# Phase 16: starlette's SessionMiddleware signs the session cookie with
|
||||||
|
# itsdangerous — an OPTIONAL starlette extra ("full") since starlette 1.x,
|
||||||
|
# so the app declares it directly (narrower than starlette[full]).
|
||||||
|
"itsdangerous>=2.2,<3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ from sqlalchemy.orm import Session
|
|||||||
# The production default stays 0.62 (app/config.py, A8 revised).
|
# The production default stays 0.62 (app/config.py, A8 revised).
|
||||||
os.environ.setdefault("BOR_RELEVANCE_THRESHOLD", "0.30")
|
os.environ.setdefault("BOR_RELEVANCE_THRESHOLD", "0.30")
|
||||||
|
|
||||||
|
# Phase 16: single-admin auth is fail-loud — create_app() refuses to boot
|
||||||
|
# without both vars, and app.main (imported below) builds the app at
|
||||||
|
# import time. Set known test values first, same pattern as the threshold.
|
||||||
|
ADMIN_PASSWORD = "test-admin-password"
|
||||||
|
SESSION_SECRET = "test-session-secret-0123456789abcdef0123456789abcdef"
|
||||||
|
os.environ.setdefault("BOR_ADMIN_PASSWORD", ADMIN_PASSWORD)
|
||||||
|
os.environ.setdefault("BOR_SESSION_SECRET", SESSION_SECRET)
|
||||||
|
|
||||||
from app.db import SessionLocal, db_available # noqa: E402
|
from app.db import SessionLocal, db_available # noqa: E402
|
||||||
from app.main import app as fastapi_app # noqa: E402
|
from app.main import app as fastapi_app # noqa: E402
|
||||||
|
|
||||||
@@ -24,6 +32,19 @@ def client() -> TestClient:
|
|||||||
return TestClient(fastapi_app)
|
return TestClient(fastapi_app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin_client(client: TestClient) -> TestClient:
|
||||||
|
"""A client signed in as the single admin (phase 16).
|
||||||
|
|
||||||
|
TestClient keeps its cookie jar across requests, so one login covers
|
||||||
|
every subsequent request of the test. Use it for the admin-only
|
||||||
|
surface (``GET /api/docs``, ``/api/steering``).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def db() -> Iterator[Session]:
|
def db() -> Iterator[Session]:
|
||||||
"""Real Postgres session (``podman compose up -d db``).
|
"""Real Postgres session (``podman compose up -d db``).
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Shared Playwright auth helper (phase 16).
|
||||||
|
|
||||||
|
``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).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
from e2e.conftest import ADMIN_PASSWORD # noqa: F401 (re-exported for tests)
|
||||||
|
|
||||||
|
DEFAULT_NEXT = "/sources.html"
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
* correct password (or ``password=None`` → the shared admin password)
|
||||||
|
→ redirects to ``next`` (default ``/sources.html``);
|
||||||
|
* wrong password → ``#login-error`` (role=alert) is visible, the URL
|
||||||
|
never changes, and the visitor is still anonymous.
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
page.fill("#login-password", attempt)
|
||||||
|
page.click("#login-form button[type=submit]")
|
||||||
|
if attempt != ADMIN_PASSWORD:
|
||||||
|
expect(page.locator("#login-error")).to_be_visible(timeout=15_000)
|
||||||
|
expect(page).to_have_url(url) # no redirect on failure
|
||||||
|
return
|
||||||
|
expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
|
||||||
@@ -31,6 +31,13 @@ MOCK_PORT = int(os.environ.get("E2E_MOCK_PORT", "8901"))
|
|||||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||||
USE_REAL_LLM = os.environ.get("E2E_REAL_LLM") == "1"
|
USE_REAL_LLM = os.environ.get("E2E_REAL_LLM") == "1"
|
||||||
|
|
||||||
|
# Phase 16: the app under test boots with single-admin auth configured
|
||||||
|
# (fail-loud otherwise). Known E2E values — the shared form-login helper
|
||||||
|
# (tests/e2e/auth_helpers.py) uses ADMIN_PASSWORD; the secret is fixed so
|
||||||
|
# session cookies stay valid across a session-scoped app restart.
|
||||||
|
ADMIN_PASSWORD = "e2e-admin-password"
|
||||||
|
SESSION_SECRET = "e2e-session-secret-0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
|
||||||
def _wait_http(url: str, timeout: float = 40.0) -> None:
|
def _wait_http(url: str, timeout: float = 40.0) -> None:
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
@@ -89,6 +96,9 @@ def app_server(mock_llm: int) -> Iterator[str]:
|
|||||||
# `embed` model's 0.41–0.84 cosine range, PLAN A8).
|
# `embed` model's 0.41–0.84 cosine range, PLAN A8).
|
||||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||||
env.setdefault("BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese")
|
env.setdefault("BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese")
|
||||||
|
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||||
|
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||||
|
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Phase 16 E2E (Playwright): single-admin sign-in (A10 revised).
|
||||||
|
|
||||||
|
Story: ``.agent/user_stories/admin-auth.md``
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_admin_auth.py -v --no-cov
|
||||||
|
|
||||||
|
The E2E app server boots with ``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``
|
||||||
|
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``
|
||||||
|
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``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from playwright.sync_api import 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 ADMIN_PASSWORD, login
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
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 owns the test loop)."""
|
||||||
|
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), optionally re-seed."""
|
||||||
|
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 _ask(page: Page, question: str) -> None:
|
||||||
|
"""Send one turn and wait until the grounded answer has fully landed."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Anonymous: chat works, 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)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
page.goto(app_url)
|
||||||
|
|
||||||
|
# Header: Sign in offered, Sign out not.
|
||||||
|
expect(page.locator("#sign-in-link")).to_be_visible()
|
||||||
|
expect(page.locator("#sign-in-link")).to_have_attribute(
|
||||||
|
"href", "/login.html?next=/sources.html"
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# …but 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.
|
||||||
|
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)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_sources_gated_viewer_open(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
|
||||||
|
api_docs_calls: list[str] = []
|
||||||
|
page.on(
|
||||||
|
"request",
|
||||||
|
lambda r: api_docs_calls.append(r.url) if "/api/docs" in r.url else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
page.goto(f"{app_url}/sources.html")
|
||||||
|
# The gate, with its sign-in link (≥44px) — not a redirect.
|
||||||
|
gate = page.locator("#sources-gate")
|
||||||
|
expect(gate).to_be_visible()
|
||||||
|
expect(gate).to_contain_text("Sign in to view the full catalog")
|
||||||
|
link = gate.locator("a[href='/login.html?next=/sources.html']")
|
||||||
|
expect(link).to_have_count(1)
|
||||||
|
box = link.bounding_box()
|
||||||
|
assert box is not None and box["height"] >= 44
|
||||||
|
|
||||||
|
# Stat cards + table hidden…
|
||||||
|
expect(page.locator("#stat-cards")).to_be_hidden()
|
||||||
|
expect(page.locator("#docs-table")).to_be_hidden()
|
||||||
|
expect(page.locator("#sources-empty")).to_be_hidden()
|
||||||
|
# …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.
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Wrong password → role=alert error, no redirect, still anonymous
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password_shows_error(page: Page, app_url: str, db_ready: None) -> None:
|
||||||
|
_reset_db(mock_port=0, seed=False)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
|
||||||
|
login(page, app_url, password="definitely-not-the-password")
|
||||||
|
|
||||||
|
error = page.locator("#login-error")
|
||||||
|
expect(error).to_be_visible()
|
||||||
|
assert error.get_attribute("role") == "alert"
|
||||||
|
expect(error).not_to_be_empty()
|
||||||
|
# No redirect happened…
|
||||||
|
expect(page).to_have_url(app_url + "/login.html")
|
||||||
|
# …and the server agrees: still anonymous, no session cookie set.
|
||||||
|
who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
|
||||||
|
assert who == {"authenticated": False, "role": "anonymous"}
|
||||||
|
|
||||||
|
# The form stays usable: the correct password now succeeds.
|
||||||
|
page.fill("#login-password", ADMIN_PASSWORD)
|
||||||
|
page.click("#login-form button[type=submit]")
|
||||||
|
expect(page).to_have_url(app_url + "/sources.html", timeout=30_000)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Correct password → Sources + tuning unlocked, Sign out offered
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_login_unlocks_sources_and_tuning(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
|
||||||
|
# Real form login (default password + next) lands on the catalog.
|
||||||
|
login(page, app_url)
|
||||||
|
expect(page).to_have_url(app_url + "/sources.html")
|
||||||
|
expect(page.locator("#sources-gate")).to_be_hidden()
|
||||||
|
expect(page.locator("#stat-docs")).to_have_text("8")
|
||||||
|
expect(page.locator("#stat-chunks")).not_to_have_text("–")
|
||||||
|
expect(page.locator("#docs-table")).to_be_visible()
|
||||||
|
expect(page.locator("#docs-tbody tr")).to_have_count(8)
|
||||||
|
|
||||||
|
# Chat: the tuning UI is back — header toggle with count badge,
|
||||||
|
# Sign out instead of Sign in, Tune under the answer.
|
||||||
|
page.goto(app_url)
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||||
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||||
|
toggle = page.locator("#steering-toggle")
|
||||||
|
expect(toggle).to_be_visible()
|
||||||
|
expect(page.locator("#steering-count")).to_have_text("0")
|
||||||
|
|
||||||
|
_ask(page, QUESTION)
|
||||||
|
tune = page.locator(".msg.brain .tune-btn").last
|
||||||
|
expect(tune).to_be_visible()
|
||||||
|
box = tune.bounding_box()
|
||||||
|
assert box is not None and box["height"] >= 44
|
||||||
|
|
||||||
|
# The API agrees: admin, and the gated endpoints answer now.
|
||||||
|
who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
|
||||||
|
assert who == {"authenticated": True, "role": "admin"}
|
||||||
|
docs_status = page.evaluate("() => fetch('/api/docs').then((r) => r.status)")
|
||||||
|
assert docs_status == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Sign out → anonymous again (gate back, tuning gone, restore untunable)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_returns_to_anonymous(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
|
||||||
|
login(page, app_url, next="/") # straight into the chat
|
||||||
|
expect(page).to_have_url(app_url + "/")
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||||
|
expect(page.locator("#steering-toggle")).to_be_visible()
|
||||||
|
|
||||||
|
# One grounded turn as admin (persisted to localStorage by phase 14).
|
||||||
|
_ask(page, QUESTION)
|
||||||
|
expect(page.locator(".msg.brain .tune-btn").last).to_be_visible()
|
||||||
|
|
||||||
|
# Sign out: POST /api/logout + reload → anonymous again.
|
||||||
|
page.click("#sign-out-btn")
|
||||||
|
expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000)
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||||
|
expect(page.locator("#steering-toggle")).to_have_count(0)
|
||||||
|
expect(page.locator("#steering-panel")).to_have_count(0)
|
||||||
|
|
||||||
|
# The restored conversation came back… without any Tune button.
|
||||||
|
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||||
|
expect(page.locator(".msg.brain .tune-btn")).to_have_count(0)
|
||||||
|
|
||||||
|
# The server agrees, and Sources is gated again.
|
||||||
|
who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
|
||||||
|
assert who == {"authenticated": False, "role": "anonymous"}
|
||||||
|
page.goto(f"{app_url}/sources.html")
|
||||||
|
expect(page.locator("#sources-gate")).to_be_visible()
|
||||||
|
expect(page.locator("#docs-table")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Login page accessibility (WCAG 2.1 AA basics)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_a11y(page: Page, app_url: str, db_ready: None) -> None:
|
||||||
|
_reset_db(mock_port=0, seed=False)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
|
||||||
|
page.goto(f"{app_url}/login.html")
|
||||||
|
|
||||||
|
# Standard app frame: landmarks + skip link, no CDN tags.
|
||||||
|
expect(page.locator("header.app-header")).to_have_count(1)
|
||||||
|
expect(page.locator("nav[aria-label='Primary']")).to_have_count(1)
|
||||||
|
expect(page.locator("main#main")).to_have_count(1)
|
||||||
|
expect(page.locator("footer.app-footer")).to_have_count(1)
|
||||||
|
expect(page.locator(".skip-link")).to_have_count(1)
|
||||||
|
html = page.content()
|
||||||
|
assert 'src="https://' not in html and 'href="https://' not in html
|
||||||
|
|
||||||
|
# The password field is labeled (visually-hidden <label for=…>).
|
||||||
|
pw = page.get_by_label("Admin password")
|
||||||
|
expect(pw).to_have_count(1)
|
||||||
|
expect(pw.first).to_have_attribute("type", "password")
|
||||||
|
expect(pw.first).to_have_attribute("autocomplete", "current-password")
|
||||||
|
|
||||||
|
# Touch targets ≥44px (field + submit).
|
||||||
|
for el in (pw.first, page.locator("#login-form button[type=submit]")):
|
||||||
|
box = el.bounding_box()
|
||||||
|
assert box is not None and box["height"] >= 44, f"target too small: {box}"
|
||||||
|
|
||||||
|
# Keyboard focus draws the 3px focus-visible outline.
|
||||||
|
page.focus("#login-password")
|
||||||
|
outline = page.evaluate(
|
||||||
|
"() => getComputedStyle(document.querySelector('#login-password')).outlineWidth"
|
||||||
|
)
|
||||||
|
assert outline == "3px", f"focus-visible outline missing: {outline!r}"
|
||||||
|
|
||||||
|
# Errors are announced through the role=alert region.
|
||||||
|
error = page.locator("#login-error")
|
||||||
|
assert error.get_attribute("role") == "alert"
|
||||||
|
expect(error).to_be_hidden()
|
||||||
|
page.fill("#login-password", "wrong")
|
||||||
|
page.click("#login-form button[type=submit]")
|
||||||
|
expect(error).to_be_visible(timeout=15_000)
|
||||||
|
|
||||||
|
# A signed-in visit to /login.html?next=/ redirects immediately.
|
||||||
|
page.fill("#login-password", ADMIN_PASSWORD)
|
||||||
|
page.click("#login-form button[type=submit]")
|
||||||
|
expect(page).to_have_url(app_url + "/sources.html", timeout=30_000)
|
||||||
|
page.goto(f"{app_url}/login.html?next=/")
|
||||||
|
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||||
@@ -33,6 +33,7 @@ from app.config import Settings
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
@@ -271,7 +272,9 @@ def test_persists_across_page_navigation(
|
|||||||
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
|
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
|
||||||
|
|
||||||
# A trip to Sources — the New chat control is chat-page-only.
|
# A trip to Sources — the New chat control is chat-page-only.
|
||||||
page.goto(app_url + "/sources.html")
|
# (Phase 16: the catalog is admin-only — the trip starts with a
|
||||||
|
# real form login.)
|
||||||
|
login(page, app_url, next="/sources.html")
|
||||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||||
expect(page.locator("#new-chat-btn")).to_have_count(0)
|
expect(page.locator("#new-chat-btn")).to_have_count(0)
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from app.config import Settings
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
@@ -212,8 +213,9 @@ def test_dark_palette_and_contrast(
|
|||||||
_assert_aa(pairs["button"], "dark ink on brand (send button)")
|
_assert_aa(pairs["button"], "dark ink on brand (send button)")
|
||||||
_assert_aa(pairs["chip"], "chip ink on chip bg (chat)")
|
_assert_aa(pairs["chip"], "chip ink on chip bg (chat)")
|
||||||
|
|
||||||
# Sources page pairs.
|
# Sources page pairs. (Phase 16: the stat cards are admin-only —
|
||||||
page.goto(f"{app_url}/sources.html")
|
# a real form login first.)
|
||||||
|
login(page, app_url, next="/sources.html")
|
||||||
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
|
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
|
||||||
pairs = page.evaluate(
|
pairs = page.evaluate(
|
||||||
"""() => {
|
"""() => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from app.config import Settings
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
@@ -140,7 +141,7 @@ def test_back_from_sources_returns_to_sources(
|
|||||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
page.goto(f"{app_url}/sources.html")
|
login(page, app_url) # phase 16: the Sources table is admin-only
|
||||||
|
|
||||||
row = page.locator("#docs-tbody tr", has_text="kubernetes.md")
|
row = page.locator("#docs-tbody tr", has_text="kubernetes.md")
|
||||||
expect(row).to_have_count(1)
|
expect(row).to_have_count(1)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from app.db import SessionLocal
|
|||||||
from app.models import Document
|
from app.models import Document
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
@@ -139,7 +140,7 @@ def test_sources_row_links_to_viewer(
|
|||||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
page.goto(f"{app_url}/sources.html")
|
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
||||||
|
|
||||||
row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml")
|
row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml")
|
||||||
expect(row).to_have_count(1)
|
expect(row).to_have_count(1)
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ Test → story mapping (Playwright Mapping Rule):
|
|||||||
1. ``test_header_height_identical_across_pages_desktop``
|
1. ``test_header_height_identical_across_pages_desktop``
|
||||||
2. ``test_header_height_identical_across_pages_mobile``
|
2. ``test_header_height_identical_across_pages_mobile``
|
||||||
3. ``test_viewer_header_content_still_fits`` (phase-10 regression guard)
|
3. ``test_viewer_header_content_still_fits`` (phase-10 regression guard)
|
||||||
|
|
||||||
|
Phase 16 adaptation: the auth control (Sign in / Sign out) joins the chat
|
||||||
|
header's ``.header-inner`` — the desktop test verifies its presence in
|
||||||
|
both auth states without the bar's height moving (height assertions
|
||||||
|
unchanged).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -28,6 +33,7 @@ from app.config import Settings
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
@@ -111,6 +117,18 @@ def test_header_height_identical_across_pages_desktop(
|
|||||||
f"document header {heights['document']}px (was content-sized)"
|
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
|
||||||
|
# height.
|
||||||
|
page.goto(app_url + "/")
|
||||||
|
expect(page.locator("#sign-in-link")).to_be_visible()
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||||
|
assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||||
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||||
|
assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. Mobile (≤640px): all three pages, one identical 58px bar
|
# 2. Mobile (≤640px): all three pages, one identical 58px bar
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
|
|||||||
Seeding runs the real import function in-process against
|
Seeding runs the real import function in-process against
|
||||||
``tests/fixtures/docs/`` with the deterministic mock embeddings — it is a
|
``tests/fixtures/docs/`` with the deterministic mock embeddings — it is a
|
||||||
fixture, not the subject of the tests.
|
fixture, not the subject of the tests.
|
||||||
|
|
||||||
|
Phase 16 adaptation: the Sources catalog is admin-only — every test
|
||||||
|
performs the real form login (``e2e.auth_helpers.login``) first.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -23,6 +26,7 @@ from app.config import Settings
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
@@ -86,7 +90,7 @@ def test_sources_page_lists_indexed_docs(
|
|||||||
assert summary is not None and summary.added == 8
|
assert summary is not None and summary.added == 8
|
||||||
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||||
|
|
||||||
page.goto(f"{app_url}/sources.html")
|
login(page, app_url) # phase 16: the catalog is admin-only
|
||||||
expect(page.locator("#stat-docs")).to_have_text("8")
|
expect(page.locator("#stat-docs")).to_have_text("8")
|
||||||
expect(page.locator("#stat-chunks")).to_have_text(str(summary.chunks))
|
expect(page.locator("#stat-chunks")).to_have_text(str(summary.chunks))
|
||||||
expect(page.locator("#stat-last")).not_to_have_text("–")
|
expect(page.locator("#stat-last")).not_to_have_text("–")
|
||||||
@@ -105,7 +109,7 @@ def test_sources_table_layout(
|
|||||||
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
page.goto(f"{app_url}/sources.html")
|
login(page, app_url) # phase 16: the catalog is admin-only
|
||||||
page.locator("#docs-tbody tr").first.wait_for(state="visible")
|
page.locator("#docs-tbody tr").first.wait_for(state="visible")
|
||||||
|
|
||||||
wrap = page.locator(".table-wrap")
|
wrap = page.locator(".table-wrap")
|
||||||
@@ -124,7 +128,7 @@ def test_sources_table_layout(
|
|||||||
# scrolls horizontally instead of squeezing into a hairline.
|
# scrolls horizontally instead of squeezing into a hairline.
|
||||||
mobile = browser.new_page(viewport={"width": 375, "height": 812})
|
mobile = browser.new_page(viewport={"width": 375, "height": 812})
|
||||||
try:
|
try:
|
||||||
mobile.goto(f"{app_url}/sources.html")
|
login(mobile, app_url) # phase 16: the catalog is admin-only
|
||||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
|
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
|
||||||
scroll_width, client_width = mobile.evaluate(
|
scroll_width, client_width = mobile.evaluate(
|
||||||
"() => { const el = document.querySelector('.table-wrap');"
|
"() => { const el = document.querySelector('.table-wrap');"
|
||||||
@@ -138,7 +142,7 @@ def test_sources_table_layout(
|
|||||||
def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
|
def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
|
||||||
_reset_db(mock_llm, seed=False)
|
_reset_db(mock_llm, seed=False)
|
||||||
|
|
||||||
page.goto(f"{app_url}/sources.html")
|
login(page, app_url) # phase 16: the (empty-state) catalog is admin-only
|
||||||
expect(page.locator("#sources-empty")).to_be_visible()
|
expect(page.locator("#sources-empty")).to_be_visible()
|
||||||
expect(page.locator("#sources-empty")).to_contain_text("Nothing indexed yet")
|
expect(page.locator("#sources-empty")).to_contain_text("Nothing indexed yet")
|
||||||
expect(page.locator("#sources-empty code")).to_have_text(
|
expect(page.locator("#sources-empty code")).to_have_text(
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from app.config import Settings
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
@@ -206,7 +207,7 @@ def test_no_horizontal_overflow_at_viewports(
|
|||||||
)
|
)
|
||||||
_assert_no_doc_overflow(page, f"chat @ {width}px")
|
_assert_no_doc_overflow(page, f"chat @ {width}px")
|
||||||
|
|
||||||
page.goto(f"{app_url}/sources.html")
|
login(page, app_url, next="/sources.html") # phase 16: admin-only
|
||||||
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||||
_assert_no_doc_overflow(page, f"sources @ {width}px")
|
_assert_no_doc_overflow(page, f"sources @ {width}px")
|
||||||
finally:
|
finally:
|
||||||
@@ -254,7 +255,7 @@ def test_sources_table_full_width(
|
|||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||||
try:
|
try:
|
||||||
page.goto(f"{app_url}/sources.html")
|
login(page, app_url, next="/sources.html") # phase 16: admin-only
|
||||||
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||||
wrap_box = page.locator(".table-wrap").bounding_box()
|
wrap_box = page.locator(".table-wrap").bounding_box()
|
||||||
shell_box = page.locator(".sources-shell").bounding_box()
|
shell_box = page.locator(".sources-shell").bounding_box()
|
||||||
@@ -268,7 +269,7 @@ def test_sources_table_full_width(
|
|||||||
|
|
||||||
mobile = browser.new_page(viewport={"width": 375, "height": 812})
|
mobile = browser.new_page(viewport={"width": 375, "height": 812})
|
||||||
try:
|
try:
|
||||||
mobile.goto(f"{app_url}/sources.html")
|
login(mobile, app_url, next="/sources.html") # phase 16: admin-only
|
||||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||||
scroll, client = mobile.evaluate(
|
scroll, client = mobile.evaluate(
|
||||||
"() => { const el = document.querySelector('.table-wrap');"
|
"() => { const el = document.querySelector('.table-wrap');"
|
||||||
@@ -389,7 +390,8 @@ def test_contrast_pairs_pass_aa(
|
|||||||
_assert_aa(pairs["deflection"], "deflection ink on deflection bg")
|
_assert_aa(pairs["deflection"], "deflection ink on deflection bg")
|
||||||
|
|
||||||
# Sources page: ink-soft/surface, white/brand (active nav).
|
# Sources page: ink-soft/surface, white/brand (active nav).
|
||||||
page.goto(f"{app_url}/sources.html")
|
# (Phase 16: the stat cards are admin-only — sign in first.)
|
||||||
|
login(page, app_url, next="/sources.html")
|
||||||
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
|
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
|
||||||
pairs = page.evaluate(
|
pairs = page.evaluate(
|
||||||
"""() => {
|
"""() => {
|
||||||
@@ -498,7 +500,7 @@ def test_long_content_wraps_without_overflow(
|
|||||||
# Sources @ 360px: the long path ellipsizes, full path stays in `title`.
|
# Sources @ 360px: the long path ellipsizes, full path stays in `title`.
|
||||||
phone = browser.new_page(viewport={"width": 360, "height": 740})
|
phone = browser.new_page(viewport={"width": 360, "height": 740})
|
||||||
try:
|
try:
|
||||||
phone.goto(f"{app_url}/sources.html")
|
login(phone, app_url, next="/sources.html") # phase 16: admin-only
|
||||||
row = phone.locator("#docs-tbody tr", has_text="backup_rotation").first
|
row = phone.locator("#docs-tbody tr", has_text="backup_rotation").first
|
||||||
row.wait_for(state="visible", timeout=10_000)
|
row.wait_for(state="visible", timeout=10_000)
|
||||||
cell = row.get_by_role("cell").nth(1)
|
cell = row.get_by_role("cell").nth(1)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from app.db import SessionLocal
|
|||||||
from app.models import QueryLog
|
from app.models import QueryLog
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
@@ -90,7 +91,15 @@ def test_multi_format_import_hidden_doc_excluded(
|
|||||||
assert summary.added == 8
|
assert summary.added == 8
|
||||||
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||||
|
|
||||||
r = httpx.get(f"{app_url}/api/docs", timeout=10)
|
# Phase 16: the catalog is admin-only — perform the real form login,
|
||||||
|
# then call the API with the signed cookie the browser now holds.
|
||||||
|
login(page, app_url, next="/sources.html")
|
||||||
|
cookies = {
|
||||||
|
c["name"]: c["value"]
|
||||||
|
for c in page.context.cookies()
|
||||||
|
if "name" in c and "value" in c
|
||||||
|
}
|
||||||
|
r = httpx.get(f"{app_url}/api/docs", timeout=10, cookies=cookies)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
docs = r.json()["documents"]
|
docs = r.json()["documents"]
|
||||||
assert len(docs) == 8
|
assert len(docs) == 8
|
||||||
@@ -103,8 +112,7 @@ def test_multi_format_import_hidden_doc_excluded(
|
|||||||
"homelab/ssh/ssh_aliases.txt",
|
"homelab/ssh/ssh_aliases.txt",
|
||||||
}
|
}
|
||||||
|
|
||||||
# The Sources page reflects the same set.
|
# The Sources page (we're already on it, signed in) reflects the set.
|
||||||
page.goto(f"{app_url}/sources.html")
|
|
||||||
expect(page.locator("#stat-docs")).to_have_text("8")
|
expect(page.locator("#stat-docs")).to_have_text("8")
|
||||||
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
|
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"""Phase 15 E2E (Playwright): tune how Brain answers (steering notes).
|
"""Phase 15 E2E (Playwright): tune how Brain answers (steering notes).
|
||||||
|
|
||||||
|
Phase 16 adaptation: tuning is admin-only — every test performs the real
|
||||||
|
form login (``e2e.auth_helpers.login``) before touching the tuning UI.
|
||||||
|
|
||||||
Story: ``.agent/user_stories/steering-notes.md``
|
Story: ``.agent/user_stories/steering-notes.md``
|
||||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
@@ -34,6 +37,7 @@ from app.db import SessionLocal
|
|||||||
from app.models import SteeringNote
|
from app.models import SteeringNote
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
@@ -125,6 +129,7 @@ def test_tune_under_answer_persists_and_steers(
|
|||||||
assert summary is not None and summary.added == 8 # A9 formats
|
assert summary is not None and summary.added == 8 # A9 formats
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
page.goto(app_url)
|
page.goto(app_url)
|
||||||
|
login(page, app_url, next="/") # phase 16: tuning is admin-only
|
||||||
_ask(page, QUESTION)
|
_ask(page, QUESTION)
|
||||||
|
|
||||||
# The Tune control: ghost button in the answer's meta row, ≥44px.
|
# The Tune control: ghost button in the answer's meta row, ≥44px.
|
||||||
@@ -165,6 +170,7 @@ def test_delete_note_stops_steering(
|
|||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
page.goto(app_url)
|
page.goto(app_url)
|
||||||
|
login(page, app_url, next="/") # phase 16: tuning is admin-only
|
||||||
_ask(page, QUESTION)
|
_ask(page, QUESTION)
|
||||||
_tune_and_save(page, NOTE)
|
_tune_and_save(page, NOTE)
|
||||||
|
|
||||||
@@ -202,6 +208,7 @@ def test_note_rendered_as_text_xss_safe(
|
|||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
page.goto(app_url)
|
page.goto(app_url)
|
||||||
|
login(page, app_url, next="/") # phase 16: tuning is admin-only
|
||||||
|
|
||||||
dialogs: list[str] = []
|
dialogs: list[str] = []
|
||||||
|
|
||||||
@@ -234,6 +241,7 @@ def test_tuning_panel_a11y(page: Page, app_url: str, db_ready: None) -> None:
|
|||||||
_reset_db(mock_port=0, seed=False) # no KB seeding needed for the panel a11y
|
_reset_db(mock_port=0, seed=False) # no KB seeding needed for the panel a11y
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
page.goto(app_url)
|
page.goto(app_url)
|
||||||
|
login(page, app_url, next="/") # phase 16: the panel is admin-only
|
||||||
|
|
||||||
toggle = page.locator("#steering-toggle")
|
toggle = page.locator("#steering-toggle")
|
||||||
panel = page.locator("#steering-panel")
|
panel = page.locator("#steering-panel")
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
|
|||||||
("/", "Brain of Reese"),
|
("/", "Brain of Reese"),
|
||||||
("/sources.html", "Knowledge base"),
|
("/sources.html", "Knowledge base"),
|
||||||
("/document.html", "Brain of Reese"), # phase 10: viewer page
|
("/document.html", "Brain of Reese"), # phase 10: viewer page
|
||||||
|
("/login.html", "Sign in"), # phase 16: admin sign-in page
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
|
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
|
||||||
@@ -75,6 +76,7 @@ def test_styles_and_js_served(client) -> None:
|
|||||||
assert client.get("/assets/sources.js").status_code == 200
|
assert client.get("/assets/sources.js").status_code == 200
|
||||||
assert client.get("/assets/markdown.js").status_code == 200 # phase 10: shared renderer
|
assert client.get("/assets/markdown.js").status_code == 200 # phase 10: shared renderer
|
||||||
assert client.get("/assets/document.js").status_code == 200 # phase 10: viewer page
|
assert client.get("/assets/document.js").status_code == 200 # phase 10: viewer page
|
||||||
|
assert client.get("/assets/login.js").status_code == 200 # phase 16: login page
|
||||||
|
|
||||||
|
|
||||||
# Emoji code points banned from UI chrome (phase 08): the pictograph
|
# Emoji code points banned from UI chrome (phase 08): the pictograph
|
||||||
@@ -104,10 +106,12 @@ def _find_emoji(text: str) -> list[str]:
|
|||||||
"/",
|
"/",
|
||||||
"/sources.html",
|
"/sources.html",
|
||||||
"/document.html",
|
"/document.html",
|
||||||
|
"/login.html", # phase 16
|
||||||
"/assets/app.js",
|
"/assets/app.js",
|
||||||
"/assets/sources.js",
|
"/assets/sources.js",
|
||||||
"/assets/markdown.js",
|
"/assets/markdown.js",
|
||||||
"/assets/document.js",
|
"/assets/document.js",
|
||||||
|
"/assets/login.js", # phase 16
|
||||||
"/assets/styles.css",
|
"/assets/styles.css",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Integration: the auth surface (phase 16) + the public-API regression
|
||||||
|
guards.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Requires: podman compose up -d db
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
from test_chat_api import FakeRagLLM, _stream_chat
|
||||||
|
|
||||||
|
from app.api import chat as chat_api
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
from app.models import Document
|
||||||
|
from app.rag.importer import import_sources
|
||||||
|
from tests.conftest import ADMIN_PASSWORD
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
|
||||||
|
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"))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||||
|
"""Fresh Postgres with the fixture docs imported (real pipeline)."""
|
||||||
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
|
db.commit()
|
||||||
|
llm = FakeRagLLM()
|
||||||
|
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||||
|
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||||
|
yield llm
|
||||||
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_one_doc(db) -> Document:
|
||||||
|
"""One minimal document (for the public document-content endpoint)."""
|
||||||
|
doc = Document(
|
||||||
|
source="docs",
|
||||||
|
path="homelab/kubernetes.md",
|
||||||
|
full_path="/tmp/kubernetes.md",
|
||||||
|
title="Kubernetes Homelab Cluster",
|
||||||
|
content="# Kubernetes Homelab Cluster\n\nTalos on 3 nodes.",
|
||||||
|
content_hash="c" * 64,
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
assert r.json() == {"detail": "invalid password"}
|
||||||
|
# No session state was created by the failed attempt.
|
||||||
|
assert "bor_session" not in client.cookies
|
||||||
|
|
||||||
|
r = client.post("/api/login", json={"password": ""}) # empty → same 401
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json() == {"detail": "invalid password"}
|
||||||
|
|
||||||
|
# The gated surface stays closed (anonymous).
|
||||||
|
assert client.get("/api/docs").status_code == 403
|
||||||
|
r = client.get("/api/steering")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json() == {"detail": "admin only"}
|
||||||
|
assert client.post("/api/steering", json={"note": "x"}).status_code == 403
|
||||||
|
assert client.get("/api/whoami").json() == {
|
||||||
|
"authenticated": False,
|
||||||
|
"role": "anonymous",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_logout_lifecycle(client: TestClient) -> None:
|
||||||
|
# Anonymous shape before anything.
|
||||||
|
who = client.get("/api/whoami").json()
|
||||||
|
assert who == {"authenticated": False, "role": "anonymous"}
|
||||||
|
|
||||||
|
# Wrong first, right second — one generic 401, then success.
|
||||||
|
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
|
||||||
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||||
|
assert r.status_code == 204
|
||||||
|
assert "bor_session" in client.cookies # the signed session cookie
|
||||||
|
|
||||||
|
# Admin: whoami + the gated endpoints all open up.
|
||||||
|
assert client.get("/api/whoami").json() == {
|
||||||
|
"authenticated": True,
|
||||||
|
"role": "admin",
|
||||||
|
}
|
||||||
|
assert client.get("/api/docs").status_code == 200
|
||||||
|
|
||||||
|
created = client.post("/api/steering", json={"note": " be terse "})
|
||||||
|
assert created.status_code == 201
|
||||||
|
note = created.json()
|
||||||
|
assert note["note"] == "be terse"
|
||||||
|
listing = client.get("/api/steering")
|
||||||
|
assert listing.status_code == 200
|
||||||
|
assert [n["note"] for n in listing.json()["notes"]] == ["be terse"]
|
||||||
|
assert client.delete(f"/api/steering/{note['id']}").status_code == 204
|
||||||
|
assert client.get("/api/steering").json() == {"notes": []}
|
||||||
|
|
||||||
|
# Logout: 204, cookie gone, gated again.
|
||||||
|
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/docs").status_code == 403
|
||||||
|
r = client.get("/api/steering")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json() == {"detail": "admin only"}
|
||||||
|
# Logout is idempotent (anonymous logout is still a clean 204).
|
||||||
|
assert client.post("/api/logout").status_code == 204
|
||||||
|
assert client.get("/api/whoami").json()["authenticated"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_forged_cookie_is_rejected(client: TestClient) -> None:
|
||||||
|
client.cookies.set("bor_session", "tampered-session-blob")
|
||||||
|
assert client.get("/api/whoami").json() == {
|
||||||
|
"authenticated": False,
|
||||||
|
"role": "anonymous",
|
||||||
|
}
|
||||||
|
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."""
|
||||||
|
_seed_one_doc(db)
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
"/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["title"] == "Kubernetes Homelab Cluster"
|
||||||
|
assert "Talos" in body["content"]
|
||||||
|
assert set(body) == {
|
||||||
|
"source",
|
||||||
|
"path",
|
||||||
|
"title",
|
||||||
|
"format",
|
||||||
|
"content",
|
||||||
|
"indexed_at",
|
||||||
|
"chunks",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unknown docs still 404 anonymously (no enumeration of titles).
|
||||||
|
r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_chat_still_streams(
|
||||||
|
client: TestClient, seeded_kb: FakeRagLLM
|
||||||
|
) -> None:
|
||||||
|
"""Regression guard: sign-in must not have locked chat (A10 public)."""
|
||||||
|
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")
|
||||||
|
deltas = [f for f in frames if f.get("type") == "delta"]
|
||||||
|
assert len(deltas) >= 2 # genuinely streamed
|
||||||
|
assert frames[-1]["type"] == "done"
|
||||||
|
assert frames[-1]["deflected"] is False
|
||||||
|
assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md"
|
||||||
@@ -12,15 +12,15 @@ from sqlalchemy import text
|
|||||||
from app.models import Chunk, Document
|
from app.models import Chunk, Document
|
||||||
|
|
||||||
|
|
||||||
def test_docs_empty_shape(client, db) -> None:
|
def test_docs_empty_shape(admin_client, db) -> None:
|
||||||
db.execute(text("TRUNCATE chunks, documents"))
|
db.execute(text("TRUNCATE chunks, documents"))
|
||||||
db.commit()
|
db.commit()
|
||||||
r = client.get("/api/docs")
|
r = admin_client.get("/api/docs")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json() == {"documents": []}
|
assert r.json() == {"documents": []}
|
||||||
|
|
||||||
|
|
||||||
def test_docs_populated_shape_sorted_with_chunk_counts(client, db) -> None:
|
def test_docs_populated_shape_sorted_with_chunk_counts(admin_client, db) -> None:
|
||||||
db.execute(text("TRUNCATE chunks, documents"))
|
db.execute(text("TRUNCATE chunks, documents"))
|
||||||
db.commit()
|
db.commit()
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
@@ -50,7 +50,7 @@ def test_docs_populated_shape_sorted_with_chunk_counts(client, db) -> None:
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
r = client.get("/api/docs")
|
r = admin_client.get("/api/docs")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
# Ordered by (source, path): Deployments < Homelab.
|
# Ordered by (source, path): Deployments < Homelab.
|
||||||
@@ -69,8 +69,8 @@ def test_docs_populated_shape_sorted_with_chunk_counts(client, db) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def test_docs_response_matches_schema_shape(client, db) -> None:
|
def test_docs_response_matches_schema_shape(admin_client, db) -> None:
|
||||||
r = client.get("/api/docs")
|
r = admin_client.get("/api/docs")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert set(body) == {"documents"}
|
assert set(body) == {"documents"}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ EXPECTED_DOCS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_import_fixtures_end_to_end(client, db) -> None:
|
def test_import_fixtures_end_to_end(admin_client, db) -> None:
|
||||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
db.commit()
|
db.commit()
|
||||||
llm = FakeEmbedder()
|
llm = FakeEmbedder()
|
||||||
@@ -67,7 +67,7 @@ def test_import_fixtures_end_to_end(client, db) -> None:
|
|||||||
assert c.embedding is not None and len(c.embedding) == 768
|
assert c.embedding is not None and len(c.embedding) == 768
|
||||||
|
|
||||||
# The Sources page consumes exactly this shape.
|
# The Sources page consumes exactly this shape.
|
||||||
r = client.get("/api/docs")
|
r = admin_client.get("/api/docs") # phase 16: the catalog is admin-only
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert len(body["documents"]) == 8
|
assert len(body["documents"]) == 8
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ def _turn_log_lines(caplog: pytest.LogCaptureFixture) -> list[str]:
|
|||||||
# ---------- CRUD ----------
|
# ---------- CRUD ----------
|
||||||
|
|
||||||
|
|
||||||
def test_create_note_returns_201_and_stores_trimmed(client: TestClient, db) -> None:
|
def test_create_note_returns_201_and_stores_trimmed(admin_client: TestClient, db) -> None:
|
||||||
r = client.post("/api/steering", json={"note": f" {NOTE} "})
|
r = admin_client.post("/api/steering", json={"note": f" {NOTE} "})
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["note"] == NOTE # trimmed before storage
|
assert body["note"] == NOTE # trimmed before storage
|
||||||
@@ -74,13 +74,13 @@ def test_create_note_returns_201_and_stores_trimmed(client: TestClient, db) -> N
|
|||||||
assert [row.note for row in rows] == [NOTE]
|
assert [row.note for row in rows] == [NOTE]
|
||||||
|
|
||||||
|
|
||||||
def test_list_notes_empty(client: TestClient) -> None:
|
def test_list_notes_empty(admin_client: TestClient) -> None:
|
||||||
r = client.get("/api/steering")
|
r = admin_client.get("/api/steering")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json() == {"notes": []}
|
assert r.json() == {"notes": []}
|
||||||
|
|
||||||
|
|
||||||
def test_list_notes_newest_first(client: TestClient, db) -> None:
|
def test_list_notes_newest_first(admin_client: TestClient, db) -> None:
|
||||||
base = datetime.now(UTC)
|
base = datetime.now(UTC)
|
||||||
db.add_all(
|
db.add_all(
|
||||||
[
|
[
|
||||||
@@ -91,7 +91,7 @@ def test_list_notes_newest_first(client: TestClient, db) -> None:
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
r = client.get("/api/steering")
|
r = admin_client.get("/api/steering")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert [n["note"] for n in body["notes"]] == ["newest", "middle", "oldest"]
|
assert [n["note"] for n in body["notes"]] == ["newest", "middle", "oldest"]
|
||||||
@@ -100,33 +100,33 @@ def test_list_notes_newest_first(client: TestClient, db) -> None:
|
|||||||
uuid.UUID(n["id"])
|
uuid.UUID(n["id"])
|
||||||
|
|
||||||
|
|
||||||
def test_delete_note_returns_204_and_removes(client: TestClient, db) -> None:
|
def test_delete_note_returns_204_and_removes(admin_client: TestClient, db) -> None:
|
||||||
created = client.post("/api/steering", json={"note": NOTE}).json()
|
created = admin_client.post("/api/steering", json={"note": NOTE}).json()
|
||||||
|
|
||||||
assert client.delete(f"/api/steering/{created['id']}").status_code == 204
|
assert admin_client.delete(f"/api/steering/{created['id']}").status_code == 204
|
||||||
assert client.get("/api/steering").json() == {"notes": []}
|
assert admin_client.get("/api/steering").json() == {"notes": []}
|
||||||
assert db.scalars(select(SteeringNote)).all() == []
|
assert db.scalars(select(SteeringNote)).all() == []
|
||||||
|
|
||||||
|
|
||||||
def test_delete_unknown_note_returns_404(client: TestClient) -> None:
|
def test_delete_unknown_note_returns_404(admin_client: TestClient) -> None:
|
||||||
r = client.delete(f"/api/steering/{uuid.uuid4()}")
|
r = admin_client.delete(f"/api/steering/{uuid.uuid4()}")
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
assert "not found" in r.json()["detail"]
|
assert "not found" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_delete_invalid_id_returns_422(client: TestClient) -> None:
|
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||||
assert client.delete("/api/steering/not-a-uuid").status_code == 422
|
assert admin_client.delete("/api/steering/not-a-uuid").status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_create_rejects_empty_and_blank_notes(client: TestClient) -> None:
|
def test_create_rejects_empty_and_blank_notes(admin_client: TestClient) -> None:
|
||||||
assert client.post("/api/steering", json={"note": ""}).status_code == 422
|
assert admin_client.post("/api/steering", json={"note": ""}).status_code == 422
|
||||||
assert client.post("/api/steering", json={"note": " \t\n "}).status_code == 422
|
assert admin_client.post("/api/steering", json={"note": " \t\n "}).status_code == 422
|
||||||
assert client.get("/api/steering").json() == {"notes": []}
|
assert admin_client.get("/api/steering").json() == {"notes": []}
|
||||||
|
|
||||||
|
|
||||||
def test_create_enforces_2000_char_limit(client: TestClient) -> None:
|
def test_create_enforces_2000_char_limit(admin_client: TestClient) -> None:
|
||||||
assert client.post("/api/steering", json={"note": "x" * 2001}).status_code == 422
|
assert admin_client.post("/api/steering", json={"note": "x" * 2001}).status_code == 422
|
||||||
r = client.post("/api/steering", json={"note": "x" * 2000})
|
r = admin_client.post("/api/steering", json={"note": "x" * 2000})
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
assert len(r.json()["note"]) == 2000
|
assert len(r.json()["note"]) == 2000
|
||||||
|
|
||||||
@@ -135,14 +135,14 @@ def test_create_enforces_2000_char_limit(client: TestClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_chat_turn_high_mode_receives_note_in_system_prompt(
|
def test_chat_turn_high_mode_receives_note_in_system_prompt(
|
||||||
client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
client.post("/api/steering", json={"note": NOTE})
|
admin_client.post("/api/steering", json={"note": NOTE})
|
||||||
caplog.set_level(logging.INFO, logger="app.chat")
|
caplog.set_level(logging.INFO, logger="app.chat")
|
||||||
|
|
||||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
try:
|
try:
|
||||||
_, _, frames = _stream_chat(client, QUESTION)
|
_, _, frames = _stream_chat(admin_client, QUESTION)
|
||||||
finally:
|
finally:
|
||||||
fastapi_app.dependency_overrides.clear()
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
@@ -170,14 +170,14 @@ def test_chat_turn_high_mode_receives_note_in_system_prompt(
|
|||||||
|
|
||||||
|
|
||||||
def test_chat_turn_low_mode_receives_note_in_system_prompt(
|
def test_chat_turn_low_mode_receives_note_in_system_prompt(
|
||||||
client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
client.post("/api/steering", json={"note": NOTE})
|
admin_client.post("/api/steering", json={"note": NOTE})
|
||||||
caplog.set_level(logging.INFO, logger="app.chat")
|
caplog.set_level(logging.INFO, logger="app.chat")
|
||||||
|
|
||||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
try:
|
try:
|
||||||
_, _, frames = _stream_chat(client, OFF_TOPIC)
|
_, _, frames = _stream_chat(admin_client, OFF_TOPIC)
|
||||||
finally:
|
finally:
|
||||||
fastapi_app.dependency_overrides.clear()
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
@@ -192,13 +192,13 @@ def test_chat_turn_low_mode_receives_note_in_system_prompt(
|
|||||||
|
|
||||||
|
|
||||||
def test_chat_turn_without_notes_has_no_tuning_section(
|
def test_chat_turn_without_notes_has_no_tuning_section(
|
||||||
client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
caplog.set_level(logging.INFO, logger="app.chat")
|
caplog.set_level(logging.INFO, logger="app.chat")
|
||||||
|
|
||||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
try:
|
try:
|
||||||
_stream_chat(client, QUESTION)
|
_stream_chat(admin_client, QUESTION)
|
||||||
finally:
|
finally:
|
||||||
fastapi_app.dependency_overrides.clear()
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ def test_chat_turn_without_notes_has_no_tuning_section(
|
|||||||
assert lines and "tuning=0" in lines[-1]
|
assert lines and "tuning=0" in lines[-1]
|
||||||
|
|
||||||
|
|
||||||
def test_chat_turn_numbers_notes_oldest_first(client: TestClient, db, seeded_kb) -> None:
|
def test_chat_turn_numbers_notes_oldest_first(admin_client: TestClient, db, seeded_kb) -> None:
|
||||||
base = datetime.now(UTC)
|
base = datetime.now(UTC)
|
||||||
db.add_all(
|
db.add_all(
|
||||||
[
|
[
|
||||||
@@ -220,7 +220,7 @@ def test_chat_turn_numbers_notes_oldest_first(client: TestClient, db, seeded_kb)
|
|||||||
|
|
||||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
try:
|
try:
|
||||||
_stream_chat(client, QUESTION)
|
_stream_chat(admin_client, QUESTION)
|
||||||
finally:
|
finally:
|
||||||
fastapi_app.dependency_overrides.clear()
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
@@ -231,22 +231,22 @@ def test_chat_turn_numbers_notes_oldest_first(client: TestClient, db, seeded_kb)
|
|||||||
assert system["content"].index("1. older note") < system["content"].index("2. newer note")
|
assert system["content"].index("1. older note") < system["content"].index("2. newer note")
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_turns_keep_reading_notes(client: TestClient, seeded_kb) -> None:
|
def test_multiple_turns_keep_reading_notes(admin_client: TestClient, seeded_kb) -> None:
|
||||||
"""The note steers EVERY subsequent turn, not just the next one."""
|
"""The note steers EVERY subsequent turn, not just the next one."""
|
||||||
client.post("/api/steering", json={"note": NOTE})
|
admin_client.post("/api/steering", json={"note": NOTE})
|
||||||
|
|
||||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
try:
|
try:
|
||||||
_stream_chat(client, QUESTION)
|
_stream_chat(admin_client, QUESTION)
|
||||||
_stream_chat(client, QUESTION)
|
_stream_chat(admin_client, QUESTION)
|
||||||
assert len(seeded_kb.seen_messages) == 2
|
assert len(seeded_kb.seen_messages) == 2
|
||||||
for messages in seeded_kb.seen_messages:
|
for messages in seeded_kb.seen_messages:
|
||||||
assert f"1. {NOTE}" in messages[0]["content"]
|
assert f"1. {NOTE}" in messages[0]["content"]
|
||||||
|
|
||||||
# Delete → the following turn is clean again.
|
# Delete → the following turn is clean again.
|
||||||
note_id = client.get("/api/steering").json()["notes"][0]["id"]
|
note_id = admin_client.get("/api/steering").json()["notes"][0]["id"]
|
||||||
assert client.delete(f"/api/steering/{note_id}").status_code == 204
|
assert admin_client.delete(f"/api/steering/{note_id}").status_code == 204
|
||||||
_stream_chat(client, QUESTION)
|
_stream_chat(admin_client, QUESTION)
|
||||||
assert len(seeded_kb.seen_messages) == 3
|
assert len(seeded_kb.seen_messages) == 3
|
||||||
assert "<tuning>" not in seeded_kb.seen_messages[-1][0]["content"]
|
assert "<tuning>" not in seeded_kb.seen_messages[-1][0]["content"]
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Unit tests: single-admin auth (phase 16; A10 revised).
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.middleware.sessions import Session
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
import app.main as main_mod
|
||||||
|
from app.api.auth import whoami
|
||||||
|
from app.config import Settings
|
||||||
|
from app.core.auth import (
|
||||||
|
check_password,
|
||||||
|
ensure_admin_configured,
|
||||||
|
require_admin,
|
||||||
|
sign_in,
|
||||||
|
sign_out,
|
||||||
|
)
|
||||||
|
from app.schemas import WhoamiResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**kwargs: object) -> Settings:
|
||||||
|
return Settings(_env_file=None, **kwargs) # pyright: ignore[reportCallIssue]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- ensure_admin_configured (fail-loud) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_passes() -> None:
|
||||||
|
ensure_admin_configured(_settings(admin_password="pw", session_secret="s"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("password", "secret", "named"),
|
||||||
|
[
|
||||||
|
("", "s", "BOR_ADMIN_PASSWORD"),
|
||||||
|
("pw", "", "BOR_SESSION_SECRET"),
|
||||||
|
("", "", "BOR_ADMIN_PASSWORD"),
|
||||||
|
("", "", "BOR_SESSION_SECRET"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_missing_vars_raise_naming_them(password: str, secret: str, named: str) -> None:
|
||||||
|
with pytest.raises(RuntimeError) as exc:
|
||||||
|
ensure_admin_configured(_settings(admin_password=password, session_secret=secret))
|
||||||
|
assert named in str(exc.value)
|
||||||
|
# Both vars are named when both are missing.
|
||||||
|
if not password and not secret:
|
||||||
|
assert "BOR_ADMIN_PASSWORD" in str(exc.value)
|
||||||
|
assert "BOR_SESSION_SECRET" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitespace_only_counts_as_missing() -> None:
|
||||||
|
with pytest.raises(RuntimeError, match="BOR_ADMIN_PASSWORD"):
|
||||||
|
ensure_admin_configured(_settings(admin_password=" ", session_secret="s"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_app_raises_when_admin_password_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(main_mod.settings, "admin_password", "")
|
||||||
|
with pytest.raises(RuntimeError, match="BOR_ADMIN_PASSWORD"):
|
||||||
|
main_mod.create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_app_raises_when_session_secret_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(main_mod.settings, "session_secret", "")
|
||||||
|
with pytest.raises(RuntimeError, match="BOR_SESSION_SECRET"):
|
||||||
|
main_mod.create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_app_boots_when_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# conftest set both env vars before app.main imported — the factory
|
||||||
|
# with valid config returns an app (and no static-dir warning fires
|
||||||
|
# here: the frontend dir exists in the repo).
|
||||||
|
monkeypatch.setattr(main_mod.settings, "admin_password", "pw")
|
||||||
|
monkeypatch.setattr(main_mod.settings, "session_secret", "s")
|
||||||
|
app2 = main_mod.create_app()
|
||||||
|
assert app2 is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- check_password (constant-time, one generic result) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_password_match() -> None:
|
||||||
|
assert check_password("hunter2", "hunter2") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_password_mismatch() -> None:
|
||||||
|
assert check_password("hunter2", "hunter3") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_password_empty_candidate() -> None:
|
||||||
|
assert check_password("", "hunter2") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_password_unicode() -> None:
|
||||||
|
assert check_password("pässwörd", "pässwörd") is True
|
||||||
|
assert check_password("pässwörd", "pässwörX") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- require_admin (dependency: 403 for anonymous) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _request_with_session(**session: object) -> Request:
|
||||||
|
# request.session is a scope-backed property (SessionMiddleware puts
|
||||||
|
# the Session into the scope) — build the scope the same way.
|
||||||
|
return Request({"type": "http", "session": Session(dict(session))})
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_admin_passes_for_admin_session() -> None:
|
||||||
|
require_admin(_request_with_session(admin=True)) # no exception
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("session", [{}, {"admin": False}, {"admin": None}])
|
||||||
|
def test_require_admin_403s_anonymous(session: dict) -> None:
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
require_admin(_request_with_session(**session))
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
assert exc.value.detail == "admin only"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- whoami payload shape ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_whoami_anonymous_payload() -> None:
|
||||||
|
body = whoami(_request_with_session())
|
||||||
|
assert body == WhoamiResponse(authenticated=False, role="anonymous")
|
||||||
|
assert set(body.model_dump()) == {"authenticated", "role"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_whoami_admin_payload() -> None:
|
||||||
|
body = whoami(_request_with_session(admin=True))
|
||||||
|
assert body == WhoamiResponse(authenticated=True, role="admin")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- sign_in / sign_out session semantics ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_in_marks_session_admin_and_modified() -> None:
|
||||||
|
session = Session({})
|
||||||
|
sign_in(session)
|
||||||
|
assert session["admin"] is True
|
||||||
|
assert session.modified is True # the middleware will persist the cookie
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_out_clears_session() -> None:
|
||||||
|
session = Session({"admin": True, "stray": "x"})
|
||||||
|
sign_out(session)
|
||||||
|
assert dict(session) == {}
|
||||||
|
assert "admin" not in session
|
||||||
@@ -55,6 +55,7 @@ dependencies = [
|
|||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "itsdangerous" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "pgvector" },
|
{ name = "pgvector" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
@@ -80,6 +81,7 @@ requires-dist = [
|
|||||||
{ name = "alembic", specifier = ">=1.13,<2.0" },
|
{ name = "alembic", specifier = ">=1.13,<2.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115,<1.0" },
|
{ name = "fastapi", specifier = ">=0.115,<1.0" },
|
||||||
{ name = "httpx", specifier = ">=0.27,<1.0" },
|
{ name = "httpx", specifier = ">=0.27,<1.0" },
|
||||||
|
{ name = "itsdangerous", specifier = ">=2.2,<3.0" },
|
||||||
{ name = "openai", specifier = ">=1.40,<3.0" },
|
{ name = "openai", specifier = ">=1.40,<3.0" },
|
||||||
{ name = "pgvector", specifier = ">=0.3,<1.0" },
|
{ name = "pgvector", specifier = ">=0.3,<1.0" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1,<4.0" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1,<4.0" },
|
||||||
@@ -433,6 +435,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jiter"
|
name = "jiter"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user