Files
brain-of-reese/tests/integration/test_api.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

428 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Integration tests: HTTP API surface (mostly no database required).
Phase 79 note: the user-gated endpoints (chat, suggestions) are driven
here by a signed-in ADMIN client — the admin path of ``require_user``
short-circuits before any DB touch, so this module stays database-free
(the 401 auth contract itself is pinned in ``test_auth_api.py``).
Phase 80 note: the suggestions pins are the exception — the chips are
the last 3 questions asked once any are saved, so the env-override
pin (the override is the SEED) needs an empty ``saved_chats``;
the full state matrix lives in ``test_suggestions_api.py``.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import get_settings
from tests.conftest import ADMIN_PASSWORD
def test_health_reports_ok(client) -> None:
r = client.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["db"] in {"up", "down"}
assert body["version"]
def test_config_returns_default_app_metadata(client) -> None:
"""GET /api/config is public (anonymous) and returns exactly six
keys — the phase-39 app metadata, the phase-59 docs flag (inert
false while BOR_DOCS_REPO is empty — the "Save as doc" gating),
and the phase-62 UI customization strings (composer placeholder,
footer line, theme file name)."""
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert body["app_name"] == "Brain of Reese"
assert body["version"] == get_settings().app_version
assert body["docs_repo_configured"] is False
# Phase 62: UNSET => the phase-61 neutral copy stands (the
# byte-identical contract); an empty theme = the built-in palette.
assert body["input_placeholder"] == "Ask me anything…"
assert body["footer_text"] == "Powered by self-hosted models"
assert body["theme"] == ""
def test_config_follows_overridden_app_name(client) -> None:
"""GET /api/config reflects a Settings override (e.g. BOR_APP_NAME)."""
from app.config import Settings
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
app_name="Brain of Testy"
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert body["app_name"] == "Brain of Testy"
assert body["version"] == "0.1.0"
assert body["docs_repo_configured"] is False
finally:
fastapi_app.dependency_overrides.clear()
def test_config_serves_ui_customization_overrides(client) -> None:
"""Phase 62: the three UI customization keys mirror Settings
overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` /
``BOR_THEME``) verbatim — the values the frontend brand layer
applies at boot, so this dict is the whole contract."""
from app.config import Settings
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
input_placeholder="Ask the vault…",
footer_text="Powered by my own models",
theme="indigo.css",
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert body["input_placeholder"] == "Ask the vault…"
assert body["footer_text"] == "Powered by my own models"
assert body["theme"] == "indigo.css"
finally:
fastapi_app.dependency_overrides.clear()
def test_config_docs_flag_tracks_settings(client) -> None:
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors
``settings.docs_configured`` — a real bool (never a truthy string)
that flips true the moment BOR_DOCS_REPO is non-empty: that flag is
the entire frontend gating of the "Save as doc" button."""
from app.config import Settings
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
app_name="Brain of Testy",
docs_repo="/srv/docs-repo",
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert isinstance(body["docs_repo_configured"], bool)
assert body["docs_repo_configured"] is True
finally:
fastapi_app.dependency_overrides.clear()
def test_suggestions_returns_list(client) -> None:
# Phase 79: the chips are user-gated — sign in as the admin first
# (the test's purpose is the list shape, not the auth contract).
# Phase 80: the chips are the last 3 questions asked OR the seed —
# the per-state exact lists are pinned in test_suggestions_api.py;
# here the DB-free shape pin holds in EVERY state: a list of
# non-blank strings (1–3 chips once questions exist, the seed
# while none do).
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.get("/api/suggestions")
assert r.status_code == 200
suggestions = r.json()["suggestions"]
assert isinstance(suggestions, list)
assert all(isinstance(s, str) and s.strip() for s in suggestions)
def test_suggestions_honors_bor_suggestions_env_override(
monkeypatch, db: Session
) -> None:
"""GET /api/suggestions reflects the BOR_SUGGESTIONS JSON env
override — as the SEED (phase 80): it appears while ZERO questions
have been saved, so the pin needs an empty ``saved_chats`` (the
full state matrix is test_suggestions_api.py)."""
from fastapi.testclient import TestClient
from app.main import create_app
override = [
"How do I back up with Borg?",
"How is my K3S cluster set up?",
"How do I deploy a service?",
"What proxy fronts reeseapps.com?",
]
db.execute(text("TRUNCATE saved_chats"))
db.commit()
get_settings.cache_clear()
try:
monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(override))
fresh_client = TestClient(create_app())
finally:
get_settings.cache_clear()
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# Phase 79: sign the fresh client in as the admin (the chips are
# user-gated; the override's value is what this test pins).
assert fresh_client.post(
"/api/login", json={"password": ADMIN_PASSWORD}
).status_code == 204
r = fresh_client.get("/api/suggestions")
assert r.status_code == 200
assert r.json() == {"suggestions": override}
@pytest.mark.parametrize(
("path", "marker"),
[
("/", "Brain of Reese"),
# Phase 76 (task 02): /sources.html + /git-sources.html are SHELL
# routes — the body is the shell (index.html) whose static
# <title> is "Brain of Reese" (the per-view title is set
# CLIENT-side by the router, invisible to httpx). The marker
# asserts the shell body (the view section is inside it) instead
# of the old page's title.
("/sources.html", 'id="view-rag"'), # phase 76: shell route (was "Knowledge base")
("/document.html", "Brain of Reese"), # phase 10: viewer page
("/login.html", "Sign in"), # phase 16: admin sign-in page
# Phase 76 (task 01): /tuning.html is a SHELL route — same
# shell-body marker pattern as the two task-02 paths above.
("/tuning.html", 'id="view-tuning"'), # phase 76: shell route (was "Global Tuning")
("/git-sources.html", 'id="view-git-sources"'), # phase 76: shell route (was "Git sources")
# Phase 76 (task 03): /history.html is a SHELL route too — the
# shell-body marker (the History view section is inside the
# shell; the old standalone page's title is client-side now).
("/history.html", 'id="view-history"'), # phase 76: shell route (was "Saved chats")
# Phase 79 (task 06): /tokens.html is a SHELL route too — the
# shell-body marker (the Tokens view section is inside the
# shell; the per-view title is client-side now).
("/tokens.html", 'id="view-tokens"'), # phase 79: shell route
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
],
)
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
"""No-CDN check (PLAN §7.3, re-verified on BOTH pages in phase 07 and
on the viewer page in phase 10): each page is served by FastAPI and
references only same-origin assets (no https:// script/link tags)."""
r = client.get(path)
assert r.status_code == 200
assert marker in r.text
assert 'src="https://' not in r.text
assert 'href="https://' not in r.text
# Phase 33 (cache busting): the five HTML pages revalidate (no-cache) with
# ?v=<token> asset refs; /assets/* is immutable for a year; /api/* is
# untouched. The token itself is unit-tested in tests/unit/test_caching.py.
def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
"""GET / — always revalidated, and the stylesheet reference carries
the process version token (non-empty, matching asset_version())."""
from app.core.caching import asset_version
token = asset_version()
assert token # non-empty in every supported environment
r = client.get("/")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert f'href="/assets/styles.css?v={token}"' in r.text
# The unversioned reference is gone from the served body.
assert 'href="/assets/styles.css">' not in r.text
@pytest.mark.parametrize(
"path",
["/sources.html", "/document.html", "/login.html", "/tuning.html",
"/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03)
"/tokens.html", # phase 79 task 06: + Tokens (shell route)
"/shared.html"], # phase 51: + the anonymous shared page
)
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
"""Each of the other four pages revalidates and carries at least one
versioned asset reference."""
from app.core.caching import asset_version
r = client.get(path)
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert f"?v={asset_version()}" in r.text
@pytest.mark.parametrize(
("path", "view_id", "old_title"),
[
("/tuning.html", 'id="view-tuning"', "Global Tuning · Brain of Reese"),
("/sources.html", 'id="view-rag"', "Sources · Brain of Reese"),
("/git-sources.html", 'id="view-git-sources"', "Git sources · Brain of Reese"),
("/history.html", 'id="view-history"', "Saved chats · Brain of Reese"), # phase 76 task 03
# phase 79 task 06: the sixth view — there was never a
# standalone tokens.html, so "old_title" is the router's
# client-side title: the pin asserts the shell never carries
# the per-view title statically (the router writes it).
("/tokens.html", 'id="view-tokens"', "Access tokens · Brain of Reese"), # phase 79 task 06
],
)
def test_shell_routes_serve_the_shell_no_cache_versioned(
client, path: str, view_id: str, old_title: str
) -> None:
"""Phase 76 (task 01: /tuning.html; task 02: /sources.html +
/git-sources.html; task 03: /history.html — all four non-chat
navbar views): every shell route serves the shell
(``frontend/index.html``), so the phase-33 page contract applies to
it exactly as to a static page: 200, text/html, ``Cache-Control:
no-cache``, ``?v=<token>`` asset refs, no validators (the
middleware wraps the whole app and lists the path in ``HTML_PAGES``
— unchanged). The body IS the shell: the view section is inside it,
the old standalone page's title is gone. The static catch-all stays
intact: an unknown path still 404s. (The conditional-GET
revalidation contract for these paths is pinned by
``tests/integration/test_caching_revalidation.py``.)"""
from app.core.caching import asset_version
r = client.get(path)
assert r.status_code == 200
assert r.headers["content-type"].split(";", 1)[0] == "text/html"
assert r.headers["cache-control"] == "no-cache"
assert "etag" not in r.headers
assert "last-modified" not in r.headers
assert f"?v={asset_version()}" in r.text
# The body is the SHELL: the chat view AND the route's view section
# are inside it…
assert 'id="view-chat"' in r.text
assert view_id in r.text
# …with the shell's static title (the per-view title is the
# router's client-side job), and the old page's own <title> is gone.
assert "<title>Brain of Reese</title>" in r.text
assert f"{old_title}</title>" not in r.text
# The catch-all is intact: an unknown path still 404s.
assert client.get("/nonexistent.html").status_code == 404
def test_index_html_variant_no_cache_versioned(client) -> None:
"""/index.html is the same page as / — same caching treatment."""
from app.core.caching import asset_version
r = client.get("/index.html")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert f"?v={asset_version()}" in r.text
def test_assets_served_immutable_for_a_year(client) -> None:
r = client.get("/assets/styles.css")
assert r.status_code == 200
cc = r.headers["cache-control"]
assert "public" in cc
assert "max-age=31536000" in cc
assert "immutable" in cc
# The asset body is untouched (header-only middleware).
assert client.get("/assets/app.js?v=whichever").status_code == 200
def test_api_health_gets_no_cache_control_injected(client) -> None:
"""Baseline (pre-middleware) behavior for /api/*: FastAPI's JSON
responses ship no Cache-Control header — the middleware must not
inject one."""
r = client.get("/api/health")
assert r.status_code == 200
assert "cache-control" not in r.headers
def test_styles_and_js_served(client) -> None:
assert client.get("/assets/styles.css").status_code == 200
assert client.get("/assets/app.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/document.js").status_code == 200 # phase 10: viewer page
assert client.get("/assets/login.js").status_code == 200 # phase 16: login page
assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module
assert client.get("/assets/tuning.js").status_code == 200 # phase 27: tuning page
assert client.get("/assets/git-sources.js").status_code == 200 # phase 35: git sources page
assert client.get("/assets/shared.js").status_code == 200 # phase 51: shared page module
# Emoji code points banned from UI chrome (phase 08): the pictograph
# blocks, VS-16/ZWJ, plus the exact glyphs the old light theme used
# (🧠 🧑 👋 📂 ⚠).
_EMOJI_GLYPHS = "\U0001F9E0\U0001F9D1\U0001F44B\U0001F4C2\u26A0"
def _find_emoji(text: str) -> list[str]:
hits: list[str] = []
for ch in text:
cp = ord(ch)
if (
0x1F300 <= cp <= 0x1FAFF
or 0x2600 <= cp <= 0x27BF
or 0x2B00 <= cp <= 0x2BFF
or cp in (0xFE0F, 0x200D)
or ch in _EMOJI_GLYPHS
):
hits.append(ch)
return hits
@pytest.mark.parametrize(
"path",
[
"/",
"/sources.html",
"/document.html",
"/login.html", # phase 16
"/tuning.html", # phase 27
"/git-sources.html", # phase 35
"/assets/app.js",
"/assets/sources.js",
"/assets/markdown.js",
"/assets/document.js",
"/assets/login.js", # phase 16
"/assets/document-modal.js", # phase 26: the document modal module
"/assets/git-sources.js", # phase 35: the git sources page module
"/shared.html", # phase 51: the anonymous shared page
"/assets/shared.js", # phase 51: the shared page module
"/assets/styles.css",
],
)
def test_ui_chrome_has_no_emoji(client, path: str) -> None:
"""Permanent regression guard (phase 08): the UI chrome — all pages,
the JS that renders it, and the stylesheet — is emoji-free.
Phase 37 revision (owner permission 2026-08-26, PLAN §4): the agent's
``.tool-call`` line carries the CONTENT marks — 🔎 (list) and 📄
(read) — the only emoji in the whole frontend, and only as the exact
tool-line template strings in app.js. Phase 68 revision: the search
tool line (the ``grep`` tool, phase 70) adds the third template
literal ("🔎 Searching for "). Phase 70 revision: the scoped ``ls``
tool line adds the fourth ("🔎 Listing documents in "). The guard strips
precisely those four literals; any other emoji, or those marks
anywhere else, still fails."""
r = client.get(path)
assert r.status_code == 200
text = r.text
if path in ("/assets/app.js", "/assets/shared.js"):
text = text.replace('"🔎 Listing documents"', "")
text = text.replace('"📄 Reading "', "")
text = text.replace('"🔎 Searching for "', "")
text = text.replace('"🔎 Listing documents in "', "")
assert _find_emoji(text) == [], f"emoji found in {path}: {_find_emoji(text)!r}"
def test_chat_requires_message(client) -> None:
"""The empty-message 422 validation pin (phase 79: the anonymous
caller now 401s BEFORE validation — sign in as the admin so this
test keeps testing validation, not the auth contract)."""
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.post("/api/chat", json={"message": ""})
assert r.status_code == 422