mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
7492816ab2
* feat: governance — RBAC, tool policies, prompt templates, usage tracking, audit logging Add comprehensive governance layer for the admin console: - RBAC with 15 granular permissions, 3 builtin roles (admin, operator, viewer), custom role CRUD, user-role assignment with privilege escalation prevention - Tool policies with glob pattern matching, priority-ordered evaluation (allow/deny/ask), enforced before auto-approve in WebUI.approve_tools() - Prompt templates with variable substitution, categories, default flag - Usage tracking: per-LLM-request token/tool metrics, aggregated queries (group by day/model/user), automatic 90-day pruning via scheduler - Audit logging: append-only event trail for all admin mutations, filterable/paginated queries, automatic 365-day pruning, X-Forwarded-For aware IP extraction - require_permission() enforced on all 35+ admin endpoints (users, tokens, channels, schedules, watches, roles, orgs, policies, templates, usage, audit) - Field allowlists on storage update methods prevent mass-assignment bugs - Self-deletion guard on admin_delete_user, delete_user cascades user_roles - _row_to_dict helper eliminates ~400 lines of fragile positional row mapping - _audit_context helper deduplicates 18 instances of audit boilerplate - Migration 008: 7 new tables, 3 builtin roles, org_id on users - Console admin panel: 5 new tabs (Roles, Policies, Templates, Usage, Audit) with permission-gated visibility, 7 modal dialogs, full keyboard accessibility - Python + TypeScript SDK methods for all governance endpoints - 120+ new tests (1554 total) * fix: address PR #39 review feedback - Rebuild serialized items after policy evaluation so denied/allowed verdicts are reflected in tool_info/approve_request SSE payloads - Make `since` query param optional in usage OpenAPI spec (handler already defaults to last 7 days) - Add response_model=StatusResponse to DELETE role/policy/template and POST/DELETE role assignment endpoints in OpenAPI spec - Add missing org_id/created/updated fields to UserRoleInfo schema - Add missing created field to AuditEventInfo schema - Show "no permissions" empty state instead of loading inaccessible tab when all admin tabs are permission-gated - Fix "13 permissions" → "15 permissions" in architecture.md and security.md - Fix import sorting in test_audit.py and test_tool_policy.py * fix: address PR #39 round 2 review feedback - Clear stale permissions from sessionStorage on config-token login (auth.js _storePermissions) - Only trust X-Forwarded-For when behind a proxy that sets X-Forwarded-Proto (conditional on is_secure_request trust model) - Thread user_id from auth into WebUI.on_status for usage events - Add created field to TS AuditEventInfo type - Return typed Pydantic models from all SDK governance methods instead of dict[str, Any] — both async and sync clients - Validate group_by param against allowed enum in admin_usage handler - Add deterministic secondary sort (event_id DESC) to list_audit_events in both SQLite and PostgreSQL backends
286 lines
9.7 KiB
Python
286 lines
9.7 KiB
Python
"""Tests for scheduled task 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_create_schedule,
|
|
admin_delete_schedule,
|
|
admin_get_schedule,
|
|
admin_list_schedule_runs,
|
|
admin_list_schedules,
|
|
admin_update_schedule,
|
|
)
|
|
from turnstone.core.auth import AuthResult
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
|
|
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
request.state.auth_result = AuthResult(
|
|
user_id="test-admin",
|
|
scopes=frozenset({"approve"}),
|
|
token_source="config",
|
|
permissions=frozenset({"admin.schedules"}),
|
|
)
|
|
return await call_next(request)
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
"""Fresh SQLite backend for each test."""
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
@pytest.fixture
|
|
def client(storage):
|
|
"""TestClient with storage and auth bypassed."""
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/admin/schedules", admin_list_schedules),
|
|
Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]),
|
|
Route("/api/admin/schedules/{task_id}", admin_get_schedule),
|
|
Route(
|
|
"/api/admin/schedules/{task_id}",
|
|
admin_update_schedule,
|
|
methods=["PUT"],
|
|
),
|
|
Route(
|
|
"/api/admin/schedules/{task_id}",
|
|
admin_delete_schedule,
|
|
methods=["DELETE"],
|
|
),
|
|
Route(
|
|
"/api/admin/schedules/{task_id}/runs",
|
|
admin_list_schedule_runs,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
return TestClient(app)
|
|
|
|
|
|
def _cron_payload(**overrides):
|
|
"""Build default cron schedule creation payload."""
|
|
defaults = {
|
|
"name": "Daily report",
|
|
"description": "Generate the summary",
|
|
"schedule_type": "cron",
|
|
"cron_expr": "0 9 * * *",
|
|
"target_mode": "auto",
|
|
"model": "gpt-5",
|
|
"initial_message": "Generate the daily report",
|
|
}
|
|
defaults.update(overrides)
|
|
return defaults
|
|
|
|
|
|
def _at_payload(**overrides):
|
|
"""Build default at-time schedule creation payload."""
|
|
defaults = {
|
|
"name": "One-shot task",
|
|
"description": "Run once",
|
|
"schedule_type": "at",
|
|
"at_time": "2099-01-01T00:00:00+00:00",
|
|
"target_mode": "auto",
|
|
"model": "gpt-5",
|
|
"initial_message": "Do the thing",
|
|
}
|
|
defaults.update(overrides)
|
|
return defaults
|
|
|
|
|
|
class TestScheduleAPI:
|
|
"""Tests for the 6 admin schedule endpoints."""
|
|
|
|
def test_list_empty(self, client):
|
|
resp = client.get("/v1/api/admin/schedules")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["schedules"] == []
|
|
|
|
def test_create_cron(self, client):
|
|
resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
assert resp.status_code == 200
|
|
task = resp.json()
|
|
assert task["name"] == "Daily report"
|
|
assert task["schedule_type"] == "cron"
|
|
assert task["cron_expr"] == "0 9 * * *"
|
|
assert task["enabled"] is True
|
|
assert "task_id" in task
|
|
assert "created" in task
|
|
assert "next_run" in task
|
|
assert task["next_run"] != ""
|
|
|
|
def test_create_at(self, client):
|
|
resp = client.post("/v1/api/admin/schedules", json=_at_payload())
|
|
assert resp.status_code == 200
|
|
task = resp.json()
|
|
assert task["schedule_type"] == "at"
|
|
assert task["at_time"] == "2099-01-01T00:00:00+00:00"
|
|
assert task["next_run"] == "2099-01-01T00:00:00+00:00"
|
|
|
|
def test_create_missing_name(self, client):
|
|
payload = _cron_payload()
|
|
del payload["name"]
|
|
resp = client.post("/v1/api/admin/schedules", json=payload)
|
|
assert resp.status_code == 400
|
|
assert "name" in resp.json()["error"].lower()
|
|
|
|
def test_create_invalid_cron(self, client):
|
|
resp = client.post(
|
|
"/v1/api/admin/schedules",
|
|
json=_cron_payload(cron_expr="not a cron"),
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "cron" in resp.json()["error"].lower()
|
|
|
|
def test_create_naive_at_time(self, client):
|
|
"""Naive timestamps (no timezone) should be rejected."""
|
|
resp = client.post(
|
|
"/v1/api/admin/schedules",
|
|
json=_at_payload(at_time="2099-01-01T00:00:00"),
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "timezone" in resp.json()["error"].lower()
|
|
|
|
def test_create_past_at_time(self, client):
|
|
resp = client.post(
|
|
"/v1/api/admin/schedules",
|
|
json=_at_payload(at_time="2000-01-01T00:00:00+00:00"),
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "future" in resp.json()["error"].lower()
|
|
|
|
def test_get_schedule(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
resp = client.get(f"/v1/api/admin/schedules/{task_id}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["task_id"] == task_id
|
|
assert resp.json()["name"] == "Daily report"
|
|
|
|
def test_get_nonexistent(self, client):
|
|
resp = client.get("/v1/api/admin/schedules/nonexistent_id")
|
|
assert resp.status_code == 404
|
|
|
|
def test_update_schedule(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
resp = client.put(
|
|
f"/v1/api/admin/schedules/{task_id}",
|
|
json={"name": "Weekly report"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["name"] == "Weekly report"
|
|
|
|
# Verify via GET
|
|
get_resp = client.get(f"/v1/api/admin/schedules/{task_id}")
|
|
assert get_resp.json()["name"] == "Weekly report"
|
|
|
|
def test_update_nonexistent(self, client):
|
|
resp = client.put(
|
|
"/v1/api/admin/schedules/nonexistent_id",
|
|
json={"name": "Nope"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_delete_schedule(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
resp = client.delete(f"/v1/api/admin/schedules/{task_id}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "ok"
|
|
|
|
# Verify gone
|
|
get_resp = client.get(f"/v1/api/admin/schedules/{task_id}")
|
|
assert get_resp.status_code == 404
|
|
|
|
def test_delete_nonexistent(self, client):
|
|
resp = client.delete("/v1/api/admin/schedules/nonexistent_id")
|
|
assert resp.status_code == 404
|
|
|
|
def test_list_runs_empty(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["runs"] == []
|
|
|
|
def test_list_runs_nonexistent(self, client):
|
|
resp = client.get("/v1/api/admin/schedules/nonexistent_id/runs")
|
|
assert resp.status_code == 404
|
|
|
|
def test_create_specific_node_target(self, client):
|
|
payload = _cron_payload(target_mode="node-custom-001")
|
|
resp = client.post("/v1/api/admin/schedules", json=payload)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["target_mode"] == "node-custom-001"
|
|
|
|
def test_list_runs_with_data(self, client, storage):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
# Record runs directly in storage
|
|
storage.record_task_run(
|
|
run_id="run_001",
|
|
task_id=task_id,
|
|
node_id="node-1",
|
|
ws_id="ws_abc",
|
|
correlation_id="corr_001",
|
|
started="2025-06-01T09:00:00",
|
|
status="dispatched",
|
|
error="",
|
|
)
|
|
storage.record_task_run(
|
|
run_id="run_002",
|
|
task_id=task_id,
|
|
node_id="node-2",
|
|
ws_id="",
|
|
correlation_id="corr_002",
|
|
started="2025-06-01T09:01:00",
|
|
status="failed",
|
|
error="No reachable nodes",
|
|
)
|
|
|
|
resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs")
|
|
assert resp.status_code == 200
|
|
runs = resp.json()["runs"]
|
|
assert len(runs) == 2
|
|
# Most recent first
|
|
assert runs[0]["run_id"] == "run_002"
|
|
assert runs[0]["status"] == "failed"
|
|
assert runs[1]["run_id"] == "run_001"
|
|
|
|
def test_list_runs_invalid_limit(self, client):
|
|
create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload())
|
|
task_id = create_resp.json()["task_id"]
|
|
|
|
# Invalid limit should not crash — falls back to 50
|
|
resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs?limit=abc")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["runs"] == []
|