"""Integration: the themed favicon route — ``GET /favicon.svg`` (phase 177, task 02, D2; A2/A3 owner-confirmed) + the template pins (task 03, D3 — the route is the SINGLE source; the static file is deleted). The route's contract, driven through the real app (TestClient + the house ``db`` fixture — the ``test_ui_settings_api`` pattern): * **default state** (no ``ui_settings`` row): 200 ``image/svg+xml`` + ``Cache-Control: no-cache``, the body byte-identical to the golden art (``FAVICON_GOLDEN`` from ``tests.unit.test_theming`` — ONE source, so the route and the renderer can never serve different bytes), the CSP header present (the ``SecurityHeadersMiddleware`` pass-through — no new header wiring was needed); * **themed state**: a set row's ``surface``/``brand``/``brand_ink`` land in their exact SVG attribute roles (asserted by PARSING the attributes — the ``test_theming`` unit-pin idiom, not substring) and none of the other 14 built-ins appears in the body; * **no process cache**: a row update is visible on the very next request — the palette is re-read per request (the runtime-change contract; a theme change never needs a restart to reach the tab icon); * **DB-failure state**: ``effective_settings`` raising (a DB blip / a pre-migration boot) → 200 with the built-in golden bytes (NEVER 500 — the never-break contract) + the fallback log line; * **HEAD parity**: the same headers as GET, an empty body (the phase-76 static-file parity idiom — the pre-phase static file answered both methods); * **routes-first**: a file named ``favicon.svg`` in the static dir could NOT shadow the route (the registration position holds); * **task 03 (template pins)**: all five served page bodies link the EXACT bare ```` (NO ``?v=`` — the href is not under ``/assets/``, so ``CachingMiddleware``'s ``_ASSET_REF_RE`` can never touch it; the pin keeps that contract explicit against a future regex drift) and contain no ``/assets/favicon.svg`` reference; ``GET /assets/favicon.svg`` 404s (the file is deleted); a tree scan of ``frontend/`` finds zero remaining ``/assets/favicon.svg`` references. The route is deliberately OUTSIDE ``/api/*`` (no per-response byte-budget line — AGENTS rule 10 is ``/api/*``-only) and carries NO auth dependency (the favicon is chrome — public like the login page): every test below is the anonymous ``client``. Real Postgres (``podman compose up -d db``); no LLM involved. Requires: podman compose up -d db """ from __future__ import annotations import logging import xml.etree.ElementTree as ET from collections.abc import Iterator from pathlib import Path import pytest from fastapi.testclient import TestClient from sqlalchemy import text, update from sqlalchemy.orm import Session from app.config import get_settings from app.core import theming from app.models import UiSettings from tests.unit.test_theming import FAVICON_GOLDEN # Phase 177 (task 03, D3): the template pins — every page links the # BARE route; the static file is gone. #: The EXACT favicon link tag every template must carry (bare — no #: ``?v=``: the href is not under ``/assets/``). FAVICON_LINK_TAG = ( '' ) #: The five L8 pages — the shell + the four standalone pages. PAGE_PATHS: tuple[str, ...] = ( "/", "/document.html", "/login.html", "/shared.html", "/doc-edit.html", ) #: The frontend tree root (the house tree-scan idiom). FRONTEND = Path(__file__).resolve().parents[2] / "frontend" @pytest.fixture(autouse=True) def clean_state(db: Session) -> Iterator[None]: """The single ui_settings row is global state — wiped so every test starts from the state it asserts (the house DELETE reset pattern, ``test_ui_settings_api``).""" db.execute(text("DELETE FROM ui_settings")) db.commit() yield db.execute(text("DELETE FROM ui_settings")) db.commit() def _assert_roles( svg_text: str, *, surface: str, brand: str, brand_ink: str ) -> None: """The three hexes in their EXACT SVG attribute roles — asserted by PARSING the attributes (the ``test_theming`` unit-pin idiom, not substring) — the geometry invariants along the way (the hexagon ``d`` path, the three element order), and none of the other 14 built-in hexes anywhere in the body (a themed favicon must not leak a built-in color).""" ns = "{http://www.w3.org/2000/svg}" root = ET.fromstring(svg_text) assert root.tag == f"{ns}svg" hexagon, circle, spokes = list(root) assert hexagon.tag == f"{ns}path" assert hexagon.get("d") == "M32 4 55 18v28L32 60 9 46V18Z" assert hexagon.get("fill") == surface assert hexagon.get("stroke") == brand assert circle.tag == f"{ns}circle" assert circle.get("fill") == brand assert spokes.tag == f"{ns}path" assert spokes.get("stroke") == brand_ink for key, value in theming.BUILTIN_COLORS.items(): if value not in (surface, brand, brand_ink): assert value not in svg_text, ( f"built-in {key} ({value}) must not appear in a themed favicon" ) def test_default_state_serves_the_golden_bytes( client: TestClient, db: Session ) -> None: """No ui_settings row: the BUILT-IN palette renders — 200 ``image/svg+xml`` + ``Cache-Control: no-cache``, the body byte-identical to the golden art (the D1 contract through the wire), and the CSP header is present on the response (the ``SecurityHeadersMiddleware`` pass-through — no header change was needed; the phase-123 ``img-src 'self' data:`` carve-out is part of the policy that covers task 04's ``data:`` preview).""" assert ( db.execute(text("SELECT count(*) FROM ui_settings")).scalar() == 0 ), "the test starts from a row-missing state" r = client.get("/favicon.svg") assert r.status_code == 200 assert r.headers["content-type"] == "image/svg+xml" assert r.headers["cache-control"] == "no-cache" assert r.text == FAVICON_GOLDEN assert "content-security-policy" in r.headers assert "default-src 'self'" in r.headers["content-security-policy"] assert "img-src 'self' data:" in r.headers["content-security-policy"] def test_themed_state_carries_the_three_roles( client: TestClient, db: Session ) -> None: """A set row (distinct ``surface``/``brand``/``brand_ink``): the three hexes land in their exact SVG attribute roles (parsed, not substring) and none of the other 14 built-ins appears in the body; the header contract (no-cache + content-type) is unchanged by the palette.""" db.add( UiSettings( id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc" ) ) db.commit() r = client.get("/favicon.svg") assert r.status_code == 200 assert r.headers["content-type"] == "image/svg+xml" assert r.headers["cache-control"] == "no-cache" _assert_roles( r.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc" ) def test_themed_state_is_live_no_process_cache( client: TestClient, db: Session ) -> None: """The runtime-change contract (D2): the palette is re-read per request — NO process cache, so a row update is visible on the very next request (a theme change never needs a restart to reach the tab icon), and the OLD palette is gone from the body (nothing stale is served).""" db.add( UiSettings( id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc" ) ) db.commit() r1 = client.get("/favicon.svg") assert r1.status_code == 200 _assert_roles( r1.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc" ) db.execute( update(UiSettings) .where(UiSettings.id == 1) .values(surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd") ) db.commit() r2 = client.get("/favicon.svg") assert r2.status_code == 200 _assert_roles( r2.text, surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd" ) for old in ("#102030", "#4f46e5", "#a5b4fc"): assert old not in r2.text def test_db_failure_serves_the_builtin_art_with_200( client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """A DB blip / pre-migration boot (``effective_settings`` raising): the handler's fallback serves the BUILT-IN golden bytes with 200 (NEVER 500 — the never-break contract) and logs the failure via ``logger.exception`` (the ``caching.py`` DB-blip pattern).""" def _boom(_session: Session) -> dict[str, str]: raise RuntimeError("db blip (test)") monkeypatch.setattr(theming, "effective_settings", _boom) with caplog.at_level(logging.ERROR, logger="app"): r = client.get("/favicon.svg") assert r.status_code == 200 assert r.headers["content-type"] == "image/svg+xml" assert r.headers["cache-control"] == "no-cache" assert r.text == FAVICON_GOLDEN assert any( "favicon: theme read failed" in record.message for record in caplog.records ), "the fallback path must log the failure (logger.exception)" def test_head_parity_matches_get(client: TestClient, db: Session) -> None: """``HEAD /favicon.svg``: 200 with the SAME headers as GET (content-type, no-cache, the CSP pass-through) and an empty body (the phase-76 static-file parity idiom — the pre-phase static file answered both methods, the dynamic route keeps that).""" r = client.head("/favicon.svg") assert r.status_code == 200 assert r.headers["content-type"] == "image/svg+xml" assert r.headers["cache-control"] == "no-cache" assert "content-security-policy" in r.headers assert r.content == b"" def test_route_wins_over_the_static_mount(client: TestClient) -> None: """Routes-first: a file named ``favicon.svg`` in the static dir could NOT shadow the route — the file is created with SHADOW-ATTEMPT bytes (and deleted again in the ``finally``), and the response is still the golden art (the registration position — after the router includes, before the static catch-all mount — holds).""" shadow = Path(get_settings().static_dir).resolve() / "favicon.svg" assert not shadow.exists(), ( "the test must start without a static-dir favicon.svg" ) shadow.write_text("SHADOW ATTEMPT", encoding="utf-8") try: r = client.get("/favicon.svg") assert r.status_code == 200 assert r.text == FAVICON_GOLDEN assert "SHADOW ATTEMPT" not in r.text finally: shadow.unlink() def test_route_is_public_no_auth_dependency(client: TestClient) -> None: """The favicon is chrome — public like the login page: the anonymous (unsigned-in) client gets 200, and the route carries no ``/api/`` prefix (deliberately outside ``/api/*`` — no per-response byte-budget line, AGENTS rule 10's scope).""" assert client.get("/api/whoami").json() == { "authenticated": False, "role": "anonymous", } r = client.get("/favicon.svg") assert r.status_code == 200 # ---------- Phase 177 (task 03, D3): the template pins ---------- @pytest.mark.parametrize("path", PAGE_PATHS) def test_served_page_links_the_bare_route( client: TestClient, path: str ) -> None: """Each of the five served page bodies carries the EXACT bare favicon link (task 03, D3 — ``href="/favicon.svg"``) and contains NO ``/assets/favicon.svg`` reference of any kind. The link stays BARE — no ``?v=`` (the href is not under ``/assets/``, so ``CachingMiddleware``'s ``_ASSET_REF_RE`` can never touch it; the pin keeps that contract explicit against a future regex drift).""" r = client.get(path) assert r.status_code == 200 assert FAVICON_LINK_TAG in r.text, f"{path}: favicon link pin missing" assert "/assets/favicon.svg" not in r.text assert "/favicon.svg?v=" not in r.text, ( f"{path}: the favicon link must stay bare (no ?v= version token)" ) def test_the_static_favicon_file_is_gone(client: TestClient) -> None: """The single-source decision (task 03, D3): ``frontend/assets/ favicon.svg`` no longer exists — on disk AND over the wire (the static catch-all 404s it). Nothing is lost: the built-in bytes stay pinned by ``FAVICON_GOLDEN`` in ``test_theming`` (task 01's golden constant is the durable contract), and ``GET /favicon.svg`` still serves the golden art (the route is the single source now).""" assert not (FRONTEND / "assets" / "favicon.svg").exists() r = client.get("/assets/favicon.svg") assert r.status_code == 404 r = client.get("/favicon.svg") assert r.status_code == 200 assert r.text == FAVICON_GOLDEN def test_tree_scan_has_zero_assets_favicon_references() -> None: """A tree scan of ``frontend/`` (the house idiom — every file, markup + scripts + styles, comments included): ZERO remaining references to ``/assets/favicon.svg`` (the deletion is complete — no template, script, or stylesheet still asks for the retired URL).""" files = [ p for p in sorted(FRONTEND.rglob("*")) if p.is_file() ] assert files, "frontend/ contains no files — test is blind" for path in files: assert "/assets/favicon.svg" not in path.read_text(encoding="utf-8"), ( f"/assets/favicon.svg survived in {path.relative_to(FRONTEND)}" )