mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
101afd84da
* 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 ("***")
292 lines
9.8 KiB
Python
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
|