phase: 91_admin_theme_tab
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s

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:
2026-09-09 17:22:24 -04:00
parent 3095c4c577
commit d22d260b8b
74 changed files with 4448 additions and 675 deletions
+54 -18
View File
@@ -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
}
+193
View File
@@ -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"
+52 -2
View File
@@ -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
+182
View File
@@ -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