phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report: **Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)** - Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired. - No defects found; zero changes made — working tree left exactly as the task executors left it. - Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation. - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. - Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules. - Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted. - Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
@@ -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-")
|
||||
Reference in New Issue
Block a user