phase: 168_admin_settings_panel
Build and Push Containers / build-and-push-app (push) Successful in 4m4s
Build and Push Containers / build-and-push-db (push) Successful in 15s

**Phase 168 — Admin Settings panel — final verification: PASS**

1. **Implemented/verified:** `/settings.html` (admin-only) with the 75-field catalogue (`app/core/settings_catalog.py`), DB-over-env overrides in Postgres (`app_settings`, migration 0028 at head, `settings_overlay.py` resolver, `app/api/admin_settings.py` — 4 admin-only routes), live-apply on save with no restart + restart-required badges, Reset/Clear all, write-only secrets, `.env`-never-written, `BOR_SETTINGS_DB=0` kill switch.
2. **Phase E2E: 12/12** — run with `E2E_APP_PORT=8200` (ports 8123/8124 are held by an unrelated "undercooked" project; no process killed, no code change — the documented env override).
3. **Regression set: all 7 suites green in isolation** (theme tab, API tokens, configurable brand, global tuning, admin auth, cache busting, wide desktop column — 38 tests).
4. **Full suite: unit+integration exit 0 (4066 passed), app/ coverage 99%** (gate >90%); **ruff clean, pyright 0 errors / 0 warnings.**
5. **Log contract (rule 10) verified live:** per-turn + per-response byte-budget lines present; settings write lines carry key + action, **never the value**.
6. **Byte-budget audit: all endpoints under the 262,144 B budget**; settings catalogue 23,513 B (gzip 3,326).
7. **Defect fixed (one):** criterion 5 had no integration pin — added `test_bad_row_inserted_directly_never_bricks_boot` to `tests/integration/test_admin_settings_api.py` (malformed row inserted directly → fresh `create_app()` boots, row skipped with logged reason, rest of table applies, `/api/health` 200) — 12/12 green.
8. **Docs pass:** `README.md` and `AGENTS.md` reviewed in full — both consistent with the as-built feature (Settings page table, Settings section, Configuration-reference note, `.env.example` block); **no changes required**.
9. **Cleanup:** audit app killed (by PID); `app_settings` left empty (0 rows); all changes left in the working tree (no commit, per protocol).
10. **Completion criteria:** all 7 checkboxes verified green (criterion 7's commit is the harness's).
11. **Next pending phase:** `170_edit_bubble_sizing`.
This commit is contained in:
2026-10-10 01:05:14 -04:00
parent abe7388dda
commit 1200437bf8
79 changed files with 7148 additions and 378 deletions
@@ -0,0 +1,518 @@
"""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)
@@ -0,0 +1,217 @@
"""Phase 168 (task 03): the end-to-end proof of "no restart", in-process.
After ``invalidate_settings()`` (the save-side wrapper), the NEXT
request behaves under the new value — pinned for the three settings
that are NOT covered by the cache clear alone or that prove it end
to end: the retrieval honesty gate (``relevance_threshold`` — the
per-use read, ``app/api/chat.py``'s ``plan_turn`` gate), the shared
``LLMClient`` (rebuilt: model + timeout from the new ``Settings``
snapshot, ``app/api/chat.py::get_llm``), and the chat concurrency
semaphore (``chat_max_concurrent``). Plus the kill-switch counterpart
(``BOR_SETTINGS_DB=0`` with rows present → env values are back) and
the ``scripts/``-consumer surface (``import_extension_set``).
The rows are inserted directly through the ``db`` session (the admin
API is task 04); the save itself is ``invalidate_settings()`` —
exactly what task 04's PUT/DELETE call after the write.
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api import chat as chat_api
from app.config import get_settings, invalidate_settings
from app.core import settings_overlay
from app.core.settings_overlay import register_invalidator
from app.models import Document
from app.rag.retriever import RetrievedChunk
@pytest.fixture(autouse=True)
def _clean_state(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> Iterator[None]:
"""A clean ``app_settings`` table + a fresh overlay/hook slate.
``reset_state_for_tests()`` clears the process-wide hook list (a
prior suite's test may have wiped the hook ``app.main`` registered
at import — re-wire what ``create_app()`` wires: the chat runtime
reset), then the table is emptied and the switch is ON for the
test. Teardown deletes the rows and invalidates (runs the reset
hook — a test-built ``LLMClient``/semaphore snapshot never leaks
into a later suite) + clears the cache; ``monkeypatch`` then
restores ``BOR_SETTINGS_DB`` and the next resolution is the
conftest-pinned env-only view.
"""
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 _save(db: Session, key: str, value: str) -> None:
"""One upserted override row (the panel's Save, minus the API)."""
db.execute(
text(
"INSERT INTO app_settings (key, value) VALUES (:k, :v)"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
),
{"k": key, "v": value},
)
db.commit()
def _doc(title: str) -> Document:
return Document(
id=uuid.uuid4(),
source="Homelab",
path=f"{title.lower().replace(' ', '-')}.md",
full_path="/tmp/doc.md",
title=title,
content=f"{title} content body for the gate pin",
summary=None,
content_hash="0" * 64,
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
)
def _chunk(doc: Document, score: float, cosine: float) -> RetrievedChunk:
"""A non-FTS candidate: *cosine* is the honesty-gate input."""
return RetrievedChunk(
chunk_id=uuid.uuid4(),
position=0,
content=doc.content[:32],
score=score,
document=doc,
cosine=cosine,
)
# ---------------------------------------------------------------------------
# the per-use read: the A8 gate behaves under the saved threshold
# ---------------------------------------------------------------------------
def test_relevance_threshold_db_value_bites_in_the_gate(db: Session) -> None:
base = get_settings().relevance_threshold
if base >= 0.95:
pytest.skip("env threshold too close to 1.0 to bracket a DB override")
# A DB value over the base; a cosine BETWEEN them — grounded under
# the base, deflected under the DB value (no FTS: fts_hits = 0).
db_value = min(base + 0.2, 0.9)
cosine = (base + db_value) / 2
chunk = _chunk(_doc("Gate Pin"), score=0.1, cosine=cosine)
assert chat_api.plan_turn([chunk], get_settings()).deflected is False
_save(db, "relevance_threshold", str(db_value))
invalidate_settings()
assert get_settings().relevance_threshold == db_value
# the SAME chunk, the SAME function, the overlay-fed settings —
# the gate now deflects (no process restart in between)
assert chat_api.plan_turn([chunk], get_settings()).deflected is True
# ---------------------------------------------------------------------------
# the LLM client snapshot: Save swaps the shared LLMClient
# ---------------------------------------------------------------------------
def test_llm_client_rebuilt_from_saved_values(db: Session) -> None:
_save(db, "llm_chat_model", "gpt-test")
_save(db, "llm_timeout", "7")
invalidate_settings()
llm = chat_api.get_llm()
# the rebuilt client holds the DB-overridden snapshot…
assert llm.settings.llm_chat_model == "gpt-test"
assert llm.settings.llm_timeout == 7.0
# …and its AsyncOpenAI transport was built from it (base_url +
# the timeout's connect value — the way llm.py's __init__ builds
# it; the openai client enforces a trailing slash on base_url)
assert llm._client.base_url == ( # pyright: ignore[reportPrivateUsage]
llm.settings.llm_base_url.rstrip("/") + "/"
)
assert llm._client._client.timeout.connect == 7.0 # pyright: ignore[reportPrivateUsage]
def test_llm_client_repoints_on_the_next_save(db: Session) -> None:
_save(db, "llm_chat_model", "gpt-first")
invalidate_settings()
assert chat_api.get_llm().settings.llm_chat_model == "gpt-first"
# the NEXT save re-points the singleton (not just the first)
_save(db, "llm_chat_model", "gpt-second")
invalidate_settings()
llm = chat_api.get_llm()
assert llm.settings.llm_chat_model == "gpt-second"
# ---------------------------------------------------------------------------
# the chat concurrency semaphore follows the saved cap
# ---------------------------------------------------------------------------
def test_chat_semaphore_follows_saved_cap(db: Session) -> None:
base_cap = get_settings().chat_max_concurrent
first = 3 if base_cap != 3 else 5
_save(db, "chat_max_concurrent", str(first))
invalidate_settings()
assert get_settings().chat_max_concurrent == first
sem = chat_api._get_chat_semaphore()
assert sem._value == first # pyright: ignore[reportPrivateUsage]
# the next save re-points the semaphore too
second = 5 if first == 3 else 3
_save(db, "chat_max_concurrent", str(second))
invalidate_settings()
rebuilt = chat_api._get_chat_semaphore()
assert rebuilt is not sem
assert rebuilt._value == second # pyright: ignore[reportPrivateUsage]
# ---------------------------------------------------------------------------
# the kill switch: rows present, switch off → the env values are back
# ---------------------------------------------------------------------------
def test_kill_switch_returns_env_values(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
base = get_settings().input_placeholder
_save(db, "input_placeholder", "DB-WINS-PLACEHOLDER")
invalidate_settings()
assert get_settings().input_placeholder == "DB-WINS-PLACEHOLDER"
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
invalidate_settings()
assert get_settings().input_placeholder == base
# ---------------------------------------------------------------------------
# the scripts/ consumer: the importer reads the same overlay
# ---------------------------------------------------------------------------
def test_import_extensions_override_reaches_script_consumer(db: Session) -> None:
base = get_settings().import_extension_set
# a REAL change — guard against a dev .env that already carries
# exactly this set (a no-op would still pass the final assert)
if base == {".md", ".txt", ".csv"}:
saved, expect = "md,txt,csv,ini", {".md", ".txt", ".csv", ".ini"}
else:
saved, expect = "md,txt,csv", {".md", ".txt", ".csv"}
_save(db, "import_extensions", saved)
invalidate_settings()
assert get_settings().import_extension_set == expect
@@ -88,6 +88,7 @@ SHELL_BACKED_PAGES = {
"/history.html": "index.html", # phase 76 task 03
"/tokens.html": "index.html", # phase 79 task 06
"/theme.html": "index.html", # phase 91 task 04
"/settings.html": "index.html", # phase 168 task 05
}
+283
View File
@@ -0,0 +1,283 @@
"""Integration: migration 0028 (app_settings) schema contract
(phase 168, task 01).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0017.py`` (information_schema assertions on the state
the migration must leave). The tests target the 0027 → 0028 step
explicitly so later migrations cannot break them:
* upgrade 0027 → 0028 → the ``app_settings`` table exists with the
full column contract — ``key`` VARCHAR(100) NOT NULL and the
primary key (one row per BARE ``Settings`` field name; the PK is
the uniqueness guard — two rows for one setting would make
"which one wins" undefined), ``value`` TEXT NOT NULL (the env-
native string form; the reset is a DELETE, never an empty value),
``updated_at`` TIMESTAMPTZ NOT NULL with the now() server default
(house style) — while the 0027 schema (``documents.
page_strip_mode``) survives;
* inserted rows round-trip (``relevance_threshold`` / ``"0.55"`` and
``import_extensions`` / ``"md,txt"``);
* a duplicate ``key`` raises ``IntegrityError`` (the PK pin) and a
NULL ``value`` is rejected (NOT NULL);
* downgrade to 0027 → the table is GONE (A13 — reversible), the rest
of the schema (``documents.page_strip_mode``, ``ui_settings``)
survives;
* upgrade back to 0028 → the table is back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown
upgrades to head no matter what happened, so the dev DB is never
left below head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _table_exists(db: Session, table: str) -> bool:
return (
db.execute(
text("SELECT 1 FROM information_schema.tables WHERE table_name = :t"),
{"t": table},
).scalar()
is not None
)
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default, character_maximum_length)
for one table column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default, character_maximum_length"
" FROM information_schema.columns"
" WHERE table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
).fetchone()
return tuple(row) if row is not None else None
def _pk_columns(db: Session, table: str) -> list[str]:
"""The table's PRIMARY KEY columns in ordinal position."""
rows = db.execute(
text(
"SELECT kcu.column_name"
" FROM information_schema.table_constraints tc"
" JOIN information_schema.key_column_usage kcu"
" ON kcu.constraint_name = tc.constraint_name"
" AND kcu.table_name = tc.table_name"
" WHERE tc.table_name = :t"
" AND tc.constraint_type = 'PRIMARY KEY'"
" ORDER BY kcu.ordinal_position"
),
{"t": table},
).fetchall()
return [r[0] for r in rows]
def _clear_rows(db: Session) -> None:
db.execute(text("DELETE FROM app_settings"))
db.commit()
def test_upgrade_to_0028_creates_app_settings(db: Session, alembic: Config) -> None:
"""Upgrade 0027 → 0028: the table exists with the full column
contract (``key`` VARCHAR(100) NOT NULL + the sole PK; ``value``
TEXT NOT NULL; ``updated_at`` TIMESTAMPTZ NOT NULL with the now()
server default), and the table is ABSENT at 0027 while the 0027
``documents`` schema survives the upgrade."""
command.downgrade(alembic, "0027") # start from the pre-0028 state
assert _version(db) == "0027"
assert not _table_exists(db, "app_settings"), (
"app_settings must be absent at 0027"
)
command.upgrade(alembic, "0028")
assert _version(db) == "0028", "alembic_version must be at 0028"
assert _table_exists(db, "app_settings"), "the table must exist at 0028"
key = _column(db, "app_settings", "key")
assert key is not None, "app_settings.key is missing"
assert key[0] == "character varying", "key must be VARCHAR"
assert key[1] == "NO", "key must be NOT NULL (PK)"
assert key[2] is None, "key must have no server default"
assert key[3] == 100, "key must be String(100)"
value = _column(db, "app_settings", "value")
assert value is not None, "app_settings.value is missing"
assert value[0] == "text", "value must be TEXT (the env-native string form)"
assert value[1] == "NO", "value must be NOT NULL (reset is a DELETE, never empty)"
updated = _column(db, "app_settings", "updated_at")
assert updated is not None, "app_settings.updated_at is missing"
assert updated[0] == "timestamp with time zone", "updated_at must be TIMESTAMPTZ"
assert updated[1] == "NO", "updated_at must be NOT NULL"
assert updated[2] is not None and "now" in str(updated[2]), (
"updated_at must carry the now() server default (house style)"
)
assert _pk_columns(db, "app_settings") == ["key"], (
"key must be the sole primary key (the uniqueness guard —"
" two rows for one setting make 'which one wins' undefined)"
)
# The 0027 schema survives the additive upgrade.
strip = _column(db, "documents", "page_strip_mode")
assert strip is not None, "documents.page_strip_mode (0027) must survive"
assert strip[3] == 16, "page_strip_mode must stay String(16)"
def test_inserted_rows_round_trip(db: Session, alembic: Config) -> None:
"""At 0028, the env-native string forms round-trip verbatim
(``relevance_threshold`` / ``"0.55"``, ``import_extensions`` /
``"md,txt"``) and ``updated_at`` is stamped by the server default."""
command.upgrade(alembic, "head")
try:
db.execute(
text(
"INSERT INTO app_settings (key, value)"
" VALUES ('relevance_threshold', '0.55')"
)
)
db.execute(
text(
"INSERT INTO app_settings (key, value)"
" VALUES ('import_extensions', 'md,txt')"
)
)
db.commit()
rows = db.execute(
text(
"SELECT key, value, updated_at"
" FROM app_settings ORDER BY key"
)
).fetchall()
assert len(rows) == 2, "both rows must be stored"
assert rows[0][0] == "import_extensions", "key must round-trip verbatim"
assert rows[0][1] == "md,txt", "the CSV string form must round-trip"
assert rows[0][2] is not None, "updated_at must be stamped (server default)"
assert rows[1][0] == "relevance_threshold"
assert rows[1][1] == "0.55", "the numeric string form must round-trip"
finally:
_clear_rows(db)
def test_duplicate_key_is_rejected(db: Session, alembic: Config) -> None:
"""The PK is the uniqueness guard: two rows for one setting would
make 'which one wins' undefined, so the second insert must raise
``IntegrityError``."""
command.upgrade(alembic, "head")
try:
db.execute(
text("INSERT INTO app_settings (key, value) VALUES ('llm_retries', '3')")
)
db.commit()
with pytest.raises(IntegrityError):
db.execute(
text(
"INSERT INTO app_settings (key, value)"
" VALUES ('llm_retries', '5')"
)
)
db.rollback() # the IntegrityError aborts the open transaction
finally:
_clear_rows(db)
def test_null_value_is_rejected(db: Session, alembic: Config) -> None:
"""``value`` is NOT NULL: 'clear the override' is a DELETE of the
row, never a NULL/empty value, so the resolver never has to
distinguish 'no override' from 'override to empty'."""
command.upgrade(alembic, "head")
try:
with pytest.raises(IntegrityError):
db.execute(
text(
"INSERT INTO app_settings (key, value)"
" VALUES ('chat_max_concurrent', NULL)"
)
)
db.rollback() # the IntegrityError aborts the open transaction
stored = db.execute(
text("SELECT count(*) FROM app_settings")
).scalar()
assert stored == 0, "the rejected row must not be stored"
finally:
_clear_rows(db)
def test_downgrade_to_0027_drops_the_table(db: Session, alembic: Config) -> None:
"""Downgrade 0028 → 0027: the table is gone (A13 — fully
reversible) while the rest of the schema survives (the 0027
``documents.page_strip_mode`` column, ``ui_settings``,
``api_tokens``). The values are re-enterable from the panel —
they are an override of the env, not a source of truth."""
command.downgrade(alembic, "0027")
assert _version(db) == "0027"
assert not _table_exists(db, "app_settings"), "app_settings must be dropped"
strip = _column(db, "documents", "page_strip_mode")
assert strip is not None and strip[3] == 16, (
"documents.page_strip_mode (0027) must survive the downgrade"
)
semantic = _column(db, "ui_settings", "ok_bg")
assert semantic is not None and semantic[3] == 7, (
"ui_settings.ok_bg must survive the downgrade"
)
token_col = _column(db, "api_tokens", "token_hash")
assert token_col is not None and token_col[0] == "character varying", (
"api_tokens.token_hash must survive the downgrade"
)
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0027, then upgrade back to 0028: the table is back
with the column contract and PK intact (A13 round-trip)."""
command.downgrade(alembic, "0027")
command.upgrade(alembic, "0028")
assert _version(db) == "0028", "round-trip upgrade must land at 0028"
assert _table_exists(db, "app_settings"), "the table must be back"
key = _column(db, "app_settings", "key")
assert key is not None, "app_settings.key must be back"
assert key[0] == "character varying", "key must be VARCHAR after the round-trip"
assert key[1] == "NO", "key must be NOT NULL after the round-trip"
assert key[3] == 100, "key must be String(100) after the round-trip"
value = _column(db, "app_settings", "value")
assert value is not None, "app_settings.value must be back"
assert value[0] == "text", "value must be TEXT after the round-trip"
assert value[1] == "NO", "value must be NOT NULL after the round-trip"
assert _pk_columns(db, "app_settings") == ["key"]