phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report: **Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)** - Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired. - No defects found; zero changes made — working tree left exactly as the task executors left it. - Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation. - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. - Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules. - Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted. - Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
@@ -9,6 +9,14 @@ 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``.
|
||||
|
||||
Phase 91 (task 01) note: the ``/api/config`` pins are now DB-backed —
|
||||
the three UI strings are the EFFECTIVE values (the ``ui_settings`` row
|
||||
over the env values, B1), resolved through a short-lived session, so
|
||||
the pins take the ``db`` fixture (skip when the stack is down) and
|
||||
start from an empty ``ui_settings`` table (the env-only-deployment
|
||||
state; the DB-over-env behaviour itself is pinned in
|
||||
test_ui_settings_api.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,6 +30,14 @@ from app.config import get_settings
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
|
||||
def _clear_ui_settings(db: Session) -> None:
|
||||
"""The env-only-deployment state for the /api/config pins: no
|
||||
ui_settings row, so the effective strings are the env values
|
||||
(phase 91, task 01)."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_health_reports_ok(client) -> None:
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
@@ -31,34 +47,41 @@ def test_health_reports_ok(client) -> None:
|
||||
assert body["version"]
|
||||
|
||||
|
||||
def test_config_returns_default_app_metadata(client) -> None:
|
||||
"""GET /api/config is public (anonymous) and returns exactly six
|
||||
def test_config_returns_default_app_metadata(client, db: Session) -> None:
|
||||
"""GET /api/config is public (anonymous) and returns exactly five
|
||||
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)."""
|
||||
footer line). Phase 91: with an empty ui_settings table the
|
||||
effective strings are the env defaults (B1 — DB-over-env, the row
|
||||
absent here); the retired CSS-file theming's ``theme`` key is gone
|
||||
(task 03 — the five keys are the entire contract)."""
|
||||
_clear_ui_settings(db)
|
||||
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",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
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.
|
||||
# byte-identical contract).
|
||||
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)."""
|
||||
def test_config_follows_overridden_app_name(client, db: Session) -> None:
|
||||
"""GET /api/config reflects a Settings override (e.g. BOR_APP_NAME).
|
||||
Phase 91: the override is the ENV side of the DB-over-env resolver —
|
||||
with an empty ui_settings row the effective app_name is the
|
||||
overridden env value."""
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
_clear_ui_settings(db)
|
||||
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
|
||||
app_name="Brain of Testy"
|
||||
)
|
||||
@@ -68,7 +91,7 @@ def test_config_follows_overridden_app_name(client) -> None:
|
||||
body = r.json()
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
assert body["app_name"] == "Brain of Testy"
|
||||
assert body["version"] == "0.1.0"
|
||||
@@ -77,18 +100,21 @@ def test_config_follows_overridden_app_name(client) -> None:
|
||||
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."""
|
||||
def test_config_serves_ui_customization_overrides(client, db: Session) -> None:
|
||||
"""Phase 62: the UI customization string keys mirror Settings
|
||||
overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``) — the
|
||||
values the frontend brand layer applies at boot, so this dict is
|
||||
the whole contract. Phase 91: placeholder + footer are the
|
||||
EFFECTIVE strings — the env overrides win with an empty
|
||||
ui_settings row (B1); the retired theming's ``theme`` key is gone
|
||||
(task 03)."""
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
_clear_ui_settings(db)
|
||||
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")
|
||||
@@ -96,16 +122,15 @@ def test_config_serves_ui_customization_overrides(client) -> None:
|
||||
body = r.json()
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
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:
|
||||
def test_config_docs_flag_tracks_settings(client, db: Session) -> 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
|
||||
@@ -113,6 +138,7 @@ def test_config_docs_flag_tracks_settings(client) -> None:
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
_clear_ui_settings(db)
|
||||
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
|
||||
app_name="Brain of Testy",
|
||||
docs_repo="/srv/docs-repo",
|
||||
@@ -206,6 +232,10 @@ def test_suggestions_honors_bor_suggestions_env_override(
|
||||
# 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
|
||||
# Phase 91 (task 04): /theme.html is a SHELL route too — the
|
||||
# shell-body marker (the Theme view section is inside the
|
||||
# shell; the per-view title is client-side now).
|
||||
("/theme.html", 'id="view-theme"'), # phase 91: shell route
|
||||
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
|
||||
],
|
||||
)
|
||||
@@ -246,6 +276,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
|
||||
["/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)
|
||||
"/theme.html", # phase 91 task 04: + Theme (shell route)
|
||||
"/shared.html"], # phase 51: + the anonymous shared page
|
||||
)
|
||||
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
@@ -271,6 +302,11 @@ def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
# 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
|
||||
# phase 91 task 04: the seventh view — there was never a
|
||||
# standalone theme.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).
|
||||
("/theme.html", 'id="view-theme"', "Theme · Brain of Reese"), # phase 91 task 04
|
||||
],
|
||||
)
|
||||
def test_shell_routes_serve_the_shell_no_cache_versioned(
|
||||
|
||||
@@ -87,6 +87,7 @@ SHELL_BACKED_PAGES = {
|
||||
"/git-sources.html": "index.html", # phase 76 task 02
|
||||
"/history.html": "index.html", # phase 76 task 03
|
||||
"/tokens.html": "index.html", # phase 79 task 06
|
||||
"/theme.html": "index.html", # phase 91 task 04
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Integration: migration 0014 (ui_settings) schema contract.
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0012.py`` (information_schema assertions on the state
|
||||
the migration must leave). The tests target revision ``0014``
|
||||
explicitly so later migrations cannot break them:
|
||||
|
||||
* upgrade 0013 → 0014 → the ``ui_settings`` table exists with the full
|
||||
column contract (``id`` INTEGER PK; the 3 strings VARCHAR(300) NULL;
|
||||
the 8 identity colors VARCHAR(7) NULL — NULL = default, B1); no
|
||||
server defaults anywhere (a missing row means "defaults");
|
||||
* an inserted id-1 row round-trips its values (the PUT upsert's shape);
|
||||
* downgrade to 0013 → the table is gone (A13 — reversible), the rest of
|
||||
the schema (e.g. ``api_tokens.token_hash``) survives;
|
||||
* upgrade back to 0014 → the table is back (round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import db_available
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
||||
to head no matter what happened, so the dev DB is never left below
|
||||
head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _table_exists(db: Session, table: str) -> bool:
|
||||
count: Any = db.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM information_schema.tables"
|
||||
" WHERE table_schema = 'public' AND table_name = :t"
|
||||
),
|
||||
{"t": table},
|
||||
).scalar()
|
||||
assert count is not None, "information_schema count must be an int"
|
||||
return int(count) == 1
|
||||
|
||||
|
||||
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default, character_maximum_length)
|
||||
for one table column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default, character_maximum_length"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _insert_row(db: Session, *, app_name: str | None, brand: str | None) -> None:
|
||||
"""Insert the single row (the PUT upsert's shape) with two values set
|
||||
and the rest NULL — the NULL = default state the resolver merges."""
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO ui_settings (id, app_name, brand) VALUES (1, :n, :b)"
|
||||
),
|
||||
{"n": app_name, "b": brand},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _delete_row(db: Session) -> None:
|
||||
db.execute(text("DELETE FROM ui_settings WHERE id = 1"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0014_adds_ui_settings(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0013 → 0014: the table exists with the full column
|
||||
contract (the Integer PK, the 3 strings VARCHAR(300) NULL, the 8
|
||||
colors VARCHAR(7) NULL — no server defaults anywhere: a missing row
|
||||
means "defaults"); the table is absent at 0013."""
|
||||
command.downgrade(alembic, "0013") # start from the pre-0014 state
|
||||
assert _version(db) == "0013"
|
||||
assert not _table_exists(db, "ui_settings"), "ui_settings must be absent at 0013"
|
||||
|
||||
command.upgrade(alembic, "0014")
|
||||
assert _version(db) == "0014", "alembic_version must be at 0014"
|
||||
assert _table_exists(db, "ui_settings"), "ui_settings must exist at 0014"
|
||||
|
||||
id_col = _column(db, "ui_settings", "id")
|
||||
assert id_col is not None, "ui_settings.id is missing"
|
||||
assert id_col[0] == "integer", "ui_settings.id must be INTEGER"
|
||||
assert id_col[1] == "NO", "ui_settings.id must be NOT NULL (PK)"
|
||||
|
||||
for name in ("app_name", "input_placeholder", "footer_text"):
|
||||
col = _column(db, "ui_settings", name)
|
||||
assert col is not None, f"ui_settings.{name} is missing"
|
||||
assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
|
||||
assert col[1] == "YES", f"ui_settings.{name} must be NULL (env default, B1)"
|
||||
assert col[2] is None, f"ui_settings.{name} must have no server default"
|
||||
assert col[3] == 300, f"ui_settings.{name} must be String(300)"
|
||||
|
||||
for name in ("bg", "surface", "ink", "ink_soft", "line",
|
||||
"brand", "brand_soft", "brand_ink"):
|
||||
col = _column(db, "ui_settings", name)
|
||||
assert col is not None, f"ui_settings.{name} is missing"
|
||||
assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
|
||||
assert col[1] == "YES", f"ui_settings.{name} must be NULL (the built-in, B1)"
|
||||
assert col[2] is None, f"ui_settings.{name} must have no server default"
|
||||
assert col[3] == 7, f"ui_settings.{name} must be String(7) — #rrggbb"
|
||||
|
||||
|
||||
def test_inserted_id_1_row_round_trips_values(db: Session, alembic: Config) -> None:
|
||||
"""At 0014, the single row (id 1, the PUT upsert's shape) round-trips
|
||||
its set values verbatim and keeps the unset columns NULL."""
|
||||
command.upgrade(alembic, "head")
|
||||
_insert_row(db, app_name="Brain of Testy", brand="#818cf8")
|
||||
try:
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT id, app_name, input_placeholder, footer_text, brand"
|
||||
" FROM ui_settings WHERE id = 1"
|
||||
)
|
||||
).fetchone()
|
||||
assert row is not None, "the ui_settings row must exist"
|
||||
assert row[0] == 1, "the single row is always id 1"
|
||||
assert row[1] == "Brain of Testy", "app_name must round-trip verbatim"
|
||||
assert row[2] is None, "input_placeholder must stay NULL (the default)"
|
||||
assert row[3] is None, "footer_text must stay NULL (the default)"
|
||||
assert row[4] == "#818cf8", "brand must round-trip verbatim"
|
||||
finally:
|
||||
_delete_row(db)
|
||||
|
||||
|
||||
def test_downgrade_to_0013_drops_the_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0013: the table is gone (A13 — reversible) while the
|
||||
rest of the schema survives."""
|
||||
command.downgrade(alembic, "0013")
|
||||
assert _version(db) == "0013"
|
||||
assert not _table_exists(db, "ui_settings"), "ui_settings must be dropped"
|
||||
|
||||
token_col = _column(db, "api_tokens", "token_hash")
|
||||
assert token_col is not None and token_col[0] == "character varying", (
|
||||
"api_tokens.token_hash must survive the downgrade"
|
||||
)
|
||||
ignore_col = _column(db, "git_sources", "ignore_paths")
|
||||
assert ignore_col is not None and ignore_col[0] == "jsonb", (
|
||||
"git_sources.ignore_paths must survive the downgrade"
|
||||
)
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0013, then upgrade back to 0014: the table is back
|
||||
with the column contract intact."""
|
||||
command.downgrade(alembic, "0013")
|
||||
command.upgrade(alembic, "0014")
|
||||
assert _version(db) == "0014", "round-trip upgrade must land at 0014"
|
||||
|
||||
assert _table_exists(db, "ui_settings"), "ui_settings must be back"
|
||||
|
||||
id_col = _column(db, "ui_settings", "id")
|
||||
assert id_col is not None and id_col[0] == "integer", (
|
||||
"id must be INTEGER after the round-trip"
|
||||
)
|
||||
brand = _column(db, "ui_settings", "brand")
|
||||
assert brand is not None and brand[1] == "YES", (
|
||||
"brand must be VARCHAR NULL after the round-trip"
|
||||
)
|
||||
assert brand[3] == 7, "brand must be String(7) after the round-trip"
|
||||
@@ -30,8 +30,12 @@ from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import theming
|
||||
from app.core.security_headers import CSP
|
||||
from app.models import UiSettings
|
||||
|
||||
#: The exact owner-approved A1 policy string (phase 82). The constant is
|
||||
#: the single source of truth; the unit suite additionally pins that the
|
||||
@@ -54,9 +58,14 @@ def _assert_security_headers(response: httpx.Response) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_page_carries_all_three_headers(client: TestClient) -> None:
|
||||
def test_page_carries_all_three_headers(client: TestClient, db: Session) -> None:
|
||||
"""``GET /`` (the shell page) — 200 + all three headers, CSP exactly
|
||||
the A1 string."""
|
||||
the A1 string. The ``ui_settings`` row is cleared first (phase 91,
|
||||
task 05: a themed page carries the A1 string EXTENDED with the
|
||||
style-src hash — the plain-A1 pin is the UNTHAMED page's
|
||||
contract, and the dev database must not leak a theme into it)."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
_assert_security_headers(response)
|
||||
@@ -102,3 +111,44 @@ def test_caching_rewrite_still_runs_under_headers_middleware(client: TestClient)
|
||||
"the ?v=<token> asset rewrite no longer runs — the outermost "
|
||||
"security-header middleware altered or swallowed the body"
|
||||
)
|
||||
|
||||
|
||||
def test_themed_page_carries_a1_plus_style_src_theme_hash(
|
||||
client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Phase 91 (task 05, defect fix): the A1 CSP would BLOCK the
|
||||
inline ``<style id="bor-theme">`` pre-paint tag in every real
|
||||
browser (``style-src`` falls back to ``default-src 'self'``) — so a
|
||||
THemed HTML page carries the A1 string EXTENDED with
|
||||
``style-src 'self' 'sha256-<hash>'``, the CSP3 hash of the exact
|
||||
tag content: the current theme is the only inline style ever
|
||||
permitted, no blanket ``'unsafe-inline'``, a different palette is
|
||||
still blocked. The unthemed page keeps the plain A1 string (no
|
||||
exemption for a tag that is not served). Pinned against the real
|
||||
app (the unit suite pins the two middleware halves in isolation).
|
||||
"""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
try:
|
||||
db.add(UiSettings(id=1, brand="#818cf8"))
|
||||
db.commit()
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["brand"] = "#818cf8"
|
||||
tag = theming.theme_style_tag(colors)
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert tag in response.text # the themed page serves the tag
|
||||
assert response.headers["content-security-policy"] == (
|
||||
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
|
||||
)
|
||||
assert "unsafe-inline" not in response.headers["content-security-policy"]
|
||||
# The other two phase-82 headers ride along, unchanged.
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
finally:
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
# The UNthemed page after the row is gone: plain A1, no tag.
|
||||
response = client.get("/")
|
||||
assert response.headers["content-security-policy"] == CSP
|
||||
assert "bor-theme" not in response.text
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Integration: the admin UI-settings gate + the /api/config effective
|
||||
strings (phase 91, task 01).
|
||||
|
||||
The auth + public-contract half of task 01, driven through the real app
|
||||
(TestClient keeps the cookie jar — the house ``test_auth_api`` /
|
||||
``test_tokens_api`` admin-login pattern):
|
||||
|
||||
* the admin gate — ``GET /api/ui-settings`` and ``PUT`` are 403
|
||||
``admin only`` for anonymous callers AND for a signed-in token USER
|
||||
(role ``"user"`` — the router-wide ``require_admin`` closes the
|
||||
surface on every method, the phase-79 token matrix contract), 200 for
|
||||
the admin on both;
|
||||
* ``/api/config`` effective strings (B1: DB-over-env) — an env-only
|
||||
deployment (no ``ui_settings`` row) returns the env strings; after an
|
||||
admin PUT, the ANONYMOUS ``/api/config`` returns the DB strings;
|
||||
* the five-key /api/config contract: the retired CSS-file theming's
|
||||
``theme`` key is GONE (task 03) — the app metadata, the docs flag,
|
||||
and the two effective strings are the entire response.
|
||||
|
||||
Real Postgres (``podman compose up -d db``); no LLM involved.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core import theming
|
||||
from app.main import app as fastapi_app
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_state(db: Session) -> Iterator[None]:
|
||||
"""Both touched tables are global state: the single ui_settings row
|
||||
and the api_tokens the token-user test creates (the house
|
||||
TRUNCATE/DELETE reset pattern)."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.execute(text("TRUNCATE api_tokens"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.execute(text("TRUNCATE api_tokens"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _admin_client() -> TestClient:
|
||||
"""A fresh client signed in as the admin (the ``_admin_client``
|
||||
pattern from test_auth_api.py)."""
|
||||
c = TestClient(fastapi_app)
|
||||
r = c.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
||||
return c
|
||||
|
||||
|
||||
def test_anonymous_get_and_put_403(client: TestClient) -> None:
|
||||
"""Router-level ``require_admin``: both routes are 403 ``admin only``
|
||||
for the unsigned-in caller (one fixed detail — no enumeration)."""
|
||||
r = client.get("/api/ui-settings")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
r = client.put("/api/ui-settings", json={"app_name": "nope"})
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
def test_token_user_get_and_put_403(client: TestClient) -> None:
|
||||
"""A signed-in token USER (role ``"user"``) is NOT the admin: the
|
||||
Theme tab's surface is closed to them on both methods (the
|
||||
phase-79 token matrix contract — only the admin themes the
|
||||
deployment)."""
|
||||
admin = _admin_client()
|
||||
r = admin.post("/api/tokens", json={"label": "alice"})
|
||||
assert r.status_code == 201, r.text
|
||||
token = r.json()["token"]
|
||||
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
|
||||
assert client.get("/api/whoami").json() == {"authenticated": True, "role": "user"}
|
||||
|
||||
r = client.get("/api/ui-settings")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
r = client.put("/api/ui-settings", json={"brand": "#123456"})
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
|
||||
"""The admin passes the gate on both methods: GET reports the
|
||||
effective defaults (row missing), PUT persists + reports the new
|
||||
effective values, and a follow-up GET reads them back."""
|
||||
client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
|
||||
r = client.get("/api/ui-settings")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
|
||||
assert body["app_name"] == get_settings().app_name
|
||||
assert {k: body[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
|
||||
|
||||
r = client.put(
|
||||
"/api/ui-settings",
|
||||
json={"app_name": "Reese Brain", "brand": "#818cf8"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["app_name"] == "Reese Brain"
|
||||
assert r.json()["brand"] == "#818cf8"
|
||||
|
||||
r = client.get("/api/ui-settings")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["app_name"] == "Reese Brain"
|
||||
assert r.json()["brand"] == "#818cf8"
|
||||
# Untouched fields stay at their defaults (DB-over-env / -built-in).
|
||||
assert r.json()["footer_text"] == get_settings().footer_text
|
||||
assert r.json()["bg"] == theming.BUILTIN_COLORS["bg"]
|
||||
|
||||
|
||||
def _config_keys() -> set[str]:
|
||||
"""The /api/config key set after task 03: the five phase-39/59/62
|
||||
keys — the retired CSS-file theming's ``theme`` key is gone."""
|
||||
return {"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text"}
|
||||
|
||||
|
||||
def test_api_config_env_only_deployment_returns_env_strings(client: TestClient) -> None:
|
||||
"""B1 with an empty ui_settings table: /api/config serves the ENV
|
||||
strings (the code defaults — conftest pins them) and the key set is
|
||||
the five-key contract (the retired theming's ``theme`` key is gone
|
||||
— task 03)."""
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == _config_keys()
|
||||
assert body["app_name"] == get_settings().app_name
|
||||
assert body["input_placeholder"] == get_settings().input_placeholder
|
||||
assert body["footer_text"] == get_settings().footer_text
|
||||
|
||||
|
||||
def test_api_config_carries_no_theme_key(client: TestClient) -> None:
|
||||
"""Phase 91 (task 03): the retired CSS-file theming left NO trace
|
||||
in the endpoint — the response has no ``theme`` key at all (an
|
||||
env-only deployment and a themed one answer with the same keys; the
|
||||
colors are injected pre-paint, they never ride this fetch)."""
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
assert "theme" not in r.json()
|
||||
|
||||
|
||||
def test_api_config_returns_db_strings_after_admin_put(
|
||||
client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""B1 with a set row: after an admin PUT, the ANONYMOUS /api/config
|
||||
(the frontend's boot fetch — no admin needed) serves the DB strings
|
||||
over the env values; the untouched fields keep the env values; the
|
||||
five-key set is unchanged (the retired ``theme`` key is absent)."""
|
||||
admin = _admin_client()
|
||||
r = admin.put(
|
||||
"/api/ui-settings",
|
||||
json={
|
||||
"app_name": "Brain of Testy",
|
||||
"input_placeholder": "Ask the vault…",
|
||||
"footer_text": "Powered by my own models",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200 # /api/config stays PUBLIC (no gate)
|
||||
body = r.json()
|
||||
assert set(body) == _config_keys()
|
||||
assert body["app_name"] == "Brain of Testy"
|
||||
assert body["input_placeholder"] == "Ask the vault…"
|
||||
assert body["footer_text"] == "Powered by my own models"
|
||||
# The colors never ride /api/config (the pre-paint injection is
|
||||
# task 02; brand.js's surface is the five keys).
|
||||
assert "theme" not in body
|
||||
assert body["version"] == get_settings().app_version
|
||||
Reference in New Issue
Block a user