"""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. Phase 190 (tasks 01 + 02) adds the brand-string OWNERSHIP pins (A1): the Settings-panel row (``app_settings``, phase 168) beats a conflicting ``ui_settings`` (Theme-tab) row — the ``PUT /api/ui-settings`` string bake is dead (the PUT ignores string fields in the body and never writes the string columns), the GET reports the EFFECTIVE (panel) strings read-only, and the anonymous ``/api/config`` boot fetch serves the panel's name over a legacy baked-in tab row. Task 01 pinned those three as documented-RED ``xfail(strict=True)`` cases; task 02 flipped the resolution order and stopped the bake, so the markers are gone and all three are GREEN. The colors half of the PUT contract stayed GREEN throughout (the 17 colors, their normalization and the upsert behave exactly as phases 91/92/93 left them); the STRINGS half of the older phase-91 pins moved with the write path — a body string no longer round-trips, it is ignored. 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 select, text from sqlalchemy.orm import Session from app.config import get_settings, invalidate_settings from app.core import theming from app.main import app as fastapi_app from app.models import AppSetting, UiSettings from tests.conftest import ADMIN_PASSWORD #: The three brand strings and their distinct panel/tab/body values #: (phase 190) — one triple per field so every pin reads as one story. _PANEL_STRINGS = { "app_name": "Panel Name", "input_placeholder": "Panel placeholder…", "footer_text": "Panel footer", } _TAB_STRINGS = { "app_name": "Tab Name", "input_placeholder": "Tab placeholder…", "footer_text": "Tab footer", } _BODY_STRINGS = { "app_name": "Baked By PUT", "input_placeholder": "Baked placeholder…", "footer_text": "Baked footer", } @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 @pytest.fixture() def overlay_panel(db: Session, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """Phase 190: the settings overlay ON with an empty ``app_settings`` table (the ``test_admin_settings_api`` idiom) — the Settings panel's write path is only IN EFFECT through the overlay, so the pins that plant panel rows need the switch on. Teardown deletes the rows and re-resolves the cache — NO row and no stale resolution may survive this module (conftest pins the overlay OFF for every other suite).""" db.execute(text("DELETE FROM app_settings")) db.commit() monkeypatch.setenv("BOR_SETTINGS_DB", "1") invalidate_settings() yield db.execute(text("DELETE FROM app_settings")) db.commit() invalidate_settings() get_settings.cache_clear() def _seed_tab_row(db: Session, **colors: str) -> None: """The single ``ui_settings`` row with the three TAB strings, plus any colors the pin needs — committed directly (after task 02 the API no longer writes strings, so the seed must not depend on the behavior under test).""" db.add(UiSettings(id=1, **_TAB_STRINGS, **colors)) db.commit() def _seed_panel_rows(db: Session) -> None: """The three Settings-panel rows, committed (the panel writes them through ``PUT /api/admin/settings/{key}`` — the resolver only ever sees the table), then the settings cache is cleared so the next resolution layers them over the env base.""" db.add_all(AppSetting(key=key, value=value) for key, value in _PANEL_STRINGS.items()) db.commit() invalidate_settings() 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 COLORS, and a follow-up GET reads them back. Phase 190 (A1): the PUT body carries a brand string and the response reports the ENV one instead — the string is ignored, the palette half of the phase-91 round trip is unchanged.""" 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"] == get_settings().app_name # the string is ignored assert r.json()["brand"] == "#818cf8" r = client.get("/api/ui-settings") assert r.status_code == 200 assert r.json()["app_name"] == get_settings().app_name # never stored (A1) 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 test_admin_grid_line_validation_and_normalization( client: TestClient, db: Session ) -> None: """Phase 92 (task 01): the 9th identity color against the LIVE API — a bad hex is a 422 naming ``grid_line`` (same fixed detail as the other 8); the built-in value stores NULL (the response still reports the built-in — the no-op normalization); a non-built-in value is stored and reported back. The admin gate itself is pinned unchanged by the tests above (router-wide ``require_admin``).""" client.post("/api/login", json={"password": ADMIN_PASSWORD}) r = client.put("/api/ui-settings", json={"grid_line": "nope"}) assert r.status_code == 422, r.text assert r.json()["detail"] == "grid_line must be a #rrggbb hex color" r = client.put("/api/ui-settings", json={"grid_line": "#4a2626"}) assert r.status_code == 200, r.text assert r.json()["grid_line"] == theming.BUILTIN_COLORS["grid_line"] row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() assert row is not None, "the PUT upsert creates the id-1 row" assert row.grid_line is None # built-in → NULL normalization r = client.put("/api/ui-settings", json={"grid_line": "#123123"}) assert r.status_code == 200, r.text assert r.json()["grid_line"] == "#123123" r = client.get("/api/ui-settings") assert r.status_code == 200 assert r.json()["grid_line"] == "#123123" # the stored value reads back assert len(r.json()) == 20 # the 20-value shape (17 colors + 3 strings, phase 93) def test_admin_semantic_fields_round_trip(client: TestClient, db: Session) -> None: """Phase 93 (task 01): the 8 semantic state colors against the LIVE API — a bad hex is a 422 naming the field (same fixed detail as the identity colors); a non-built-in value is lowercased on store and reads back through GET; a built-in value stores NULL (the response still reports the built-in); an absent (null) field stores NULL. The response is the effective values for all 20 keys.""" client.post("/api/login", json={"password": ADMIN_PASSWORD}) r = client.put("/api/ui-settings", json={"ok_bg": "not-a-color"}) assert r.status_code == 422, r.text assert r.json()["detail"] == "ok_bg must be a #rrggbb hex color" r = client.put( "/api/ui-settings", json={"ok_ink": "#444444", "accent_bg": "#222222", "err_line": "#EFEFEF"}, ) assert r.status_code == 200, r.text assert r.json()["ok_ink"] == "#444444" # stored + reported assert r.json()["accent_bg"] == "#222222" # stored + reported assert r.json()["err_line"] == "#efefef" # upper → lowercased on store row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() assert row is not None, "the PUT upsert creates the id-1 row" assert row.ok_ink == "#444444" assert row.accent_bg == "#222222" assert row.err_line == "#efefef" # the stored column, lowercase assert row.err_bg is None # absent (null) field → NULL assert row.accent_ink is None # absent (null) field → NULL 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 len(body) == 20 # GET returns the EFFECTIVE values: the stored ones over the # built-ins for the untouched 14 colors. assert body["ok_ink"] == "#444444" assert body["err_bg"] == theming.BUILTIN_COLORS["err_bg"] assert body["accent_ink"] == theming.BUILTIN_COLORS["accent_ink"] untouched = [k for k in theming.COLOR_FIELDS if k not in ("ok_ink", "accent_bg", "err_line")] assert {k: body[k] for k in untouched} == { k: theming.BUILTIN_COLORS[k] for k in untouched } # the 14 untouched colors report their built-ins # The built-in → NULL normalization: PUT the built-in back (one in # uppercase) — the row's semantic columns return to NULL and the # effective values are still the built-ins (no-op contract). body_put = {"ok_ink": theming.BUILTIN_COLORS["ok_ink"].upper(), "accent_bg": theming.BUILTIN_COLORS["accent_bg"], "err_line": theming.BUILTIN_COLORS["err_line"]} r = client.put("/api/ui-settings", json=body_put) assert r.status_code == 200, r.text db.expire_all() row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() assert row is not None assert row.ok_ink is None # built-in (uppercase in) → NULL assert row.accent_bg is None # built-in → NULL assert row.err_line is None # built-in → NULL for key in theming.COLOR_FIELDS: assert r.json()[key] == theming.BUILTIN_COLORS[key] def _config_keys() -> set[str]: """The /api/config key set after task 03 (phase 91): the retired CSS-file theming's ``theme`` key is gone; phase 122 (task 01) added the ``images`` flag — the six keys below are the contract.""" return {"app_name", "version", "docs_repo_configured", "images", "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, overlay_panel: None ) -> None: """B1 with a set row: once the admin has saved the three brand strings, the ANONYMOUS /api/config (the frontend's boot fetch — no admin needed) serves them over the env values; the untouched fields keep the env values; the six-key set is unchanged (the retired ``theme`` key is absent). Phase 190 (A1) moved the writer: the strings are seeded through the Settings panel's surface (``app_settings`` + the overlay, the phase 168 ``PUT /api/admin/settings/{key}`` rows), because the Theme-tab PUT no longer writes them — the DB-over-env contract this pin owns is the same one, now owned by the panel.""" _seed_panel_rows(db) 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"] == _PANEL_STRINGS["app_name"] assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"] assert body["footer_text"] == _PANEL_STRINGS["footer_text"] # 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 # ---------- phase 190 (task 01): the brand-string ownership (A1) ---------- def test_put_ignores_string_fields_in_body( client: TestClient, db: Session ) -> None: """A1 (owner-confirmed 2026-10-18): the Theme-tab PUT no longer writes the three brand strings — a body carrying ``app_name`` / ``input_placeholder`` / ``footer_text`` DIFFERENT from the stored row's values (and from the effective value) is still 200, but the stored row's string columns stay EXACTLY as they were (re-GET shows the old values, a direct row read shows no write). An old client's save can therefore never re-bake a string and shadow the Settings panel again. GREEN since task 02 (phase 190 task 01 pinned it as a strict ``xfail`` on the defect: ``_validate_strings`` used to trim and store every string in the body — that bake is the mechanism of the ``TODO.md`` L3 incident). """ _seed_tab_row(db, bg="#101010") admin = _admin_client() r = admin.put("/api/ui-settings", json={**_BODY_STRINGS, "bg": "#001122"}) assert r.status_code == 200, r.text # The strings half — pre-fix the response round-trips the baked # body value ("Baked By PUT"); post-fix the stored value stands. assert r.json()["app_name"] == _TAB_STRINGS["app_name"] assert r.json()["input_placeholder"] == _TAB_STRINGS["input_placeholder"] assert r.json()["footer_text"] == _TAB_STRINGS["footer_text"] # The stored row itself was never touched (not just the response). db.expire_all() row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() assert row is not None assert row.app_name == _TAB_STRINGS["app_name"] assert row.input_placeholder == _TAB_STRINGS["input_placeholder"] assert row.footer_text == _TAB_STRINGS["footer_text"] # A follow-up GET shows the OLD values, not the body's. r = admin.get("/api/ui-settings") assert r.status_code == 200 assert r.json()["app_name"] == _TAB_STRINGS["app_name"] def test_put_still_stores_the_colors_beside_the_ignored_strings( client: TestClient, db: Session ) -> None: """The colors half of the same contract — GREEN pre-phase AND post-phase: the 17 colors in the same body as the ignored strings ARE stored and reported (the Theme tab stays a colors surface; the string drop does not narrow the palette half by one color).""" _seed_tab_row(db, bg="#101010") admin = _admin_client() r = admin.put("/api/ui-settings", json={**_BODY_STRINGS, "bg": "#001122"}) assert r.status_code == 200, r.text assert r.json()["bg"] == "#001122" # GREEN pre-fix and post-fix db.expire_all() row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() assert row is not None assert row.bg == "#001122" # GREEN pre-fix and post-fix assert row.brand is None # an unsent color stays NULL (the built-in reports) r = admin.get("/api/ui-settings") assert r.status_code == 200 assert r.json()["bg"] == "#001122" def test_get_reports_effective_strings_read_only( client: TestClient, db: Session, overlay_panel: None ) -> None: """A1 reaching the API: with a conflicting Settings-panel row, ``GET /api/ui-settings`` reports the EFFECTIVE (panel) value for each string — the tab's read side stays the live view (old-client compat), and it stays READ-ONLY (the GET never writes the row). GREEN since task 02 flipped the order (phase 190 task 01 pinned it as a strict ``xfail``: the endpoint used to report the baked ``ui_settings`` value). """ _seed_tab_row(db) _seed_panel_rows(db) admin = _admin_client() r = admin.get("/api/ui-settings") assert r.status_code == 200, r.text body = r.json() assert body["app_name"] == _PANEL_STRINGS["app_name"] assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"] assert body["footer_text"] == _PANEL_STRINGS["footer_text"] # The 20-value shape is unchanged (17 colors + 3 strings). assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS) # Read-only: the GET never wrote the effective values into the row. db.expire_all() row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() assert row is not None assert row.app_name == _TAB_STRINGS["app_name"] def test_api_config_reports_panel_strings_over_the_baked_tab_row( client: TestClient, db: Session, overlay_panel: None ) -> None: """The owner-visible incident through the frontend's boot fetch (phase 190, ``TODO.md`` L3): with a panel row AND a legacy baked-in ``ui_settings`` row, the ANONYMOUS ``GET /api/config`` — the very call ``brand.js`` drives the title / brand / footer / placeholder from — serves the Settings-panel strings, not the baked tab values. GREEN since task 02 (phase 190 task 01 pinned it as a strict ``xfail``: pre-fix ``effective_settings`` resolved the tab row first, so the open page kept the OLD name after every later panel change — the incident as the browser sees it). """ _seed_tab_row(db) _seed_panel_rows(db) r = client.get("/api/config") assert r.status_code == 200 # public, unchanged key set body = r.json() assert set(body) == _config_keys() assert body["app_name"] == _PANEL_STRINGS["app_name"] assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"] assert body["footer_text"] == _PANEL_STRINGS["footer_text"]