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
+4 -3
View File
@@ -40,12 +40,13 @@ os.environ["BOR_SUGGESTIONS"] = json.dumps(_Settings.model_fields["suggestions"]
# Phase 62: the same leak class for the new UI customization settings —
# an operator's local ``.env`` may legitimately carry
# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``, and
# the default-metadata pins must see the code defaults (derived from
# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``, and the
# default-metadata pins must see the code defaults (derived from
# the class fields, same pattern as the suggestions line above).
# (Phase 91, task 03: the retired CSS-file theme env var no longer
# exists — nothing to pin.)
os.environ["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
os.environ["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
os.environ["BOR_THEME"] = _Settings.model_fields["theme"].default
from app.db import SessionLocal, db_available # noqa: E402
from app.main import app as fastapi_app # noqa: E402
+5 -6
View File
@@ -123,14 +123,13 @@ def app_server(mock_llm: int) -> Iterator[str]:
)
# Phase 62: the same leak class for the new UI customization
# settings — an operator's local (gitignored) ``.env`` may
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``
# / ``BOR_THEME``, and the byte-identical default contract (task
# 05's ``test_default_server_is_byte_identical``) must see the code
# defaults (derived from the class fields, never drifts from
# ``app/config.py``).
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` /
# ``BOR_FOOTER_TEXT``, and the byte-identical default contract
# must see the code defaults (derived from the class fields, never
# drifts from ``app/config.py``). (Phase 91, task 03: the retired
# CSS-file theme env var no longer exists — nothing to pin.)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_THEME"] = _Settings.model_fields["theme"].default
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
+733
View File
@@ -0,0 +1,733 @@
"""Phase 91 E2E (Playwright): the admin Theme tab — the pickers and
fields, the pre-paint theme, the admin gate, and the reset.
Source: ``TODO.md`` L4 — "Custom theming isn't really working. The
page loads red first and then the theme 'pops' into view, replacing
words and colors in an obvious way. Remove the custom css file
theming. Create a new admin tab that allows the user to change
everything the env var and custom css currently supports but with
buttons and color pickers. Theme should load immediately, not pop in
after the page load."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
admin sees the "Theme" nav link and the form (gate hidden); the
11 inputs show the effective defaults (the 3 template strings +
the 8 built-in hexes parsed out of ``styles.css``'s ``:root``
IN-TEST — the suite can never drift from the stylesheet); Save
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
held, then restored) and lands the role=status "Theme saved.";
the inputs re-populate to the saved values; the persisted row is
the one saved; and the saved non-AA palette lists its failing
pair in ``#theme-contrast`` without blocking the save
(warning-only).
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
should load immediately, not pop in": after a save, the RAW
served HTML of ``/`` carries exactly one ``<style
id="bor-theme">`` with all 8 vars = the saved hexes, placed
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
anonymous context — and the computed ``:root`` custom properties
equal the saved hexes at load (the inline tag precedes every
stylesheet application). The 3 strings stay on the brand.js boot
fetch (the B4 split: colors pre-paint, strings via the fetch).
3. ``test_anonymous_and_token_user_are_walled`` — the admin gate:
anonymous meets ``#theme-gate`` (sign-in link
``?next=/theme.html``) with ``#theme-content`` hidden and the
nav link hidden, and ``PUT /api/ui-settings`` 403s; a token user
(the phase-79 gate login) 403s the PUT too and never sees the
nav link (B5: admin-only, like Tuning/Tokens).
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
the role=status "Reset to the built-in theme.", re-populates the
11 defaults, serves NO theme tag, and the served bytes equal a
row-less deployment byte for byte (the no-op injection
contract).
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
``--ink`` set within 0.1 ratio of ``--bg`` lists the failing
pair(s) with the ratio in ``#theme-contrast`` (role=alert) as
soon as the picker moves; Save still succeeds (warning-only);
Reset restores the AA built-ins and hides the warning (the
suite's final state is clean).
DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
single row the caching middleware reads for EVERY served page — a
leftover themed row would repaint other suites' pages) and
``api_tokens`` rows across suites. An autouse fixture truncates
``ui_settings`` and deletes the ``e2e-``-labeled tokens before AND
after every test (never a TRUNCATE on ``api_tokens`` — the shared
DB may hold the owner's real tokens).
Per-module app env (the tuning/tokens/archive-upload pattern): the
module-scoped ``app_server`` override boots the same env block as
the shared conftest server with the branding vars pinned to the
CODE defaults (an operator's local ``.env`` may carry the owner's
name/placeholder/footer, and "the effective strings start at the
template defaults" must hold regardless — the phase-61/62
leak-guard pattern, extended to ``BOR_APP_NAME``) and
``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repo must
not render as env rows in this suite's app).
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Browser, BrowserContext, Page, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.core.theming import COLOR_FIELDS
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login, login_with_token
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The distinct E2E palette (task 06): a full non-built-in indigo set —
# every value differs from its built-in, so the tag is non-empty and
# every saved color is stored as-is (no built-in→NULL collapse).
PALETTE: dict[str, str] = {
"bg": "#0b1020",
"surface": "#111730",
"ink": "#e6e9f5",
"ink_soft": "#a8b0d0",
"line": "#232a4a",
"brand": "#4f46e5",
"brand_soft": "#1e2447",
"brand_ink": "#c7d2fe",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
#: The failing-pair leg (test 5): --ink set within 0.1 ratio of --bg
#: (the deterministic near-identical pick — 1.0:1 on both dark pairs).
FAILING_INK = "#101010"
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
#: own markup — the static E2E-stable-selectors house convention).
COLOR_INPUT_IDS: dict[str, str] = {
field: f"#theme-{field.replace('_', '-')}" for field in COLOR_FIELDS
}
# ---------------------------------------------------------------------------
# In-test constants (single sources of truth — never duplicated)
# ---------------------------------------------------------------------------
def _builtin_colors() -> dict[str, str]:
"""The 8 built-in identity hexes parsed OUT of
``frontend/assets/styles.css``'s ``:root`` in-test — the single
source of truth, so the suite can't drift from the stylesheet it
asserts on."""
css = (REPO / "frontend" / "assets" / "styles.css").read_text(encoding="utf-8")
root = re.search(r":root\s*\{([^}]*)\}", css, re.DOTALL)
assert root is not None, "styles.css must open with its :root block"
colors: dict[str, str] = {}
for name in COLOR_FIELDS:
match = re.search(
rf"--{name.replace('_', '-')}\s*:\s*(#[0-9a-fA-F]{{6}})", root.group(1)
)
assert match is not None, f"--{name} missing from styles.css :root"
colors[name] = match.group(1).lower()
return colors
def _template_defaults() -> dict[str, str]:
"""The 3 template strings from the CODE defaults (derived from
the class fields — never drifts from ``app/config.py``; the
module server pins the same values, so the effective strings
start exactly here)."""
return {
"app_name": Settings.model_fields["app_name"].default,
"input_placeholder": Settings.model_fields["input_placeholder"].default,
"footer_text": Settings.model_fields["footer_text"].default,
}
def _expected_tag(colors: dict[str, str]) -> str:
"""The EXACT inline tag ``theme_style_tag`` renders for
``colors``: one ``:root`` override, all 8 vars in COLOR_FIELDS
order, no whitespace (the byte the middleware injects)."""
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
# ---------------------------------------------------------------------------
# Per-module app env (the tuning/tokens/archive-upload pattern)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: the branding vars
are pinned to the CODE defaults (the effective strings start at
the template defaults regardless of an operator's local
``.env`` — the phase-61/62 leak-guard pattern the shared conftest
server applies to its two string vars; this one pins all three,
including ``BOR_APP_NAME``, which the shared server leaves to the
process) and ``BOR_GIT_SOURCES`` is forced empty (the dev
``.env``'s git repo must not render as env rows in this
suite's app)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated threshold (conftest pattern) — no chat turn is
# ever sent in this suite, but the app boots with the same shape.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
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
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
# The branding vars: "unset" = the template defaults (the code
# defaults, derived from the class fields — the local ``.env`` may
# carry the owner's values, and this suite's assertions need the
# TEMPLATE defaults, not the owner's).
env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default
env["BOR_INPUT_PLACEHOLDER"] = (
Settings.model_fields["input_placeholder"].default
)
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# DB isolation + helpers
# ---------------------------------------------------------------------------
def _clean_ui_state() -> None:
"""Fresh theme + token state per test: truncate the single-row
``ui_settings`` (the middleware reads it for EVERY page — a
leftover themed row would repaint other suites' pages) and
delete this suite's issued tokens (label-scoped on ``e2e-`` —
never a TRUNCATE: the shared DB may hold the owner's real
tokens)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
db.commit()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
_clean_ui_state()
yield
_clean_ui_state()
def _cookies(page: Page) -> dict[str, str]:
"""The session cookies the browser context holds (the test's API
side sees exactly what that browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
"""Intercept ``PUT /api/ui-settings`` and hold it for
``hold_s`` seconds (the archive-upload suite's §7.4 pattern):
while it is held, the page's fetch is guaranteed pending, so the
in-flight state (disabled buttons, the "Saving…" / "Resetting…"
labels) is observable deterministically — a localhost PUT
settles in milliseconds, so without the hold the window is a
race. GETs (the load + the save's refetch) pass straight
through."""
def handle(route: Route) -> None:
if route.request.method == "PUT":
time.sleep(hold_s)
route.continue_()
page.route("**/api/ui-settings", handle)
def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 11 inputs: the 3 text fields (``strings``, default
the E2E set) + the 8 color pickers (``palette``)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
page.fill("#theme-footer", text_values["footer_text"])
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
"""Assert all 11 inputs show the given effective values."""
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
for field in COLOR_FIELDS:
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
"""The RAW served HTML carries exactly one inline theme tag, with
all 8 vars = the given hexes, placed IMMEDIATELY before
``</head>`` (``inject_theme``'s exact placement: the tag ends
exactly where ``</head>`` begins and carries the injector's
single leading newline)."""
tag = _expected_tag(colors)
assert raw.count(tag) == 1, f"expected exactly one theme tag:\n{tag}"
start = raw.index(tag)
head = raw.index("</head>")
assert start + len(tag) == head, "the tag must end exactly where </head> begins"
assert raw[start - 1] == "\n", "the tag must carry the injector's leading newline"
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
"""The first-paint proof: all 8 computed ``:root`` custom
properties equal the given hexes. The inline tag precedes every
stylesheet application, so a themed deployment resolves them
from the first style pass — no red flash, no pop-in (custom
properties return the specified token, so the string compare is
stable — the ``.trim()`` rides out any token whitespace)."""
expected = {f"--{k.replace('_', '-')}": v for k, v in colors.items()}
page.wait_for_function(
"""(expected) => {
const cs = getComputedStyle(document.documentElement);
return Object.entries(expected).every(
([k, v]) => cs.getPropertyValue(k).trim() === v
);
}""",
arg=expected,
timeout=timeout,
)
# ---------------------------------------------------------------------------
# 1. The tab (admin): the form, the effective defaults, the §7.4 save
# ---------------------------------------------------------------------------
def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
# The admin header contract on this page: the ship-hidden "Theme"
# nav link is revealed (header.js, role === "admin") and marks
# the current page (the router's single-writer nav stamp).
expect(page.locator("#nav-theme")).to_be_visible(timeout=15_000)
expect(page.locator("#nav-theme")).to_have_attribute("aria-current", "page")
expect(page.locator("#sign-out-btn")).to_be_visible()
# The gate is hidden for the admin and the form is revealed
# (theme.js's whoami branch — the #git-sources-content pattern).
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The 11 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 8 built-in hexes parsed straight out of
# styles.css's :root (the resolver's missing-row branch).
_expect_form_values(page, defaults, builtin)
# Set a distinct palette + the 3 strings, then Save through the
# real form — the PUT held so the §7.4 in-flight state is
# observable deterministically.
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
page.click("#theme-save")
# In-flight: BOTH buttons disabled (one action at a time),
# the primary relabeled "Saving…" (never stale).
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-save")).to_have_text("Saving…")
expect(page.locator("#theme-reset")).to_be_disabled()
# Settled: the role=status confirmation + the restored
# lifecycle (re-enabled, original label).
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
expect(page.locator("#theme-save")).to_have_text("Save theme")
expect(page.locator("#theme-save")).to_be_enabled()
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# The inputs re-populate to the SAVED (effective) values (the
# save's refetch is the canonical state).
_expect_form_values(page, SAVED_STRINGS, PALETTE)
# The row landed in Postgres (the id-1 single row, all 11 values
# — every palette color differs from its built-in, so nothing
# collapsed to NULL).
with SessionLocal() as db:
row = db.get(UiSettings, 1)
assert row is not None, "the PUT must upsert the id-1 row"
assert row.app_name == APP_NAME
assert row.input_placeholder == PLACEHOLDER
assert row.footer_text == FOOTER
for field in COLOR_FIELDS:
assert getattr(row, field) == PALETTE[field]
# The saved palette fails ONE of the five pairs — --bg on
# --brand (the button-ink pair: 3.0:1 < 4.5:1) — and the
# warning lists it. Save was NEVER blocked (the warning-only
# contract: the owner's homelab palette; the built-in stays AA).
contrast = page.locator("#theme-contrast")
expect(contrast).to_have_attribute("role", "alert")
expect(contrast).to_be_visible()
expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1")
# ---------------------------------------------------------------------------
# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML
# + the computed palette at load (the no-pop-in proof), the B4
# strings via the boot fetch
# ---------------------------------------------------------------------------
def test_saved_theme_is_pre_paint_for_everyone(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# The admin saves the theme (the API seed — test 1 owns the UI
# save path).
_seed_theme_via_api(app_url, _cookies(page))
# The RAW served HTML (httpx — no JS at all, the server's own
# bytes): exactly one inline theme tag, all 8 vars = the saved
# hexes, immediately before </head> (the pre-paint mechanism the
# middleware unit tests pin — this is its observable
# consequence).
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
_assert_raw_tag(r.text, PALETTE)
# The phase-91 CSP extension: the inline tag is permitted in a
# real browser only via the strict sha256 source expression
# (style-src 'self' 'sha256-…' appended to the A1 string — no
# 'unsafe-inline').
csp = r.headers.get("content-security-policy", "")
assert "style-src 'self' 'sha256-" in csp, csp
# The admin's browser: the same tag in the served document, and
# the computed custom properties equal the saved hexes at load
# (the inline tag precedes every stylesheet application — the
# first paint IS the themed paint).
page.goto(app_url + "/")
_assert_raw_tag(page.content(), PALETTE)
_wait_theme_computed(page, PALETTE)
# A FRESH anonymous context (no auth anywhere): the same inline
# tag + computed values — the theme is for EVERYONE, not just
# the admin who set it.
anon_ctx: BrowserContext | None = None
try:
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + "/")
_assert_raw_tag(anon.content(), PALETTE)
_wait_theme_computed(anon, PALETTE)
# The B4 split: the 3 strings are NOT pre-paint — they apply
# post-fetch via the /api/config boot fetch (brand.js) on the
# anonymous page too: the name (header brand + window
# global), the placeholder, and the footer line.
expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000)
assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME
expect(anon.locator("#message-input")).to_have_attribute(
"placeholder", PLACEHOLDER
)
expect(anon.locator(".footer-text").first).to_have_text(FOOTER)
finally:
if anon_ctx is not None:
anon_ctx.close()
# ---------------------------------------------------------------------------
# 3. The gate + the 403s: anonymous sees the gate (never the form),
# the API 403s anonymous AND token users, the nav link is admin-only
# ---------------------------------------------------------------------------
def test_anonymous_and_token_user_are_walled(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
# --- anonymous: the gate, the hidden form, the hidden nav link ---
page.goto(app_url + "/theme.html")
# Phase 79: an anonymous visitor meets the in-app token gate on
# the shell — #main is inert behind it…
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
assert page.evaluate("() => document.getElementById('main').inert") is True
# …and the Theme view's OWN gate (the exact #sources-gate
# pattern) is the view's visible surface: the sign-in link
# returns to the Theme view (?next=/theme.html)…
expect(page.locator("#theme-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#theme-gate a.sources-gate-link")).to_have_attribute(
"href", "/login.html?next=/theme.html"
)
# …while the form stays locked away (theme.js's non-admin
# branch) and the admin-only nav link is hidden.
expect(page.locator("#theme-content")).to_be_hidden()
expect(page.locator("#nav-theme")).to_be_hidden()
expect(page.locator("#sign-in-link")).to_be_visible()
# The API agrees from the context's own (empty) cookies: GET AND
# PUT are 403 "admin only" (the whole router sits behind
# require_admin — anonymous first).
anon_put = page.evaluate(
"""async () => (await fetch('/api/ui-settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brand: '#4f46e5'}),
})).status"""
)
assert anon_put == 403, f"anonymous PUT /api/ui-settings → {anon_put}"
anon_get = page.evaluate(
"() => fetch('/api/ui-settings').then((r) => r.status)"
)
assert anon_get == 403, f"anonymous GET /api/ui-settings → {anon_get}"
# --- a token user: the SAME wall (B5: admin-only, like
# Tuning/Tokens) ---
login(page, app_url, next="/")
r = httpx.post(
f"{app_url}/api/tokens",
json={"label": "e2e-theme-wall"},
cookies=_cookies(page),
timeout=10,
)
assert r.status_code == 201, r.text
token = r.json()["token"]
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
login_with_token(user, app_url, token)
# The nav link is hidden on their shell (role "user" — the
# header reveals the admin links only for role === "admin")…
expect(user.locator("#nav-theme")).to_be_hidden()
# …and the API 403s their own session (authenticated, just
# not an admin — 403, never 401).
put_status = user.evaluate(
"""async () => (await fetch('/api/ui-settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brand: '#4f46e5'}),
})).status"""
)
assert put_status == 403, f"token-user PUT /api/ui-settings → {put_status}"
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 4. Reset: the §7.4 lifecycle, the 11 defaults, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
def test_reset_restores_the_builtin_byte_identical(
page: Page, app_url: str, db_ready: None
) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The form settles on the effective defaults (the row-less
# state — the autouse clean truncated the row).
_expect_form_values(page, defaults, builtin)
# Save a distinct theme through the UI (the reset must undo a
# REAL save)…
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
_release_theme_puts(page)
# …the theme is live server-side (the pre-reset baseline):
assert "bor-theme" in httpx.get(app_url + "/", timeout=10).text
# Reset to defaults: the §7.4 lifecycle again, with the all-null
# PUT (the API's documented "defaults" operation).
_hold_theme_puts(page)
try:
page.click("#theme-reset")
expect(page.locator("#theme-reset")).to_be_disabled()
expect(page.locator("#theme-reset")).to_have_text("Resetting…")
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator("#theme-reset")).to_have_text("Reset to defaults")
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# The form re-populates to the 11 defaults (the env/built-in
# merge, re-rendered from the refetch)…
_expect_form_values(page, defaults, builtin)
# …and the WCAG warning is gone (the built-in palette passes all
# five pairs).
expect(page.locator("#theme-contrast")).to_be_hidden()
# The served HTML is back to the built-in: NO theme tag anywhere
# (the all-NULL row is the no-op)…
r = httpx.get(app_url + "/", timeout=10)
assert "bor-theme" not in r.text
# …and the computed --brand is the stylesheet's built-in again.
page.goto(app_url + "/")
_wait_theme_computed(page, builtin)
# The byte-identical contract, proven end to end: the served
# bytes of the reset (all-NULL row) deployment equal the served
# bytes of a ROW-LESS deployment (the middleware's no-op path —
# no tag, plain A1 CSP, identical ?v= rewrite).
with_row = httpx.get(app_url + "/", timeout=10).content
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.commit()
without_row = httpx.get(app_url + "/", timeout=10).content
assert with_row == without_row, (
"a defaults-saved row must serve byte-identical HTML"
)
# ---------------------------------------------------------------------------
# 5. The WCAG contrast warning: listed with the ratio on the picker's
# input event, never blocks the save, hidden again after the reset
# ---------------------------------------------------------------------------
def test_contrast_warning_does_not_block(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The form settles on the built-in defaults — the warning is
# hidden (the built-in palette passes all five pairs).
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
expect(page.locator("#theme-contrast")).to_be_hidden()
# Set ONLY --ink to a color within 0.1 ratio of --bg: the
# picker's input event previews it live AND re-runs the five
# pairs — --ink on --bg (and --ink on --surface, the ink is now
# the darker side of that pair too) fail, and each failing pair
# is listed with its ratio in the role=alert line.
page.fill("#theme-ink", FAILING_INK)
contrast = page.locator("#theme-contrast")
expect(contrast).to_have_attribute("role", "alert")
expect(contrast).to_be_visible(timeout=15_000)
expect(contrast).to_contain_text("--ink on --bg: 1.0:1 — needs 4.5:1")
expect(contrast).to_contain_text("--ink on --surface: 1.0:1 — needs 4.5:1")
# WARNING-ONLY: Save is never disabled by the warning (the
# owner's homelab palette — the built-in stays AA, so the
# default deployment is warning-free).
assert page.locator("#theme-save").is_enabled()
_hold_theme_puts(page)
try:
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
# The saved palette still fails the pairs — the warning
# tracks the SAVED state (the save's refetch re-checks it).
expect(contrast).to_be_visible()
finally:
_release_theme_puts(page)
# Restore: Reset clears the failing pick (the suite's final
# state is clean) and the warning hides with the AA built-ins.
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(contrast).to_be_hidden()
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
-1
View File
@@ -331,7 +331,6 @@ def app_server(mock_llm: int, slow_llm: int) -> Iterator[str]:
)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_THEME"] = _Settings.model_fields["theme"].default
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
+9 -6
View File
@@ -148,25 +148,28 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
# Phase 59 (task 05): the third key is the docs-push flag — the
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
# empty, so it is the inert false here. Phase 62 (task 01): the
# endpoint grew to six keys — this suite's instances carry no
# UI-customization overrides, so the three new keys are their
# defaults.
# endpoint grew with the UI-customization keys; phase 91
# (task 03) deleted the retired CSS-file theming's ``theme`` key —
# the five keys below are the entire contract (this suite's
# instances carry no UI-customization overrides, so the string
# keys are their defaults).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["app_name"] == TESTY_NAME
assert body["docs_repo_configured"] is False
# The shared conftest instance keeps the default (the other
# suites' title/label contract rides on it) — and its key set
# grew with the endpoint (phase 62).
# tracks the endpoint contract (five keys after phase 91,
# task 03).
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
assert r2.status_code == 200
r2_body = r2.json()
assert set(r2_body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert r2_body["app_name"] == DEFAULT_NAME
+49 -143
View File
@@ -1,50 +1,53 @@
"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme.
"""Phase 62 E2E (Playwright): UI customization — placeholder + footer.
Source: ``TODO.md`` L3 — "Allow UI customization. This is brain of reese,
but I want anyone to be able to deploy it with their name… custom
message-input placeholder, custom footer-inner text, custom color
themes…" (owner-locked 2026-09-01: ``BOR_INPUT_PLACEHOLDER``,
``BOR_FOOTER_TEXT``, ``BOR_THEME`` — A4/A5).
``BOR_FOOTER_TEXT`` — A4). Phase 91 (task 03) retired the story's
color-theming half — the CSS-file theme env var and its brand.js
``<link>`` insertion are gone; the admin Theme tab (phases 91,
tasks 04–06) is the only theming surface now, with its own dedicated
E2E suite.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_ui_customization.py -v --no-cov
Contract under test:
* an instance booted with ALL THREE customization vars set shows the
custom look end-to-end: the ``GET /api/config`` overrides, the chat
composer placeholder (``#message-input``), the footer line on multiple
pages (``.footer-text``), and the computed ``:root --brand`` from the
inserted ``<link id="theme-override" href="/assets/themes/indigo.css">``
(the indigo example theme, ``--brand: #818cf8``);
* an instance booted with BOTH customization vars set shows the custom
look end-to-end: the ``GET /api/config`` overrides (now the five-key
set — the retired theming's ``theme`` key is gone), the chat composer
placeholder (``#message-input``), and the footer line on multiple
pages (``.footer-text``);
* with NOTHING set the shared conftest server is byte-identical to the
phase-39/61 no-op contract: the default placeholder, the default
footer, NO theme link, the built-in ``--brand: #f43f5e``;
* a malformed ``BOR_THEME`` (``../evil.css``) refuses startup loudly,
naming the value — the phase-56 fail-loud style, proven end-to-end
via a real boot attempt, not just the validator unit test.
footer, the built-in ``--brand: #f43f5e``;
* the app NAME stays the default on the custom instance (this suite
does not re-test ``BOR_APP_NAME`` — that is the phase-39 suite's
job); the response's ``app_name`` key is the EFFECTIVE value (phase
91: DB-over-env — for an env-only deployment, the env string itself).
Determinism note: this story needs a SECOND app instance — the shared
conftest server keeps the defaults (every other suite's
placeholder/footer/palette assertions depend on it), so ``custom_server``
boots the same env block the phase-39 brand suite's ``testy_server``
boots (same DB, the mock-LLM base URL, the admin auth, the static dir,
the mock-calibrated threshold) with exactly three changes: port
the mock-calibrated threshold) with exactly two changes: port
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not
collide) and the three env overrides. Every assertion is settled-state:
collide) and the two env overrides. Every assertion is settled-state:
Playwright's ``expect`` retries ride out the brand.js ``/api/config``
fetch (the three keys are applied asynchronously, in the SAME fetch's
settled ``.then`` — no second network call). The one absence assertion
(no ``#theme-override`` on the default server) first waits for
``window.BOR_CONFIG_PROMISE`` to settle, so it cannot race the fetch.
fetch (the two string keys are applied asynchronously, in the SAME
fetch's settled ``.then`` — no second network call).
Test → contract mapping (Playwright Mapping Rule):
1. ``test_config_serves_the_overrides``
2. ``test_chat_page_shows_custom_placeholder_footer_theme``
2. ``test_chat_page_shows_custom_placeholder_and_footer``
3. ``test_footer_text_applies_on_other_pages``
4. ``test_default_server_is_byte_identical``
5. ``test_malformed_theme_refuses_startup``
"""
from __future__ import annotations
import os
@@ -67,13 +70,10 @@ from e2e.conftest import (
CUSTOM_PORT = APP_PORT + 2 # the brand suite owns APP_PORT + 1 — no collision
CUSTOM_URL = f"http://127.0.0.1:{CUSTOM_PORT}"
MALFORMED_PORT = APP_PORT + 3 # the refused boot never starts listening
# The three overrides (task 05) — the whole story:
# The two overrides (phase 62, task 05) — the surviving story legs:
CUSTOM_PLACEHOLDER = "Ask the archive…"
CUSTOM_FOOTER = "Custom footer line"
CUSTOM_THEME = "indigo.css"
INDIGO_BRAND = "#818cf8" # indigo.css's --brand (the computed token)
# The phase-39/61 no-op contract on the shared default server:
DEFAULT_NAME = "Brain of Reese"
@@ -84,7 +84,7 @@ BUILTIN_BRAND = "#f43f5e" # styles.css's built-in --brand
@pytest.fixture(scope="session")
def custom_server(mock_llm: int) -> Iterator[str]:
"""A SECOND app instance, booted with all three customization
"""A SECOND app instance, booted with both customization string
overrides.
The shared conftest ``app_server`` keeps the defaults (every other
@@ -92,9 +92,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
this fixture copies the phase-39 brand suite's ``testy_server`` env
block verbatim (same DB, the mock-LLM base URL,
``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``, ``BOR_STATIC_DIR``,
``BOR_RELEVANCE_THRESHOLD``) with exactly three changes: port
``BOR_RELEVANCE_THRESHOLD``) with exactly two changes: port
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the
three env overrides below. Started after ``mock_llm`` is available
two env overrides below. Started after ``mock_llm`` is available
(its fixture dependency).
"""
env = dict(os.environ)
@@ -117,10 +117,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
# 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
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the whole story:
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the surviving legs:
env["BOR_INPUT_PLACEHOLDER"] = CUSTOM_PLACEHOLDER
env["BOR_FOOTER_TEXT"] = CUSTOM_FOOTER
env["BOR_THEME"] = CUSTOM_THEME
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(CUSTOM_PORT), "--log-level", "warning"],
@@ -138,28 +137,13 @@ def custom_server(mock_llm: int) -> Iterator[str]:
proc.kill()
def wait_for_brand_settled(page: Page, timeout: int = 15_000) -> None:
"""Wait for the brand layer's boot fetch to settle.
The three customization keys are applied asynchronously, in the
settled ``/api/config`` promise's ``.then`` — absence assertions
(no ``#theme-override``) must not race that fetch. The promise
NEVER rejects (the brand.js contract), so its resolution means the
DOM pass has already run: ``applyBrand`` registered its callback on
the same promise at page load, before this wait's callback, and
promise callbacks run in registration order."""
page.wait_for_function(
"() => window.BOR_CONFIG_PROMISE.then(() => true)",
timeout=timeout,
)
def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
"""Retrying computed ``:root --brand`` equality. Custom properties
return the SPECIFIED token from ``getComputedStyle`` (no color
normalization), so the string compare is stable: ``#818cf8`` is
exactly what indigo.css declares, ``#f43f5e`` exactly what
styles.css declares (the built-in)."""
normalization), so the string compare is stable: ``#f43f5e`` is
exactly what styles.css declares (the built-in — the page the
shared default server serves, with no ui_settings row, carries no
inline theme tag and the stylesheet value stands)."""
page.wait_for_function(
"""(expected) =>
getComputedStyle(document.documentElement)
@@ -171,8 +155,8 @@ def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
# ---------------------------------------------------------------------------
# 1. The endpoint the brand layer reads — the three overrides, the
# six-key set, and the theme file served from the dev static dir
# 1. The endpoint the brand layer reads — the two overrides, the
# five-key set (the retired theming's theme key is gone)
# ---------------------------------------------------------------------------
@@ -180,33 +164,27 @@ def test_config_serves_the_overrides(custom_server: str) -> None:
r = httpx.get(f"{CUSTOM_URL}/api/config", timeout=5)
assert r.status_code == 200
body = r.json()
# The six-key set (the phase-39/59/62 endpoint contract) with the
# three customization overrides — the app NAME stays the default
# (this suite does not re-test BOR_APP_NAME; that is the phase-39
# suite's job).
# The five-key set (the phase-39/59/62 endpoint contract, phase 91
# task 03: the retired CSS-file theming's ``theme`` key is gone)
# with the two customization overrides — the app NAME stays the
# default (this suite does not re-test BOR_APP_NAME; that is the
# phase-39 suite's job).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["app_name"] == DEFAULT_NAME
assert body["input_placeholder"] == CUSTOM_PLACEHOLDER
assert body["footer_text"] == CUSTOM_FOOTER
assert body["theme"] == CUSTOM_THEME
# Served in dev from the static dir (the no-CDN rule): the theme
# file the boot fetch names is reachable at its served path, and
# it is the indigo example (its --brand is the E2E's theme proof).
r2 = httpx.get(f"{CUSTOM_URL}/assets/themes/{CUSTOM_THEME}", timeout=5)
assert r2.status_code == 200
assert f"--brand: {INDIGO_BRAND}" in r2.text
# ---------------------------------------------------------------------------
# 2. The chat page — placeholder, footer, the theme link + effect
# 2. The chat page — placeholder + footer (the theme legs are retired:
# colors are injected pre-paint server-side, phase 91 task 02)
# ---------------------------------------------------------------------------
def test_chat_page_shows_custom_placeholder_footer_theme(
def test_chat_page_shows_custom_placeholder_and_footer(
page: Page, custom_server: str
) -> None:
page.goto(custom_server + "/")
@@ -219,13 +197,6 @@ def test_chat_page_shows_custom_placeholder_footer_theme(
expect(page.locator(".footer-text").first).to_have_text(
CUSTOM_FOOTER, timeout=15_000
)
# 7. The theme link in <head> — rel=stylesheet, the served path.
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
)
# And it takes effect: the computed :root --brand is the indigo
# value (the built-in #f43f5e means the theme never loaded).
expect_brand_var(page, INDIGO_BRAND)
# ---------------------------------------------------------------------------
@@ -243,10 +214,6 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
expect(page.locator(".footer-text").first).to_have_text(
CUSTOM_FOOTER, timeout=15_000
)
# The theme link rides in <head> on every page too.
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
)
# ---------------------------------------------------------------------------
@@ -257,75 +224,14 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
def test_default_server_is_byte_identical(page: Page, app_server: str) -> None:
page.goto(app_server + "/")
# Settle the boot fetch BEFORE the absence assertion — it must not
# race the (absent) theme-link insertion.
wait_for_brand_settled(page)
# The phase-39/61 no-op contract: the template defaults stand.
# The phase-39/61 no-op contract: the template defaults stand
# (positive assertions on the static HTML — the brand layer's
# no-op paths touch nothing when the env vars are unset).
expect(page.locator("#message-input")).to_have_attribute(
"placeholder", DEFAULT_PLACEHOLDER
)
expect(page.locator(".footer-text").first).to_have_text(DEFAULT_FOOTER)
# With BOR_THEME unset the brand layer inserts NO theme link:
assert page.locator("#theme-override").count() == 0, (
"with BOR_THEME unset the brand layer must NOT insert a theme "
"link (the byte-identical no-op contract)"
)
# The built-in dark-tech palette stands.
# The built-in dark-tech palette stands: with no ui_settings row
# the server injects no inline theme tag (phase 91 task 02's
# no-op), so the stylesheet's --brand is the computed value.
expect_brand_var(page, BUILTIN_BRAND)
# ---------------------------------------------------------------------------
# 5. The fail-loud boot check — a malformed BOR_THEME refuses startup
# ---------------------------------------------------------------------------
def test_malformed_theme_refuses_startup() -> None:
"""A malformed ``BOR_THEME`` (``../evil.css`` — a path, exactly the
shape the A5 lock names as illegal) kills startup with the value
NAMED on stderr (the phase-56 fail-loud house style), proven
end-to-end via a real uvicorn boot attempt: the process exits
non-zero within the timeout without ever starting to listen.
``app.main`` builds its settings at import time
(``settings = get_settings()``), so the validator fires during the
ASGI app import — before admin auth, before the port binds."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The whole point: a malformed theme value.
env["BOR_THEME"] = "../evil.css"
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(MALFORMED_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
try:
proc.wait(timeout=60)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
pytest.fail(
"the app kept running with BOR_THEME='../evil.css' — a "
"malformed theme must refuse startup, not silently 404"
)
assert proc.returncode != 0, (
"the malformed BOR_THEME must make uvicorn exit non-zero"
)
stderr = proc.stderr.read() if proc.stderr else ""
# Fail-loud names the offending value (phase-56 house style):
assert "'../evil.css'" in stderr, (
f"stderr must name the offending value, got tail: {stderr[-2000:]}"
)
assert "theme must be a bare .css filename" in stderr
+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
+163
View File
@@ -28,12 +28,17 @@ import pytest
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
import app.core.caching as caching
from app.config import Settings
from app.core import theming
from app.core.caching import asset_version, rewrite_asset_refs
from app.core.security_headers import CSP
from app.models import UiSettings
TOKEN = "abc123"
@@ -212,6 +217,7 @@ def test_html_pages_include_history() -> None:
"/git-sources.html",
"/history.html",
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
"/theme.html", # phase 91 task 04: the admin theme page (shell route)
"/shared.html", # phase 51: the shared page's static path
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
):
@@ -718,3 +724,160 @@ def test_assets_path_keeps_validators_and_304(
assert r304.status_code == 304 # versioned-URL 304s stay safe
assert r304.content == b""
assert r304.headers["cache-control"] == caching.ASSET_CACHE_CONTROL
# ---------------------------------------------------------------------------
# Phase 91 (task 02): the pre-paint inline theme tag
# ---------------------------------------------------------------------------
#
# The middleware's rewrite branch now ALSO builds the theme tag from the
# effective ``ui_settings`` row (task 01's resolver — one short-lived
# session per response, no process cache) and inserts it before the
# first ``</head>``. Unset/defaults → ``tag == ""`` → the served bytes
# are EXACTLY the phase-33/54 rewrite-only output (B4's byte-identical
# contract); a DB blip is the same no-op (the page never breaks).
def _theme_page(name: str) -> str:
"""The ``text/html`` body the fixture routes below serve."""
return (
f"<html><head><title>{name}</title>"
'<link rel="stylesheet" href="/assets/styles.css">'
f"</head><body><main>{name}</main></body></html>"
)
def _theme_page_app() -> FastAPI:
"""A bare app with the middleware: the shell page at ``/`` plus a
second known page (``/document.html``) and the phase-51 dynamic
``/shared/<token>`` route (the prefix branch) — every served
``text/html`` with one versionable asset ref."""
app = FastAPI()
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return _theme_page("index")
@app.get("/document.html", response_class=HTMLResponse)
def document() -> str:
return _theme_page("document")
@app.get("/shared/{token}", response_class=HTMLResponse)
def shared(token: str) -> str:
return _theme_page(f"shared-{token}")
caching.configure_caching(app)
return app
def test_middleware_unset_page_is_byte_identical_to_rewrite_only(db: Session) -> None:
"""THE byte-identical contract (B4): with NO ``ui_settings`` row the
served body is EXACTLY the phase-33/54 rewrite-only output — not a
single byte differs, no ``#bor-theme`` anywhere."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
client = TestClient(_theme_page_app())
token = caching.asset_version()
for path, name in (("/", "index"), ("/document.html", "document")):
r = client.get(path)
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
expected = caching.rewrite_asset_refs(_theme_page(name), token)
assert r.content == expected.encode("utf-8") # byte-identical
assert "bor-theme" not in r.text
# No tag → no style-src exemption: the response carries no CSP
# of its own (this bare app has no security-headers layer), so
# the outer middleware's plain A1 string stands untouched.
assert "content-security-policy" not in r.headers
def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) -> None:
"""A ``ui_settings`` row with ONE changed color: every HTML page —
``/``, the non-shell ``/document.html``, and the dynamic
``/shared/<token>`` (the prefix branch) — carries EXACTLY ONE
``<style id="bor-theme">`` IMMEDIATELY before ``</head>`` (a leading
newline, nothing between), with all 8 ``--*`` vars in ``COLOR_FIELDS``
order and the changed value; the ``?v=`` asset rewrite still applies
alongside."""
db.execute(text("DELETE FROM ui_settings"))
db.add(UiSettings(id=1, brand="#818cf8"))
db.commit()
try:
colors = dict(theming.BUILTIN_COLORS)
colors["brand"] = "#818cf8" # one changed color, rest built-in
tag = theming.theme_style_tag(colors)
client = TestClient(_theme_page_app())
token = caching.asset_version()
shared_token = uuid.uuid4().hex
for path in ("/", "/document.html", f"/shared/{shared_token}"):
r = client.get(path)
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
# Exactly one tag …
assert r.text.count('id="bor-theme"') == 1
# … with a leading newline immediately before the first </head>
# (nothing between the tag and the close).
assert "\n" + tag + "</head>" in r.text
assert r.text.index(tag) == r.text.index("</head>") - len(tag)
# All 8 vars, COLOR_FIELDS order, the changed value present.
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', r.text)
assert declared is not None
names = re.findall(r"--([a-z-]+):", declared.group(1))
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
assert "--brand:#818cf8;" in r.text
# Phase 91 (task 05): the inline tag is blocked by the
# phase-82 CSP in a real browser unless this response also
# carries the style-src exemption — the A1 string plus a
# sha256 hash of the EXACT tag content (the current theme
# is the only inline style ever permitted; no
# 'unsafe-inline').
assert r.headers["content-security-policy"] == (
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
)
assert "unsafe-inline" not in r.headers["content-security-policy"]
# The phase-33/54 asset rewrite is untouched and applies too.
assert f'href="/assets/styles.css?v={token}"' in r.text
finally:
db.execute(text("DELETE FROM ui_settings"))
db.commit()
@pytest.mark.parametrize(
("what",),
[("session",), ("resolver",)],
ids=["session-open-fails", "resolver-fails"],
)
def test_middleware_db_failure_serves_page_without_tag(
monkeypatch: pytest.MonkeyPatch, what: str
) -> None:
"""A DB blip must NEVER break the page (loadHealth house style):
whether the session fails to open or the row read raises, the page
still 200s with the byte-identical rewrite-only body (no tag) and
the no-cache contract intact — a pre-migration boot is the same
path."""
if what == "session":
def _boom_session() -> object:
raise RuntimeError("db down")
monkeypatch.setattr(caching, "SessionLocal", _boom_session)
else:
def _boom_resolver(session: object) -> dict[str, str]:
raise RuntimeError("select failed")
monkeypatch.setattr(caching.theming, "effective_settings", _boom_resolver)
client = TestClient(_theme_page_app())
r = client.get("/")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
token = caching.asset_version()
assert r.content == caching.rewrite_asset_refs(
_theme_page("index"), token
).encode("utf-8")
assert "bor-theme" not in r.text
# The DB-failure fallback is the UNSET shape: no tag, no style-src
# exemption (the plain A1 policy stands — the page degrades to the
# built-in palette, never to an inline-style exemption for a tag
# that is not there).
assert "content-security-policy" not in r.headers
+7 -52
View File
@@ -481,70 +481,25 @@ def test_docs_branchs_garbage_ignored_when_repo_unset(
def test_ui_customization_defaults_are_the_phase_61_copy() -> None:
"""UNSET => byte-identical to the phase-61 neutral UI: the locked
phase-61 copy is the DEFAULT (composer placeholder + footer line),
and an empty theme = the built-in dark-tech palette."""
phase-61 copy is the DEFAULT (composer placeholder + footer line).
Phase 91 (task 03): the retired CSS-file theme env var is gone —
``Settings`` no longer has a theme field at all (a leftover value
in a deployment's .env is ignored, not a boot failure)."""
s = _settings()
assert s.input_placeholder == "Ask me anything…"
assert s.footer_text == "Powered by self-hosted models"
assert s.theme == ""
assert "theme" not in type(s).model_fields
def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
"""The three settings honor their ``BOR_`` env vars
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``);
"""The two string settings honor their ``BOR_`` env vars
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``);
placeholder/footer accept any string (empty is legal — the brand
layer then keeps the template default)."""
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "Ask the vault…")
monkeypatch.setenv("BOR_FOOTER_TEXT", "Powered by my own models")
monkeypatch.setenv("BOR_THEME", "indigo.css")
s = _settings()
assert s.input_placeholder == "Ask the vault…"
assert s.footer_text == "Powered by my own models"
assert s.theme == "indigo.css"
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "")
assert _settings().input_placeholder == "" # empty stands
def test_theme_validator_accepts_empty_and_bare_css_filename() -> None:
"""Phase 62 (A5): empty = the built-in palette; a bare lowercase
``.css`` filename (the ``indigo.css`` example) is the only
non-empty shape — dashes/underscores/digits are legal tokens."""
assert _settings().theme == "" # "" passes
assert _settings(theme="indigo.css").theme == "indigo.css"
assert _settings(theme="dark-2026_v2.css").theme == "dark-2026_v2.css"
@pytest.mark.parametrize(
("bad", "match"),
[
# uppercase — the shape is lowercase-only
("Indigo.css", "Indigo.css"),
# path escape — a theme is a filename, never a path
("../evil.css", r"\.\./evil\.css"),
("a/b.css", r"a/b\.css"),
("/abs.css", r"got '/abs\.css'"),
# a missing extension is not a theme file
("indigo", r"got 'indigo'"), # must not match the example text
],
)
def test_theme_validator_rejects_malformed_naming_the_value(
bad: str,
match: str,
) -> None:
"""A typo in ``BOR_THEME`` must kill startup, not silently 404 at
runtime — the rejection names the offending value (the phase-56
fail-loud house style) alongside the allowed shape."""
with pytest.raises(ValidationError, match=match):
_settings(theme=bad)
def test_bor_theme_env_malformed_fails_startup_naming_value(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The startup path: a malformed ``BOR_THEME`` in the environment
fails Settings construction loudly (the app builds its settings at
import time, so this is a refused boot), naming the value — the
E2E boots-check lands in task 05."""
monkeypatch.setenv("BOR_THEME", "../evil.css")
with pytest.raises(ValidationError, match=r"\.\./evil\.css"):
_settings()
+13 -25
View File
@@ -99,14 +99,16 @@ def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None:
def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
"""Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
/api/config answer also drives the three customization keys — the
#message-input placeholder, every .footer-text node, and the theme
stylesheet link (inserted right after the styles.css link, guarded
by #theme-override, degrading with a console.warn on 404 — A5).
/api/config answer also drives the two customization STRING keys —
the #message-input placeholder and every .footer-text node. Phase
91 (task 03): the retired CSS-file theme link (the old step 7)
and its id-guard / styles.css-finder / onerror-degrade machinery
are GONE — color theming is server-side inline injection
(app/core/theming.py), never a brand.js DOM write.
No second network call: the keys ride the existing boot fetch."""
js = _text(BRAND_JS)
assert js.count('= fetch("/api/config"') == 1, (
"the three keys must ride the existing boot fetch — no new call"
"the keys must ride the existing boot fetch — no new call"
)
# 5. The composer placeholder (chat page only — the null guard
# no-ops on every other page).
@@ -116,28 +118,14 @@ def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
# textContent: an operator string can't inject markup.
assert 'document.querySelectorAll(".footer-text")' in js
assert "footer_text" in js
# 7. The theme link: /assets/themes/<name>, inserted right after
# the styles.css link, tagged #theme-override (the idempotency
# guard), with the A5 degradation warn.
assert '"/assets/themes/"' in js
assert 'link.id = "theme-override"' in js
assert 'getElementById("theme-override")' in js
assert 'insertAdjacentElement("afterend", link)' in js
assert "link.onerror" in js
assert '"brand: theme " + themeName' in js
# The styles.css finder must survive the phase-33/54 cache-bust
# rewrite: the SERVED HTML carries the asset ref with a
# ?v=<token> query (and el.href is the absolute URL), so the match
# has to run on the RAW attribute path with query/fragment
# stripped — el.href.endsWith(…) would silently skip the insertion
# (the theme never applied; found by the task-05 E2E).
assert 'getAttribute("href")' in js
assert "split(/[?#]/)[0]" in js
assert 'endsWith("styles.css")' in js
assert "el.href.endsWith" not in js
# Phase 91 (task 03): the retired theme-link machinery is absent —
# no theme key read, no link insertion (the whole step-7 block
# lived inside the themeName guard — themeName gone, block gone).
assert "themeName" not in js
assert 'insertAdjacentElement' not in js
# The empty-skip no-op contract: each key is guarded before any
# DOM write, so an unset deployment stays byte-identical.
for guard in ("if (placeholder) {", "if (footerText) {", "if (themeName) {"):
for guard in ("if (placeholder) {", "if (footerText) {"):
assert guard in js, (
f"an empty value must skip its application ({guard})"
)
+173 -3
View File
@@ -104,8 +104,8 @@ def test_view_map_covers_the_shell_paths() -> None:
"""The VIEW map is pathname → view name: the shell's own two URLs
("/" and "/index.html") are the chat view, plus one entry per
folded view (tasks 01–03: tuning, rag, git-sources, history;
phase 79 task 06: tokens — all five non-chat navbar views are
in)."""
phase 79 task 06: tokens; phase 91 task 04: theme — all six
non-chat navbar views are in)."""
js = _js()
view_start = js.find("const VIEW = {")
assert view_start != -1, "the VIEW map must exist"
@@ -123,8 +123,11 @@ def test_view_map_covers_the_shell_paths() -> None:
assert '"/tokens.html": "tokens"' in view_body, (
"phase 79 task 06 folds the Tokens view into the shell"
)
assert '"/theme.html": "theme"' in view_body, (
"phase 91 task 04 folds the Theme view into the shell"
)
# The view names are the #view-<name> section slugs in index.html.
for name in ("chat", "tuning", "history", "tokens"):
for name in ("chat", "tuning", "history", "tokens", "theme"):
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
@@ -218,6 +221,9 @@ def test_only_non_chat_views_have_lazy_modules() -> None:
assert 'tokens: () => import("./tokens.js")' in mods_body, (
"the Tokens view module is lazy-imported on first show"
)
assert 'theme: () => import("./theme.js")' in mods_body, (
"the Theme view module is lazy-imported on first show (phase 91)"
)
assert '"chat"' not in mods_body, "the chat view has no lazy module"
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
@@ -275,6 +281,11 @@ def test_router_writes_active_state_title_and_meta() -> None:
assert "Saved chats — every conversation is saved automatically, one click back." in js
assert 'tokens: "Access tokens · Brain of Reese"' in js
assert "Generate and revoke the API tokens that let people use the app." in js
assert 'theme: "Theme · Brain of Reese"' in js
assert (
"Set the palette and branding — the theme is baked into every served page, "
"live on the first paint."
) in js
# The brand composition (phase 39's window.BOR_BRAND, read at
# write time — never a hardcoded stamp).
assert 'window.BOR_BRAND || "Brain of Reese"' in js
@@ -351,6 +362,15 @@ def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
tokens_link = tokens_match.group(0)
assert "hidden" in tokens_link, "#nav-tokens ships hidden (admin-only)"
assert "is-active" not in tokens_link, "no static active stamp on the Tokens link"
# The Theme nav link (phase 91 task 04) ships hidden (admin-only)
# and UNstamped too — the router is the single writer of the active
# state, and a token user (role "user") must never see the link
# (header.js reveals it for admin only).
theme_match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', html)
assert theme_match, "the shell must carry the #nav-theme nav link"
theme_link = theme_match.group(0)
assert "hidden" in theme_link, "#nav-theme ships hidden (admin-only)"
assert "is-active" not in theme_link, "no static active stamp on the Theme link"
# ---------- phase 76 task 04: the header is shell-owned ----------
@@ -869,3 +889,153 @@ def test_tokens_view_scaffold_in_the_shell() -> None:
# The Actions column header is visually-hidden (the row buttons
# carry their own aria-labels — the history-table convention).
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
# ---------- phase 91 task 04: the Theme view (skeleton) ----------
def test_theme_view_scaffold_in_the_shell() -> None:
"""Phase 91 task 04: the shell carries the #view-theme section —
hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) —
with the #theme-gate (the EXACT #sources-gate pattern, ship-hidden,
its Sign in returning to the Theme view via ?next=/theme.html) and
the ship-hidden #theme-content (the #git-sources-content pattern)
holding the STATIC form skeleton: the page-head (h1 "Theme"), the
#theme-form with the 3 labeled branding text inputs (maxlength=300
— the server re-validates) + the 8 labeled type=color palette inputs
(the 8 identity variables, in the theming.COLOR_FIELDS order), the
#theme-save (primary) + #theme-reset (secondary) — BOTH type="button"
(no real submit), and the three task-05 feedback lines: #theme-error
(role=alert), #theme-result (role=status), #theme-contrast
(role=alert) — all ship hidden. The editor behavior (populate,
live preview, Save/Reset, the contrast warnings) lands in task 05;
this pin keeps the E2E-stable skeleton from drifting."""
html = _html()
view = html.find('<section class="view" id="view-theme"')
assert view != -1, "the #view-theme section must be in the shell"
tag_end = html.find(">", view)
tag = html[view:tag_end]
assert "hidden" in tag and "inert" in tag, (
"the folded view ships hidden AND inert"
)
assert 'tabindex="-1"' in tag, "the target view is focusable"
main_end = html.find("</main>", view)
assert view < main_end, "the view section lives inside the single main"
body = html[view:main_end]
# The gate: the exact #sources-gate pattern (class + ship-hidden +
# its ?next= returning to the Theme view — the no-JS fallback).
gate = re.search(r'<section[^>]*id="theme-gate"[^>]*>', body)
assert gate and "hidden" in gate.group(0), "#theme-gate must ship hidden"
assert 'class="sources-gate"' in gate.group(0), (
"the gate reuses the .sources-gate visual language"
)
assert "<h2 id=\"theme-gate-title\">Sign in to change the theme</h2>" in body
assert 'href="/login.html?next=/theme.html"' in body, (
"the gate's Sign in returns to the Theme view (no-JS fallback)"
)
# The content ships hidden (theme.js reveals it for admin only —
# the #git-sources-content pattern).
content = re.search(r'<div[^>]*id="theme-content"[^>]*>', body)
assert content and "hidden" in content.group(0), (
"#theme-content must ship hidden (anonymous-safe)"
)
# The static form skeleton (the E2E-stable-selectors house
# convention): the 3 labeled branding text inputs (maxlength=300)
# and the 8 labeled type=color palette inputs (the 8 identity
# variables — one per theming.COLOR_FIELDS field).
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
"the #theme-form must be STATIC markup in the shell"
)
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
assert re.search(
rf'<label[^>]*for="{field_id}"[^>]*>', body
), f"missing the visible label for #{field_id}"
assert re.search(
rf'<input[^>]*id="{field_id}"[^>]*maxlength="300"[^>]*>', body
), f"#{field_id} must be a text input with maxlength=300"
for field_id in (
"theme-bg",
"theme-surface",
"theme-ink",
"theme-ink-soft",
"theme-line",
"theme-brand",
"theme-brand-soft",
"theme-brand-ink",
):
assert re.search(
rf'<label[^>]*for="{field_id}"[^>]*>', body
), f"missing the visible label for #{field_id}"
assert re.search(
rf'<input[^>]*id="{field_id}"[^>]*type="color"[^>]*>', body
), f"#{field_id} must be a type=color input"
# Save (primary) + Reset (secondary) — BOTH type="button" (no real
# submit; theme.js owns the onsubmit handling + the §7.4 lifecycle).
save = re.search(r'<button[^>]*id="theme-save"[^>]*>', body)
assert save and 'type="button"' in save.group(0), (
"#theme-save must be a type=button (no real submit)"
)
reset = re.search(r'<button[^>]*id="theme-reset"[^>]*>', body)
assert reset and 'type="button"' in reset.group(0), (
"#theme-reset must be a type=button (no real submit)"
)
assert "Save theme" in body, "the Save button's label"
assert "Reset to defaults" in body, "the Reset button's label"
# The three task-05 feedback lines, all ship hidden.
assert re.search(r'<[^>]*id="theme-error"[^>]*role="alert"[^>]*hidden', body)
assert re.search(r'<[^>]*id="theme-result"[^>]*role="status"[^>]*hidden', body)
assert re.search(r'<[^>]*id="theme-contrast"[^>]*role="alert"[^>]*hidden', body)
def test_theme_nav_link_ships_on_every_page_header() -> None:
"""Phase 91 task 04: the phase-34 one-bar contract — the SAME nav
ships on every page (test_nav_consistency pins the header inventory
PARITY across the shell pages, the document viewer, and the login
page), so #nav-theme (ship-hidden, admin-only) must be in the
#app-nav of EVERY header-bearing page: the shell + document.html +
login.html + shared.html. The doc-edit flow page ships the reduced
header (no admin links at all) and is out of the contract."""
for page in (
FRONTEND / "index.html",
FRONTEND / "document.html",
FRONTEND / "login.html",
FRONTEND / "shared.html",
):
text = page.read_text(encoding="utf-8")
match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', text)
assert match, f"{page.name} must carry the #nav-theme nav link (one-bar)"
link = match.group(0)
assert 'href="/theme.html"' in link, f"{page.name}: the Theme link's href"
assert "hidden" in link, (
f"{page.name}: #nav-theme ships hidden (admin-only)"
)
assert "is-active" not in link, (
f"{page.name}: no static active stamp on the Theme link"
)
def test_header_js_reveals_the_theme_link_for_admin_only() -> None:
"""Phase 91 task 04: header.js reveals #nav-theme for role admin —
the same ship-hidden / reveal-for-admin contract as the other
admin-only links: the two-line reveal (`hidden = !admin`) sits in
initSharedHeader, null-safe (a page without the link is a no-op),
and the gate is the `admin` flag (role === "admin") — a token user
(role "user") never sees the link."""
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
fn = header_js.find("export async function initSharedHeader")
assert fn != -1, "initSharedHeader must exist"
body = header_js[fn:]
lookup = body.find('document.querySelector("#nav-theme")')
assert lookup != -1, "header.js must look up #nav-theme"
reveal = body.find("navTheme.hidden = !admin")
assert 0 <= lookup < reveal, (
"the reveal must be the two-line pattern: null-safe lookup, "
"then hidden = !admin (the admin flag — role === \"admin\")"
)
# The lookup + reveal sit AFTER the whoami resolution (the admin
# flag exists only once fetchWhoami has settled).
whoami = body.find("const whoami = await fetchWhoami()")
admin_flag = body.find('const admin = whoami.role === "admin"')
assert 0 <= whoami < admin_flag < lookup, (
"the reveal keys off the resolved admin flag"
)
+27
View File
@@ -18,6 +18,33 @@ def test_all_tables_registered() -> None:
assert "documents" in tables
assert "chunks" in tables
assert "query_log" in tables
assert "ui_settings" in tables # phase 91: the single-row UI settings
def test_ui_settings_single_row_nullable_contract() -> None:
"""Phase 91: the single-row UI settings table — Integer PK ``id``
with the Python-side ``default=1`` (the row is always id 1), the 3
strings VARCHAR(300) and the 8 identity colors VARCHAR(7), ALL
nullable (NULL = default — B1: env value for the strings, the
built-in palette for the colors)."""
settings_table = Base.metadata.tables["ui_settings"]
assert set(settings_table.c.keys()) == {
"id", "app_name", "input_placeholder", "footer_text",
"bg", "surface", "ink", "ink_soft", "line",
"brand", "brand_soft", "brand_ink",
}
pk = settings_table.c["id"]
assert pk.primary_key is True, "ui_settings.id must be the PK"
assert pk.default is not None, "id needs the Python-side default=1"
for name in ("app_name", "input_placeholder", "footer_text"):
col = settings_table.c[name]
assert col.nullable is True, f"{name} must be NULL (env default)"
assert getattr(col.type, "length", None) == 300, f"{name} must be String(300)"
for name in ("bg", "surface", "ink", "ink_soft", "line",
"brand", "brand_soft", "brand_ink"):
col = settings_table.c[name]
assert col.nullable is True, f"{name} must be NULL (the built-in)"
assert getattr(col.type, "length", None) == 7, f"{name} must be String(7) — #rrggbb"
def test_chunks_embedding_is_vector_768() -> None:
+5 -3
View File
@@ -51,11 +51,13 @@ def test_app_config_dict_carries_the_docs_flag() -> None:
s = _settings()
body = app_config(s)
# Phase 62 (task 01): the response grew to the six-key set — the
# phase-62 UI customization keys ride the SAME endpoint.
# Phase 62 (task 01): the response grew to the phase-62 UI
# customization keys; phase 91 (task 03) deleted the retired
# CSS-file theming's ``theme`` key — the five keys below are the
# entire endpoint contract.
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["docs_repo_configured"] is s.docs_configured
assert body["docs_repo_configured"] is False
+26
View File
@@ -149,6 +149,32 @@ def test_404_shaped_response_carries_all_three_headers() -> None:
assert body_msg["body"] == b"not found"
def test_pre_existing_csp_from_an_inner_layer_is_preserved() -> None:
"""Phase 91 (task 05): the caching middleware publishes, on themed
HTML pages only, the A1 string EXTENDED with a ``style-src`` sha256
hash for the inline theme tag (the A1 policy would block the tag in
every real browser). A CSP an inner layer has already set is that
layer's deliberate one and must survive the outer middleware —
while the other two headers are still added."""
themed = (
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
"style-src 'self' 'sha256-2rm3wPcQfXmE8q1s9vBzK7hN4tY5uJ6gW3oR0cAeDfH='"
)
wrapped = SecurityHeadersMiddleware(
_plain_app(
200,
b"<html></html>",
headers=[[b"content-security-policy", themed.encode("ascii")]],
)
)
sent = _drive(wrapped, _http_scope())
start = sent[0]
assert _header(start, "content-security-policy") == themed # not clobbered
assert _header(start, "x-frame-options") == "DENY"
assert _header(start, "x-content-type-options") == "nosniff"
# ---------------------------------------------------------------------------
# The SSE streaming passthrough pin
# ---------------------------------------------------------------------------
-195
View File
@@ -1,195 +0,0 @@
"""Unit: the phase-62 example theme (``frontend/assets/themes/``) and
the Containerfile line that ships it (A7).
No Python logic exists for this task — the mechanism lives in
``brand.js`` (pinned by test_frontend_brand.py) and the theme is a
drop-in stylesheet. Like the other frontend-adjacent unit files, this
module pins the assets as text, so a silent regression (a theme file
gaining a selector, a declaration drifting, the Containerfile line
vanishing) is caught without a browser. The browser-visible layer
(computed ``--brand``, the inserted ``<link>``) is E2E-gated by
``tests/e2e/test_ui_customization.py`` (task 05).
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
FRONTEND = REPO_ROOT / "frontend"
THEMES = FRONTEND / "assets" / "themes"
INDIGO = THEMES / "indigo.css"
GUIDE = THEMES / "README.md"
CONTAINERFILE = REPO_ROOT / "Containerfile"
#: The 8 identity variables a theme may override — and the EXACT set
#: indigo.css ships (the semantic families accent/ok/err are states,
#: not identity: a theme that overrides them stops being honest).
IDENTITY_VARS = (
"--bg",
"--surface",
"--ink",
"--ink-soft",
"--line",
"--brand",
"--brand-soft",
"--brand-ink",
)
INDIGO_VALUES: dict[str, str] = {
"--bg": "#0a0e1a",
"--surface": "#111726",
"--ink": "#e6e9f0",
"--ink-soft": "#a8b0c8",
"--line": "#232c44",
"--brand": "#818cf8",
"--brand-soft": "#1a1f38",
"--brand-ink": "#c7d2fe",
}
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _strip_comments(css: str) -> str:
"""Drop ``/* … */`` comments — the pins assert against declarations,
not prose."""
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
def _declarations(css: str) -> dict[str, str]:
"""The ``--name: value`` declarations of the (single) ``:root``
block, in file order."""
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", css))
def test_example_theme_files_exist() -> None:
"""The example theme and its authoring guide ship in the static
dir (served at /assets/themes/… in dev AND in the image)."""
assert INDIGO.is_file(), f"missing example theme: {INDIGO}"
assert GUIDE.is_file(), f"missing authoring guide: {GUIDE}"
def test_indigo_starts_with_a_single_root_block_and_nothing_else() -> None:
"""The whole file is ONE ``:root`` block (the cascade is the entire
mechanism): after stripping comments the first non-whitespace
content is ``:root``, and no other rule, selector, or declaration
exists anywhere in the file."""
css = _strip_comments(_text(INDIGO))
assert css.lstrip().startswith(":root"), (
"indigo.css must start with the :root block (after its header "
"comment) — nothing may precede it"
)
assert re.fullmatch(r"\s*:root\s*\{[^{}]*\}\s*", css, re.S) is not None, (
"indigo.css must be exactly one :root block — no selectors, "
"no @media, no nested or extra rules"
)
def test_indigo_overrides_exactly_the_eight_identity_variables() -> None:
"""EXACTLY the 8 identity overrides with the locked values — no
other declarations (a 9th declaration here would be the theme
reaching past the palette), and the semantic families
(accent/ok/err) must be untouched (they encode states)."""
decls = _declarations(_strip_comments(_text(INDIGO)))
assert set(decls) == set(IDENTITY_VARS), (
f"indigo.css must override exactly the 8 identity variables, got "
f"{sorted(decls)}"
)
for name in IDENTITY_VARS:
assert decls[name].strip() == INDIGO_VALUES[name], (
f"{name} drifted from the locked value "
f"{INDIGO_VALUES[name]!r}, got {decls[name].strip()!r}"
)
for family in ("--accent-", "--ok-", "--err-"):
assert not any(k.startswith(family) for k in decls), (
f"semantic {family}* variables must stay the built-in "
f"theme (they encode states)"
)
def test_indigo_identity_pairs_meet_wcag_aa() -> None:
"""The five identity text/background pairs, computed from the file's
OWN hex values (not re-typed), each meet WCAG 2.1 AA (>= 4.5:1) —
AGENTS.md rule 5. The pairs are the ones the layout actually pairs:
ink on bg/surface, ink-soft on surface, the dark bg ink on brand
(text on brand buttons is --bg, never white — the built-in's
documented 3.7:1 trap), brand-ink on surface."""
decls = _declarations(_strip_comments(_text(INDIGO)))
def lum(hexcolor: str) -> float:
h = hexcolor.lstrip("#")
chans = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
lin = [
c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
for c in chans
]
r, g, b = lin
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def ratio(fg: str, bg: str) -> float:
l1, l2 = lum(fg), lum(bg)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
pairs = (
("ink on bg", decls["--ink"], decls["--bg"]),
("ink on surface", decls["--ink"], decls["--surface"]),
("ink-soft on surface", decls["--ink-soft"], decls["--surface"]),
("bg ink on brand", decls["--bg"], decls["--brand"]),
("brand-ink on surface", decls["--brand-ink"], decls["--surface"]),
)
for name, fg, bg in pairs:
r = ratio(fg, bg)
assert r >= 4.5, f"{name}: {r:.2f}:1 < 4.5:1 (WCAG 2.1 AA)"
def test_containerfile_ships_the_whole_themes_directory() -> None:
"""A7: stage 1 copies the WHOLE themes directory (no per-file
esbuild — a future theme file needs no Containerfile edit), and it
does so AFTER the styles.css minify line (so the served /assets/
tree is complete before the pages cp)."""
cf = _text(CONTAINERFILE)
stage1 = cf.split("AS frontend", 1)[1].split("\nFROM", 1)[0]
lines = stage1.splitlines()
cp_idxs = [
i
for i, ln in enumerate(lines)
if re.search(r"\bcp\s+-r\s+\./assets/themes\s+/out/assets/themes\b", ln)
]
assert len(cp_idxs) == 1, (
"stage 1 must ship the themes directory with exactly one "
"'cp -r ./assets/themes /out/assets/themes' line"
)
styles_idxs = [
i for i, ln in enumerate(lines) if "esbuild ./assets/styles.css" in ln
]
assert len(styles_idxs) == 1, "stage 1 must minify styles.css"
assert cp_idxs[0] > styles_idxs[0], (
"the themes cp must come AFTER the styles.css minify line"
)
cp_line = lines[cp_idxs[0]]
assert "--bundle" not in cp_line and "esbuild" not in cp_line, (
"A7: the themes directory is copied verbatim — no per-file "
"esbuild minify"
)
def test_authoring_guide_pins_the_contract() -> None:
"""The guide documents the load path (BOR_THEME → /api/config →
brand.js link after styles.css), the filename validator regex, the
8-variable table, the 4.5:1 bar, the never-white-on-brand trap,
and the A7 rebuild story (a new file needs no Containerfile edit)."""
guide = _text(GUIDE)
for marker in (
"BOR_THEME",
"/api/config",
"styles.css",
r"^[a-z0-9_-]+\.css$",
"4.5:1",
"white-on-brand",
"cp -r ./assets/themes /out/assets/themes",
):
assert marker in guide, f"themes/README.md must document {marker!r}"
for var in IDENTITY_VARS:
assert var in guide, f"the variable table must list {var}"
+310
View File
@@ -0,0 +1,310 @@
"""Unit: the built-in identity palette + the effective-settings resolver
(phase 91, task 01).
Covers ``app/core/theming.py`` — the single source of the built-in
identity palette (re-homed from the retired phase-62 CSS-file themes'
authoring guide before task 03 deleted it) and the DB-over-env /
DB-over-built-in resolver shared by ``/api/ui-settings`` and
``/api/config``:
* ``BUILTIN_COLORS`` — the DRIFT GUARD: the 8 built-ins must equal the
values parsed straight out of ``frontend/assets/styles.css``'s
``:root`` block, so the Python palette and the stylesheet can never
silently diverge;
* ``theme_style_tag`` — the byte-identical contract (all built-in →
``""``) and the exact tag shape (all 8 variables, ``COLOR_FIELDS``
order, lowercased hex);
* ``effective_settings`` — missing row → env strings + built-ins; a DB
row's set columns win; an empty-string DB string falls back to env
(the resolver treats "" as unset, B1). House DB-test pattern (the
``test_tokens`` precedent): the real compose Postgres, skipped with
clear instructions when the stack is not up.
"""
from __future__ import annotations
import base64
import hashlib
import re
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.core import theming
from app.models import UiSettings
REPO_ROOT = Path(__file__).resolve().parents[2]
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
def _delete_row() -> Any:
from sqlalchemy import delete
return delete(UiSettings).where(UiSettings.id == 1)
def _root_declarations() -> dict[str, str]:
"""The ``--name: value`` declarations of styles.css's (first)
``:root`` block, comments stripped, in file order."""
css = STYLES_CSS.read_text(encoding="utf-8")
match = re.search(r":root\s*\{", css)
assert match is not None, "styles.css must have a :root block"
block = css[match.end() : css.index("}", match.end())]
block = re.sub(r"/\*.*?\*/", "", block, flags=re.S)
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block))
def test_builtin_colors_match_styles_css_root() -> None:
"""The drift guard: every built-in equals the stylesheet's ``:root``
value for the same variable (and ``BUILTIN_COLORS`` names exactly
the 8 identity variables — no more, no fewer)."""
decls = _root_declarations()
builtin_names = set(theming.BUILTIN_COLORS)
assert builtin_names == {
"bg", "surface", "ink", "ink_soft", "line",
"brand", "brand_soft", "brand_ink",
}, f"BUILTIN_COLORS must name exactly the 8 identity variables, got {sorted(builtin_names)}"
for name, value in theming.BUILTIN_COLORS.items():
css_name = f"--{name.replace('_', '-')}"
assert css_name in decls, f"styles.css :root is missing {css_name}"
assert decls[css_name].strip() == value, (
f"{css_name} drifted: BUILTIN_COLORS has {value!r}, "
f"styles.css has {decls[css_name].strip()!r}"
)
def test_color_fields_are_the_eight_keys_in_readme_order() -> None:
"""``COLOR_FIELDS`` is the 8 keys in the themes-README order — the
order the resolver, the API, and the tag renderer all rely on."""
assert theming.COLOR_FIELDS == (
"bg", "surface", "ink", "ink_soft",
"line", "brand", "brand_soft", "brand_ink",
)
assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text")
def _env_settings() -> Settings:
"""An explicit env source (the resolver's optional ``settings``
parameter) — deterministic values, independent of the local ``.env``
(the ``/api/config`` env pins in test_api.py own the env-file
behaviour; this unit module only needs stable fallbacks)."""
return Settings(
app_name="Env Name",
input_placeholder="Env placeholder…",
footer_text="Env footer",
)
# ---------- effective_settings (real Postgres — house DB-test pattern) ----------
def test_effective_missing_row_is_env_strings_plus_builtins(db: Session) -> None:
"""A missing row (GET creates nothing) means "defaults": the env
strings + the built-in palette, all 11 keys."""
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is None, "the test starts from a row-missing state"
effective = theming.effective_settings(db, _env_settings())
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
assert effective["app_name"] == "Env Name"
assert effective["input_placeholder"] == "Env placeholder…"
assert effective["footer_text"] == "Env footer"
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
def test_effective_db_row_wins_column_by_column(db: Session) -> None:
"""Set columns win, unset columns fall back — per column, so a
partial row (only ``bg`` set) mixes the DB color with the built-ins
and the env strings."""
db.add(UiSettings(id=1, bg="#111111", app_name="DB Name"))
db.commit()
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["bg"] == "#111111" # DB wins
assert effective["app_name"] == "DB Name" # DB wins
# Unset columns: env strings + the built-in colors.
assert effective["input_placeholder"] == "Env placeholder…"
assert effective["footer_text"] == "Env footer"
for key in theming.COLOR_FIELDS:
if key != "bg":
assert effective[key] == theming.BUILTIN_COLORS[key]
finally:
db.execute(_delete_row())
db.commit()
def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None:
"""B1: an EMPTY string in the DB is unset — the resolver falls back
to the env value (a hand-edited row with '' can't blank the UI).
Colors: ``None`` → the built-in (an empty color is impossible through
the API — the hex validator — the resolver's not-None rule covers
the hand-edited edge by returning whatever the row holds)."""
db.add(UiSettings(id=1, app_name=""))
db.commit()
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["app_name"] == "Env Name" # "" → env fallback
assert effective["footer_text"] == "Env footer"
assert effective["brand"] == theming.BUILTIN_COLORS["brand"] # None → built-in
finally:
db.execute(_delete_row())
db.commit()
def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None:
"""``settings=None`` (the design's call shape) resolves the env
fallback from the cached :func:`app.config.get_settings` — the
values it reports must be real ``str``s for all 11 keys."""
from app.config import get_settings
effective = theming.effective_settings(db)
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
assert effective["app_name"] == get_settings().app_name
for field in theming.STRING_FIELDS:
assert isinstance(effective[field], str) and effective[field]
assert all(re.fullmatch(r"#[0-9a-f]{6}", effective[k]) for k in theming.COLOR_FIELDS)
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
# ---------- theme_style_tag (pure) ----------
def test_theme_style_tag_all_builtins_is_empty_string() -> None:
"""The byte-identical contract: an unset (or "defaults saved")
deployment serves NO tag — exactly the pre-phase-91 HTML."""
assert theming.theme_style_tag(dict(theming.BUILTIN_COLORS)) == ""
# The tag is PURE string-equality: uppercase hex is NOT the built-in
# (the API's lowercasing-before-store is what makes stored hex
# canonical — pin the renderer's own contract here).
colors = {k: v.upper() for k, v in theming.BUILTIN_COLORS.items()}
assert theming.theme_style_tag(colors) != ""
def test_theme_style_tag_one_changed_carries_all_eight_in_order() -> None:
"""A single non-built-in color still emits ALL 8 variables, in
``COLOR_FIELDS`` order, with the exact tag shape (no whitespace)."""
colors = dict(theming.BUILTIN_COLORS)
colors["brand"] = "#818cf8"
tag = theming.theme_style_tag(colors)
assert tag == (
'<style id="bor-theme">:root{'
"--bg:#0f0a0a;--surface:#1a0f0f;--ink:#f0e6e6;--ink-soft:#b8a8a8;"
"--line:#2d1a1a;--brand:#818cf8;--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
"}</style>"
)
# The changed value lands under the dashed CSS name…
assert "--brand:#818cf8;" in tag
# …and the underscored field (ink_soft) renders as --ink-soft.
assert "--ink-soft:#b8a8a8;" in tag
assert "--ink_soft" not in tag
def test_theme_style_tag_multiple_changed() -> None:
"""Two changed colors: both values present, the rest built-in, order
unchanged (the tag is a complete :root override — the page never
mixes a partial palette)."""
colors = dict(theming.BUILTIN_COLORS)
colors["bg"] = "#0a0e1a"
colors["brand_ink"] = "#c7d2fe"
tag = theming.theme_style_tag(colors)
assert tag.startswith('<style id="bor-theme">:root{--bg:#0a0e1a;')
assert "--brand-ink:#c7d2fe;" in tag
assert tag.endswith("}</style>")
# The order of the 8 dashed names is the COLOR_FIELDS order.
names = re.findall(r"--([a-z-]+):", tag)
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
# ---------- inject_theme (pure — task 02's injection helper) ----------
_HEAD_HTML = "<html><head><title>t</title></head><body><p>b</p></body></html>"
def test_inject_theme_empty_tag_is_identity() -> None:
"""``tag == ""`` (the unset / "defaults saved" deployment — what
``theme_style_tag" returns for all-built-in colors) → the html is
returned EXACTLY as passed in, byte for byte (B4's no-op
contract)."""
assert theming.inject_theme(_HEAD_HTML, "") == _HEAD_HTML
# Whitespace is NOT an empty tag — a real tag is always inserted.
assert theming.inject_theme(_HEAD_HTML, " ") != _HEAD_HTML
def test_inject_theme_missing_head_is_identity() -> None:
"""No ``</head>`` occurrence → unchanged (nothing to anchor to);
the empty string (no ``</head>`` either) is identity too."""
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
html = "<html><body>no head</body></html>"
assert theming.inject_theme(html, tag) == html
assert theming.inject_theme("", tag) == ""
def test_inject_theme_exact_placement_before_first_head_close() -> None:
"""The tag lands with a leading newline immediately BEFORE the
first ``</head>`` — nothing between the tag and the close, nothing
moved after it."""
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
html = "<html><head><title>t</title></head><body>after</body></html>"
assert theming.inject_theme(html, tag) == (
"<html><head><title>t</title>\n" + tag + "</head><body>after</body></html>"
)
# A LATER ``</head>``-shaped stretch of text is not the anchor — the
# FIRST occurrence wins (the one that closes the real head).
html2 = "<head></head><script>if (x) { a() }</head></script></head>"
assert theming.inject_theme(html2, tag) == (
"<head>\n" + tag + "</head><script>if (x) { a() }</head></script></head>"
)
def test_inject_theme_double_injection_is_idempotent() -> None:
"""The defensive idempotence rule keys on the id: once a
``id="bor-theme"`` tag is present the helper is the identity — the
page can never carry two theme tags, even for a different tag."""
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
once = theming.inject_theme(_HEAD_HTML, tag)
assert once.count('id="bor-theme"') == 1
assert theming.inject_theme(once, tag) == once
other = '<style id="bor-theme">:root{--bg:#222222;}</style>'
assert theming.inject_theme(once, other) == once
# ---------------------------------------------------------------------------
# Phase 91 (task 05, defect fix): theme_csp_hash — the CSP3 hash of the
# inline tag's content (the phase-82 CSP would otherwise BLOCK the tag
# in every real browser; the caching middleware publishes the hash as a
# style-src exemption on themed HTML pages only).
# ---------------------------------------------------------------------------
def test_theme_csp_hash_empty_tag_is_empty_string() -> None:
"""No tag (unset/defaults deployment) → no hash — the plain A1
policy stands and the header stays byte-identical to pre-91."""
assert theming.theme_csp_hash("") == ""
def test_theme_csp_hash_is_sha256_of_the_tag_content() -> None:
"""CSP3 §13.4: the hash covers the character data BETWEEN the tags
(the ``:root{…}`` declarations — the rendered content carries no
leading/trailing whitespace, so no stripping applies), base64
after SHA-256, ``sha256-`` prefixed."""
tag = '<style id="bor-theme">:root{--bg:#111111}</style>'
expected = "sha256-" + base64.b64encode(
hashlib.sha256(b":root{--bg:#111111}").digest()
).decode("ascii")
assert theming.theme_csp_hash(tag) == expected
def test_theme_csp_hash_changes_with_the_palette() -> None:
"""A different palette → a different hash: the browser keeps
blocking the OLD tag once the theme changes (the exemption always
matches exactly the served bytes, never a stale palette)."""
colors = dict(theming.BUILTIN_COLORS)
colors["brand"] = "#818cf8"
first = theming.theme_csp_hash(theming.theme_style_tag(colors))
colors["brand"] = "#22c55e"
second = theming.theme_csp_hash(theming.theme_style_tag(colors))
assert first
assert first != second
assert first.startswith("sha256-")
assert second.startswith("sha256-")
+193
View File
@@ -0,0 +1,193 @@
"""Unit: the admin UI-settings API (phase 91, task 01).
Covers ``app/api/ui_settings.py`` — the PUT validation + normalization
contract and the GET/PUT persistence on the single ``ui_settings`` row:
* PUT validation — the 422s NAME the offending field (fixed details):
a >300-char string after the trim, a non-``#rrggbb`` color (wrong
prefix, 3-digit shorthand, 8 hex chars, missing ``#``);
* normalization — colors are lowercased on store; a color EQUAL to its
built-in is stored as NULL (the owner-locked rule: "save the defaults"
must leave the row empty — the no-op injection contract); an empty /
whitespace-only string is the clear operation (NULL);
* GET — the effective merge (a partial row reports the DB values over
the env/built-in defaults);
* upsert — the first PUT CREATES the id-1 row, the second UPDATES that
same row (one row, always id 1).
House pattern (the ``test_tokens_api`` precedent): the real app via
TestClient (cookie jar = the house admin-login fixture) against the real
compose Postgres; the single row is global state, so an autouse fixture
resets it around every test.
"""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core import theming
from app.models import UiSettings
ALL_NULL_BODY: dict[str, str | None] = {
"app_name": None, "input_placeholder": None, "footer_text": None,
"bg": None, "surface": None, "ink": None, "ink_soft": None,
"line": None, "brand": None, "brand_soft": None, "brand_ink": None,
}
@pytest.fixture(autouse=True)
def clean_ui_settings(db: Session) -> Iterator[None]:
"""ui_settings holds ONE row of global state: reset it around every
test (the ``clean_tokens`` house pattern, DELETE — the row is
created only by the PUT upsert, so "absent" is the natural
pristine state)."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
yield
db.execute(text("DELETE FROM ui_settings"))
db.commit()
def _row(db: Session) -> UiSettings | None:
return db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
def test_put_too_long_string_422_names_the_field(
admin_client: TestClient, db: Session
) -> None:
"""Each of the 3 strings: >300 chars AFTER the trim is a 422 naming
that field; a rejected PUT half-writes nothing; exactly 300 still
passes (the column is VARCHAR(300))."""
for field in theming.STRING_FIELDS:
r = admin_client.put("/api/ui-settings", json={field: "x" * 301})
assert r.status_code == 422, (field, r.text)
assert r.json()["detail"] == f"{field} is too long (max 300)"
# A whitespace-padded 301 is still 301 after the trim…
r = admin_client.put("/api/ui-settings", json={field: " x" * 151})
assert r.status_code == 422, (field, r.text)
# No rejected PUT created the row — the upsert runs after validation.
assert _row(db) is None
# Exactly 300 passes — stored, trimmed.
r = admin_client.put("/api/ui-settings", json={"app_name": "y" * 300})
assert r.status_code == 200, r.text
assert r.json()["app_name"] == "y" * 300
def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
"""Each of the 8 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422
naming that field — 3-digit shorthand, 8 hex digits, a bare hex
without ``#``, a named color, and the empty string (the color clear
operation is ``null``, not ``""``)."""
for field in theming.COLOR_FIELDS:
for bad in ("fff", "#ff", "ff00aa", "#12345678", "red"):
r = admin_client.put("/api/ui-settings", json={field: bad})
assert r.status_code == 422, (field, bad, r.text)
assert r.json()["detail"] == f"{field} must be a #rrggbb hex color"
def test_put_lowercases_colors_on_store(
admin_client: TestClient, db: Session
) -> None:
"""Uppercase hex passes the validator and is stored LOWERCASE — the
canonical form the tag renderer and the drift comparison rely on."""
r = admin_client.put("/api/ui-settings", json={"brand": "#818CF8"})
assert r.status_code == 200, r.text
assert r.json()["brand"] == "#818cf8"
row = _row(db)
assert row is not None
assert row.brand == "#818cf8" # the stored column, not just the response
def test_put_built_in_color_is_stored_as_null(
admin_client: TestClient, db: Session
) -> None:
"""The owner-locked normalization: a color equal to its built-in is
stored as NULL — PUTting the whole built-in palette (with one value
in uppercase, proving the compare happens AFTER the lowercase)
leaves the row COMPLETELY empty: "save the defaults" must keep an
unset deployment byte-identical (the no-op injection contract)."""
body = dict(ALL_NULL_BODY)
for key, value in theming.BUILTIN_COLORS.items():
body[key] = value.upper() if key == "brand" else value
r = admin_client.put("/api/ui-settings", json=body)
assert r.status_code == 200, r.text
# The response is the effective values — still the built-ins…
for key in theming.COLOR_FIELDS:
assert r.json()[key] == theming.BUILTIN_COLORS[key]
# …and the row itself is empty (the upsert created a row of NULLs).
row = _row(db)
assert row is not None, "the PUT upsert creates the id-1 row"
assert row.id == 1
for field in (*theming.STRING_FIELDS, *theming.COLOR_FIELDS):
assert getattr(row, field) is None, f"{field} must be stored as NULL"
def test_put_empty_string_is_the_clear_operation(
admin_client: TestClient, db: Session
) -> None:
"""A whitespace-only (or empty) string trims to empty → NULL — the
clear operation, not a 422 and not a stored blank: the effective
value falls back to the env default."""
r = admin_client.put("/api/ui-settings", json={"app_name": " ", "footer_text": ""})
assert r.status_code == 200, r.text
row = _row(db)
assert row is not None
assert row.app_name is None
assert row.footer_text is None
# The response reports the effective (env) fallback, not "".
assert r.json()["app_name"] == get_settings().app_name
assert r.json()["footer_text"] == get_settings().footer_text
def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None:
"""GET reports the DB values over the defaults, column by column: a
row with ONLY ``bg`` set (hand-inserted) reports that color plus the
built-ins and the env strings — all 11 keys, no nulls."""
db.add(UiSettings(id=1, bg="#123456"))
db.commit()
r = admin_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["bg"] == "#123456" # the DB value wins
for key in theming.COLOR_FIELDS:
if key != "bg":
assert body[key] == theming.BUILTIN_COLORS[key]
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_upsert_creates_then_updates_the_id_1_row(
admin_client: TestClient, db: Session
) -> None:
"""The first PUT creates the id-1 row; the second updates the SAME
row (still exactly one row, still id 1 — the single-row contract)."""
r1 = admin_client.put(
"/api/ui-settings", json={"brand": "#123abc", "app_name": "First"}
)
assert r1.status_code == 200, r1.text
row = _row(db)
assert row is not None and row.id == 1
assert row.brand == "#123abc"
assert row.app_name == "First"
r2 = admin_client.put(
"/api/ui-settings", json={"brand": "#abcdef", "input_placeholder": "Second"}
)
assert r2.status_code == 200, r2.text
assert r2.json()["brand"] == "#abcdef"
assert r2.json()["input_placeholder"] == "Second"
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
rows = db.execute(select(UiSettings)).scalars().all()
assert len(rows) == 1, "the upsert must never create a second row"
assert rows[0].id == 1
assert rows[0].brand == "#abcdef" # updated, not appended
assert rows[0].input_placeholder == "Second" # the new string landed
assert rows[0].app_name is None # absent in the second body → NULL
+10 -7
View File
@@ -168,20 +168,23 @@ def test_tuning_shell_stays_hardcoded_46rem() -> None:
def test_no_other_hardcoded_46rem_rule_remains() -> None:
"""After the switch, the form columns are the ONLY rules with a
literal max-width: 46rem: .tuning-shell (phase 27) and
literal max-width: 46rem: .tuning-shell (phase 27),
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
FORM column, not a reading column, so it must not ride
--chat-column and phase 58's wide-desktop doubling must never
stretch the form). Every reading column rides the token (the
--chat-column base declaration is the other non-rule occurrence
of 46rem)."""
stretch the form), and .theme-shell (phase 91 task 04 — the
admin Theme editor is a form column too: the palette grid +
fieldsets must never ride the wide-desktop doubling). Every
reading column rides the token (the --chat-column base
declaration is the other non-rule occurrence of 46rem)."""
css = _css()
assert css.count("max-width: 46rem") == 2, (
"only the form columns (.tuning-shell, .doc-edit-shell) may "
"keep a literal max-width: 46rem"
assert css.count("max-width: 46rem") == 3, (
"only the form columns (.tuning-shell, .doc-edit-shell, "
".theme-shell) may keep a literal max-width: 46rem"
)
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
assert "max-width: 46rem" in _rule_block(css, ".theme-shell")
def test_comments_cite_the_wide_override_with_provenance() -> None: