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
+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: