"""Integration: the admin settings panel's read/write surface (phase 168, task 04) — ``/api/admin/settings`` GET / PUT ``/{key}`` / DELETE ``/{key}`` / DELETE all, driven through the real app (TestClient keeps the cookie jar — the house ``test_ui_settings_api`` / ``test_tokens_api`` pattern). The pins (00_phase, Testing & Quality): * the auth matrix — anonymous AND a signed-in token USER get 403 on ALL four routes (the router-wide ``require_admin`` closes the surface on every method), the admin 200s on all four; * the shape — ``items`` length == ``len(Settings.model_fields)`` (75), groups in ``GROUPS`` order (the stable order the E2E pins against), ``totals.count`` / ``overridden`` correct, ``overlay_enabled`` true; on an empty table a field whose ``BOR_*`` var the test env sets reports ``source="env"``, an untouched one ``source="default"`` and none reports ``"db"``; * write → live — a saved ``relevance_threshold`` is in ``get_settings()`` with NO restart (the cache clear + task-03 hooks are the whole job), the row is in the table with a non-null ``updated_at``, and the DB value reaches the ANONYMOUS ``/api/config`` endpoint through the same overlay; * errors — unknown key 404; the two LOCKED A3 keys 422 naming the env var; a bad shape 422 naming the field with NOTHING stored; a cross-field pair that only breaks in combination (the second PUT 422s and the first row is unchanged); * masking (LOCKED A4) — a stored secret reports ``masked``/``has_value`` only, the plaintext appears NOWHERE in the raw response text, a blank secret PUT 422s, and a DELETE clears it; * reset — a DELETE ``/{key}`` restores the env value (idempotent); DELETE ``""`` clears everything (``{"cleared": 3}``); * the kill switch (D6) — ``BOR_SETTINGS_DB=0``: ``overlay_enabled`` false, a PUT still stores the row, ``get_settings()`` still reports the env value — the switch disables the mechanism, not the panel; * the byte budget — the whole catalogue stays under the phase-167 INFO bar (64 KB), so it never floods the log; * fail-soft boot — a malformed row inserted DIRECTLY into the table (bypassing the API's 422 gate) never bricks: a fresh ``create_app()`` runs with it in the table, the row is skipped with a logged reason, the rest of the table still applies, and ``/api/health`` is 200. The module's teardown leaves NO row behind (the same discipline ``test_admin_theme_tab.py`` applies to ``ui_settings`` — a surviving row would retune every later suite through the overlay). Real Postgres (``podman compose up -d db``); no LLM involved. Requires: podman compose up -d db """ from __future__ import annotations import logging from collections.abc import Iterator import pytest from fastapi.testclient import TestClient from sqlalchemy import text from sqlalchemy.orm import Session from app.api import chat as chat_api from app.config import Settings, get_settings, invalidate_settings from app.core import settings_overlay from app.core.settings_catalog import CATALOG, GROUPS from app.core.settings_overlay import register_invalidator from app.main import app as fastapi_app from app.main import create_app from tests.conftest import ADMIN_PASSWORD API = "/api/admin/settings" @pytest.fixture(autouse=True) def clean_state( db: Session, monkeypatch: pytest.MonkeyPatch ) -> Iterator[None]: """A clean ``app_settings`` table + the overlay switch ON + a fresh overlay/hook slate (the ``test_admin_settings_live`` idiom: reset the process-wide hook list and re-wire what ``create_app()`` wires, so a save in this module runs the chat runtime reset). Teardown deletes the rows + invalidates + clears the cache — NO row may survive this module (a surviving row would retune every later suite through the overlay). """ settings_overlay.reset_state_for_tests() register_invalidator(chat_api.reset_runtime_caches) 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 _admin_client() -> TestClient: """A fresh client signed in as the admin (the ``_admin_client`` pattern from ``test_ui_settings_api``).""" c = TestClient(fastapi_app) r = c.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}" return c def _token_user_client(admin: TestClient, anon: TestClient) -> TestClient: """A signed-in token USER (role ``"user"`` — issue the token the way ``test_tokens_api`` does): the only non-admin identity, 403 on this surface on every method (the phase-79 token matrix contract).""" r = admin.post("/api/tokens", json={"label": "alice"}) assert r.status_code == 201, r.text token = r.json()["token"] assert anon.post("/api/token-auth", json={"token": token}).status_code == 204 return anon def _item(body: dict, key: str) -> dict: """One catalogue item by key (the GET body's ``items`` list).""" for item in body["items"]: if item["key"] == key: return item raise AssertionError(f"catalogue item missing: {key}") def test_auth_matrix_all_four_routes(client: TestClient, db: Session) -> None: """Anonymous AND token user: 403 ``admin only`` on ALL four routes (the router-wide dependency closes the surface on every method); the admin passes on all four.""" admin = _admin_client() token_user = _token_user_client(admin, client) for caller in (client, token_user): r = caller.get(API) assert r.status_code == 403 and r.json() == {"detail": "admin only"} r = caller.put(f"{API}/relevance_threshold", json={"value": "0.5"}) assert r.status_code == 403 and r.json() == {"detail": "admin only"} r = caller.delete(f"{API}/relevance_threshold") assert r.status_code == 403 and r.json() == {"detail": "admin only"} r = caller.delete(API) assert r.status_code == 403 and r.json() == {"detail": "admin only"} r = admin.get(API) assert r.status_code == 200 r = admin.put(f"{API}/top_n_docs", json={"value": "3"}) assert r.status_code == 200, r.text r = admin.delete(f"{API}/top_n_docs") assert r.status_code == 200, r.text r = admin.delete(API) assert r.status_code == 200, r.text assert r.json() == {"cleared": 0} assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0 def test_catalogue_shape_and_sources_on_empty_table(admin_client) -> None: """The whole catalogue, grouped and stable: 75 items (exactly ``Settings.model_fields``), groups in ``GROUPS`` order (each group a contiguous block, the block order = ``GROUPS``), ``totals`` correct, ``overlay_enabled`` true. On an empty table: the env-var-set field reports ``"env"``, the untouched one ``"default"`` and NOTHING reports ``"db"``.""" r = admin_client.get(API) assert r.status_code == 200, r.text body = r.json() items = body["items"] assert len(items) == len(Settings.model_fields) assert body["totals"] == { "count": len(Settings.model_fields), "overridden": 0, } assert body["overlay_enabled"] is True # The stable order: each group a contiguous block, block order GROUPS. groups_seen: list[str] = [] for item in items: assert item["group"] in GROUPS if not groups_seen or groups_seen[-1] != item["group"]: groups_seen.append(item["group"]) assert groups_seen == [g for g in GROUPS if g in groups_seen] assert len(groups_seen) == len(set(groups_seen)) # Every item carries the full contract (the panel generates from it). for item in items: assert set(item) == { "key", "env_var", "group", "label", "type", "secret", "restart_required", "overridable", "source", "value", "masked", "has_value", "has_override", "db_value", "default", } assert item["env_var"] == "BOR_" + item["key"].upper() assert item["source"] in ("db", "env", "default") # Empty table: env / default / never db. sources = {item["key"]: item["source"] for item in items} assert "db" not in sources.values() assert sources["input_placeholder"] == "env" # BOR_… set in tests/conftest.py assert sources["docs_work_dir"] == "default" # no BOR_… in the test env assert sources["relevance_threshold"] == "env" # pinned 0.30 in conftest def test_write_is_live_without_restart(admin_client, db: Session) -> None: """Write → live (no restart): the saved value is in ``get_settings()`` in the SAME process, the row is in the table with a non-null ``updated_at``, the item reports ``source="db"`` and ``totals.overridden == 1`` — and the DB value reaches the ANONYMOUS ``/api/config`` endpoint through the same overlay (the DB-over-env rule is deployment-wide, not panel-local).""" assert get_settings().relevance_threshold == 0.30 # the conftest env value r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.5"}) assert r.status_code == 200, r.text body = r.json() assert body["key"] == "relevance_threshold" assert body["source"] == "db" assert body["has_override"] is True assert body["db_value"] == "0.5" assert get_settings().relevance_threshold == 0.5 # NO restart (task 03) row = db.execute( text("SELECT value, updated_at FROM app_settings WHERE key = 'relevance_threshold'") ).first() assert row is not None assert row[0] == "0.5" assert row[1] is not None # the server-side now() r = admin_client.get(API) assert r.json()["totals"]["overridden"] == 1 # Visible through the public config endpoint (no auth) — the same # overlay retunes every consumer at once (D2). anon = TestClient(fastapi_app) r = anon.get("/api/config") assert r.status_code == 200 assert r.json()["input_placeholder"] == "Ask me anything…" r = admin_client.put(f"{API}/input_placeholder", json={"value": "Type your question…"}) assert r.status_code == 200, r.text r = anon.get("/api/config") assert r.status_code == 200 assert r.json()["input_placeholder"] == "Type your question…" def test_put_errors_404_and_422(admin_client, db: Session) -> None: """The write-path guards (LOCKED A5, A3, A4): unknown key 404; the two A3 keys 422 naming the env var; a bad shape 422 naming the FIELD with NOTHING stored; a cross-field pair that only breaks in combination — the second PUT 422s and the first row is unchanged.""" r = admin_client.put(f"{API}/bogus_key", json={"value": "1"}) assert r.status_code == 404 assert r.json() == {"detail": "Unknown setting bogus_key"} r = admin_client.put(f"{API}/database_url", json={"value": "postgresql://x"}) assert r.status_code == 422 assert "BOR_DATABASE_URL is not settable from the database" in r.json()["detail"] r = admin_client.put(f"{API}/embedding_dim", json={"value": "384"}) assert r.status_code == 422 assert "BOR_EMBEDDING_DIM is not settable from the database" in r.json()["detail"] # A bad shape: names the field, stores nothing. r = admin_client.put(f"{API}/relevance_threshold", json={"value": "abc"}) assert r.status_code == 422 assert "relevance_threshold" in r.json()["detail"] assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0 # The one list[str] field (K2): a malformed JSON array is a 422 # naming the field (the _coerce ValueError branch), nothing stored. r = admin_client.put(f"{API}/suggestions", json={"value": "[not json"}) assert r.status_code == 422 assert "suggestions" in r.json()["detail"] assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0 # A well-formed JSON array stores fine (the JSON round-trip works). r = admin_client.put( f"{API}/suggestions", json={"value": '["How do I deploy gitea?"]'} ) assert r.status_code == 200, r.text assert r.json()["db_value"] == '["How do I deploy gitea?"]' assert db.execute( text("SELECT value FROM app_settings WHERE key = 'suggestions'") ).scalar() == '["How do I deploy gitea?"]' # (Clean up the row — the cross-field pin below counts rows.) assert admin_client.delete(f"{API}/suggestions").status_code == 200 # The cross-field pair (only breaks in combination): store # relevance_threshold=0.2 (valid alone), then lexical_support_floor=0.9 # — above the DB-overridden threshold: the SECOND PUT 422s and the # first row is unchanged. r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.2"}) assert r.status_code == 200, r.text r = admin_client.put(f"{API}/lexical_support_floor", json={"value": "0.9"}) assert r.status_code == 422 assert "lexical_support_floor" in r.json()["detail"] value = db.execute( text("SELECT value FROM app_settings WHERE key = 'relevance_threshold'") ).scalar() assert value == "0.2" # the first row is unchanged assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 1 # The failed write did not disturb the live view. assert get_settings().relevance_threshold == 0.2 def test_secrets_are_masked_and_write_only(admin_client, db: Session) -> None: """LOCKED A4: a stored secret reports ``masked``/``has_value`` only — ``value``/``db_value`` are ``None`` and the plaintext appears NOWHERE in the raw response text (asserted on ``response.text``, not the parsed model); a blank secret PUT 422s (a self-lockout — DELETE is the explicit clear); a DELETE clears it.""" db.execute( text("INSERT INTO app_settings (key, value) VALUES ('llm_api_key', :v)"), {"v": "sk-secret-12345"}, ) db.commit() invalidate_settings() r = admin_client.get(API) assert r.status_code == 200 item = _item(r.json(), "llm_api_key") assert item["masked"] is True assert item["value"] is None assert item["db_value"] is None assert item["has_value"] is True # a non-empty value is in effect assert item["has_override"] is True assert item["source"] == "db" assert item["default"] == "" # masked: the code default is never shown assert "sk-secret-12345" not in r.text # NOWHERE in the raw body # The other write-only fields are masked too (set in the test env). for key in ("admin_password", "session_secret", "database_url"): other = _item(r.json(), key) assert other["masked"] is True and other["value"] is None # A blank secret PUT is a 422 (an empty API key / session secret # would self-lockout — DELETE is the explicit clear). r = admin_client.put(f"{API}/llm_api_key", json={"value": " "}) assert r.status_code == 422 assert "BOR_LLM_API_KEY must not be blank" in r.json()["detail"] assert db.execute( text("SELECT value FROM app_settings WHERE key = 'llm_api_key'") ).scalar() == "sk-secret-12345" # unchanged # A DELETE clears the row — the effective value falls back to the # env value (whatever this deployment's env/.env provides — the # test only pins that it is NOT the stored secret and that the # item reports the fallback state). r = admin_client.delete(f"{API}/llm_api_key") assert r.status_code == 200 assert r.json()["source"] == "default" # no BOR_LLM_API_KEY in the PROCESS env assert r.json()["has_override"] is False assert r.json()["value"] is None assert r.json()["has_value"] == bool(get_settings().llm_api_key) assert get_settings().llm_api_key != "sk-secret-12345" # the override is gone assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0 def test_reset_to_env_and_clear_all(admin_client, db: Session) -> None: """Reset semantics: DELETE ``/{key}`` deletes the row (idempotent — a double Reset is a success, not a 404) and the env value wins again; DELETE ``""`` clears every row (``{"cleared": 3}``) and ``totals.overridden`` is back to 0.""" r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.5"}) assert r.status_code == 200, r.text assert get_settings().relevance_threshold == 0.5 r = admin_client.delete(f"{API}/relevance_threshold") assert r.status_code == 200, r.text assert r.json()["source"] == "env" # BOR_RELEVANCE_THRESHOLD is set (0.30) assert r.json()["has_override"] is False assert get_settings().relevance_threshold == 0.30 # the env value wins again # Idempotent: the double Reset is a success, not a 404. r = admin_client.delete(f"{API}/relevance_threshold") assert r.status_code == 200, r.text # Three rows, then "Clear all overrides". for key, value in (("relevance_threshold", "0.5"), ("llm_retries", "7"), ("top_n_docs", "3")): r = admin_client.put(f"{API}/{key}", json={"value": value}) assert r.status_code == 200, r.text assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 3 r = admin_client.delete(API) assert r.status_code == 200 assert r.json() == {"cleared": 3} assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0 assert get_settings().relevance_threshold == 0.30 # env values win again r = admin_client.get(API) assert r.json()["totals"]["overridden"] == 0 def test_kill_switch_disables_the_mechanism_not_the_panel( admin_client, db: Session, monkeypatch: pytest.MonkeyPatch ) -> None: """D6: with ``BOR_SETTINGS_DB=0`` the catalogue reports ``overlay_enabled`` false (the panel shows a banner instead of pretending a save took effect), a PUT STILL stores the row, and ``get_settings()`` still reports the ENV value — the switch disables the mechanism, not the panel.""" r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.5"}) assert r.status_code == 200, r.text assert get_settings().relevance_threshold == 0.5 # the switch was ON # Deterministic env base for the pin (machine-independent): monkeypatch.setenv("BOR_LLM_RETRIES", "3") invalidate_settings() monkeypatch.setenv("BOR_SETTINGS_DB", "0") invalidate_settings() r = admin_client.get(API) assert r.status_code == 200 assert r.json()["overlay_enabled"] is False assert r.json()["totals"]["overridden"] == 0 # no row is IN EFFECT item = _item(r.json(), "relevance_threshold") assert item["source"] == "env" # the row exists but is not in effect assert item["has_override"] is True assert item["db_value"] == "0.5" # the stored string is visible (non-secret) assert get_settings().relevance_threshold == 0.30 # the env value stands # The panel stays writable: the PUT stores the row … r = admin_client.put(f"{API}/llm_retries", json={"value": "7"}) assert r.status_code == 200, r.text assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 2 # … but the env value stays in effect while the switch is off. assert get_settings().llm_retries == 3 # the env value (pinned 3 above) def test_catalogue_stays_under_the_byte_budget(admin_client) -> None: """The whole catalogue stays under the phase-167 INFO bar (64 KB — ``BOR_LOG_LARGE_RESPONSE_BYTES``), so ``GET /api/admin/settings`` never floods the log (75 items ≈ 20 KB; the audit script re-verifies the 256 KB budget in task 06).""" r = admin_client.get(API) assert r.status_code == 200 assert len(r.content) < 65_536 def test_unknown_delete_is_a_404(admin_client) -> None: """A DELETE of an UNKNOWN SETTING is a 404 like the PUT (a typo in the key is a client bug; the idempotency pin is about a known key whose row is already gone — the double Reset in ``test_reset_to_env_and_clear_all``).""" r = admin_client.delete(f"{API}/bogus_key") assert r.status_code == 404 assert r.json() == {"detail": "Unknown setting bogus_key"} def test_write_log_lines_carry_never_the_value( admin_client, caplog: pytest.LogCaptureFixture ) -> None: """AGENTS rule 10 / A4: one line per write (``settings action=…`` on the ``app.api.admin_settings`` logger) carrying the key and the action — NEVER the value (a secret write must not leak the plaintext into the log).""" caplog.set_level(logging.INFO, logger="app.api.admin_settings") r = admin_client.put(f"{API}/llm_api_key", json={"value": "sk-log-secret-xyz"}) assert r.status_code == 200, r.text set_lines = [rec.message for rec in caplog.records if rec.name == "app.api.admin_settings"] assert "settings action=set key=llm_api_key status=200" in set_lines assert "sk-log-secret-xyz" not in "\n".join(set_lines) caplog.clear() r = admin_client.delete(f"{API}/llm_api_key") assert r.status_code == 200 assert "settings action=clear key=llm_api_key" in [ rec.message for rec in caplog.records if rec.name == "app.api.admin_settings" ] caplog.clear() admin_client.put(f"{API}/top_n_docs", json={"value": "3"}) r = admin_client.delete(API) assert r.status_code == 200 and r.json() == {"cleared": 1} assert "settings action=clear_all count=1" in [ rec.message for rec in caplog.records if rec.name == "app.api.admin_settings" ] def test_bad_row_inserted_directly_never_bricks_boot( db: Session, caplog: pytest.LogCaptureFixture ) -> None: """Completion criterion (00_phase, item 5): a malformed row written straight into ``app_settings`` (bypassing the API's 422 gate — the operator SQL, the failed migration) must never brick boot. A FRESH ``create_app()`` — the full boot path: fail-loud auth gate, resolver prime, middleware wiring — runs with the bad row present, the row is SKIPPED with a logged reason, the rest of the table still applies, and ``/api/health`` is 200 (A5).""" # One bad row (the API's 422 gate would refuse it) + one good row: # the bad one is skipped, the good one stands. db.execute(text( "INSERT INTO app_settings (key, value)" " VALUES ('relevance_threshold', 'not-a-number')" )) db.execute(text( "INSERT INTO app_settings (key, value) VALUES ('top_n_docs', '3')" )) db.commit() caplog.set_level(logging.ERROR, logger="app.settings") # The boot path with the bad row in the table — must not raise. app = create_app() s = get_settings() env_only = Settings() # the env value the skipped key falls back to assert s.relevance_threshold == env_only.relevance_threshold assert s.top_n_docs == 3 # the rest of the table still applies assert any( "settings override ignored" in rec.message and "relevance_threshold" in rec.message for rec in caplog.records ) r = TestClient(app).get("/api/health") assert r.status_code == 200 def test_catalog_covers_every_settings_field(admin_client) -> None: """The catalogue's keys are exactly ``Settings.model_fields`` (the API-level face of the unit completeness pin — a new setting cannot be missing from the panel's data).""" r = admin_client.get(API) assert r.status_code == 200 assert {item["key"] for item in r.json()["items"]} == set(Settings.model_fields) assert set(CATALOG) == set(Settings.model_fields)