Files
turnstone/tests/test_settings_api.py
T
Patrick Buckley 101afd84da feat: database-backed settings (ConfigStore) with admin API (#59)
* feat: database-backed settings (ConfigStore) with admin API

Replace config.toml for non-bootstrap settings on the server with a
database-backed ConfigStore.  ~40 settings across model, session,
tools, server, mcp, ratelimit, health, judge, and memory sections are
now managed via the admin Settings API.  CLI flags for these settings
removed from the server entry point (CLI standalone tool unchanged).

Storage: system_settings table (migration 015) with composite PK
(key, node_id) for per-node overrides.  ON CONFLICT upsert in both
SQLite and PostgreSQL.  admin.settings permission granted to
builtin-admin role.

Settings registry (settings_registry.py): code-defined catalog of
all known settings with types, defaults, validation, descriptions.
Registry defaults aligned with previous argparse defaults.

ConfigStore (config_store.py): thread-safe in-memory cache loaded
from storage on init.  Lock-free reads via dict snapshot swap.
reload() for hot-reload via internal endpoint.

Secret settings (judge.api_key) blocked from write via admin API
(403) — must be configured via config.toml or env vars.

warn_migrated_settings() logs warnings for config.toml keys that
overlap with ConfigStore-managed settings.

Console admin API: GET /v1/api/admin/settings (list with effective
values), GET .../schema (registry catalog), PUT .../{key} (update),
DELETE .../{key} (reset to default).  Audit trail on mutations.

MQ: ConfigChangeEvent for cross-node cache invalidation (emission
from console deferred to bridge integration).

Python + TypeScript SDK methods.  63 new tests.  Feature docs at
docs/settings.md, PlantUML diagram 24-settings-architecture.

* fix: address PR review — config-reload scope, registry defaults, doc alignment

- config-reload endpoint requires approve scope (was write)
- config-reload handler is sync def (avoids blocking event loop)
- reasoning_effort passes empty string through (removes `or "medium"`)
- ratelimit.requests_per_second changed to float (matches RateLimiter)
- session.retention_days allows 0 (disable pruning)
- ratelimit.trusted_proxies added to registry + wired in server
- admin_list_settings filters to global settings only (no node_id ambiguity)
- admin_update_setting validates "value" key presence (400 if missing)
- Console config change fans out reload to nodes directly (no MQ dep)
- Docs aligned with actual API response shapes and masking ("***")
2026-03-14 11:42:18 -07:00

292 lines
9.8 KiB
Python

"""Tests for system settings admin API endpoints."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
from turnstone.console.server import (
admin_delete_setting,
admin_list_settings,
admin_settings_schema,
admin_update_setting,
)
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
# ---------------------------------------------------------------------------
# Auth bypass middleware
# ---------------------------------------------------------------------------
class _InjectAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset(
{
"read",
"write",
"approve",
"admin.settings",
}
),
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
@pytest.fixture
def client(storage):
"""TestClient wired to console admin settings endpoints."""
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route("/api/admin/settings", admin_list_settings),
Route("/api/admin/settings/schema", admin_settings_schema),
Route(
"/api/admin/settings/{key:path}",
admin_update_setting,
methods=["PUT"],
),
Route(
"/api/admin/settings/{key:path}",
admin_delete_setting,
methods=["DELETE"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.auth_storage = storage
return TestClient(app)
# ---------------------------------------------------------------------------
# List settings
# ---------------------------------------------------------------------------
class TestListSettings:
def test_returns_all_registry_entries(self, client):
from turnstone.core.settings_registry import SETTINGS
r = client.get("/v1/api/admin/settings")
assert r.status_code == 200
data = r.json()
assert len(data["settings"]) == len(SETTINGS)
# Every entry has source "default" when nothing stored
for entry in data["settings"]:
assert entry["source"] == "default"
def test_stored_value_shows_source_storage(self, client, storage):
from turnstone.core.settings_registry import serialize_value
storage.upsert_system_setting(
key="tools.timeout",
value=serialize_value(60),
node_id="",
is_secret=False,
changed_by="admin",
)
r = client.get("/v1/api/admin/settings")
assert r.status_code == 200
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["tools.timeout"]["source"] == "storage"
assert by_key["tools.timeout"]["value"] == 60
# ---------------------------------------------------------------------------
# Update setting
# ---------------------------------------------------------------------------
class TestUpdateSetting:
def test_update_valid(self, client):
r = client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 30},
)
assert r.status_code == 200
data = r.json()
assert data["key"] == "tools.timeout"
assert data["value"] == 30
assert data["source"] == "storage"
def test_update_invalid_key(self, client):
r = client.put(
"/v1/api/admin/settings/bogus.nonexistent",
json={"value": "x"},
)
assert r.status_code == 400
assert "Unknown setting" in r.json()["error"]
def test_update_invalid_value_out_of_range(self, client):
r = client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 0},
)
assert r.status_code == 400
assert "minimum" in r.json()["error"]
def test_update_then_list_shows_storage(self, client):
client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 42},
)
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["tools.timeout"]["source"] == "storage"
assert by_key["tools.timeout"]["value"] == 42
# ---------------------------------------------------------------------------
# Delete setting
# ---------------------------------------------------------------------------
class TestDeleteSetting:
def test_delete_stored(self, client):
# First store a value
client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 30},
)
# Delete it
r = client.delete("/v1/api/admin/settings/tools.timeout")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_delete_then_list_shows_default(self, client):
client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 30},
)
client.delete("/v1/api/admin/settings/tools.timeout")
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["tools.timeout"]["source"] == "default"
def test_delete_non_existent(self, client):
r = client.delete("/v1/api/admin/settings/tools.timeout")
assert r.status_code == 404
assert "not found" in r.json()["error"]
# ---------------------------------------------------------------------------
# Schema endpoint
# ---------------------------------------------------------------------------
class TestSettingsSchema:
def test_returns_registry(self, client):
from turnstone.core.settings_registry import SETTINGS
r = client.get("/v1/api/admin/settings/schema")
assert r.status_code == 200
data = r.json()
assert len(data["schema"]) == len(SETTINGS)
# Spot-check a few fields
by_key = {s["key"]: s for s in data["schema"]}
timeout = by_key["tools.timeout"]
assert timeout["type"] == "int"
assert timeout["default"] == 120
assert timeout["min_value"] == 1
assert timeout["max_value"] == 3600
assert timeout["description"]
def test_choices_present(self, client):
r = client.get("/v1/api/admin/settings/schema")
by_key = {s["key"]: s for s in r.json()["schema"]}
assert by_key["tools.search"]["choices"] == ["auto", "on", "off"]
def test_secret_flag(self, client):
r = client.get("/v1/api/admin/settings/schema")
by_key = {s["key"]: s for s in r.json()["schema"]}
assert by_key["judge.api_key"]["is_secret"] is True
assert by_key["tools.timeout"]["is_secret"] is False
# ---------------------------------------------------------------------------
# Secret masking
# ---------------------------------------------------------------------------
class TestSecretMasking:
def test_secret_masked_in_list(self, client, storage):
from turnstone.core.settings_registry import serialize_value
storage.upsert_system_setting(
key="judge.api_key",
value=serialize_value("sk-real-secret"),
node_id="",
is_secret=True,
changed_by="admin",
)
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["judge.api_key"]["value"] == "***"
def test_secret_write_blocked(self, client):
"""Secret settings cannot be modified via API."""
r = client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "sk-secret-123"},
)
assert r.status_code == 403
assert "config.toml" in r.json()["error"]
def test_secret_shows_managed_label(self, client):
"""Secret settings show a label instead of a value."""
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert "managed via" in by_key["judge.api_key"]["value"]
# ---------------------------------------------------------------------------
# Audit trail (verify endpoint returns 200, confirming record_audit call)
# ---------------------------------------------------------------------------
class TestAuditTrail:
def test_update_returns_200(self, client):
"""Update succeeds — audit recording did not raise."""
r = client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 45},
)
assert r.status_code == 200
def test_delete_returns_200(self, client):
"""Delete succeeds — audit recording did not raise."""
client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 45},
)
r = client.delete("/v1/api/admin/settings/tools.timeout")
assert r.status_code == 200