mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
33ace975d2
Follow-up to the per-alias Entra OBO/app-identity backend auth: the console write path now applies default-deny field classification, the admin shelf gains full backend-auth support, and the session/registry rebind machinery is hardened for config changes landing under live sessions. Console write gate: - Default-deny classification: any non-neutral change to a row that is or becomes dynamic requires admin.mcp plus validation; the provably auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a live-schema classification test forces every future column to be classified. The derivation is a pure function (_derive_auth_gate) with unit-pinned exclusivity invariants. - Two-tier validation mirroring the MCP oauth_obo validator: the row tier (audience allow-list) runs on every gated write; the posture tier (OIDC configured, token store present) runs on pair changes and on enable-arming. - Pure-disable carve-out: disabling a dynamic row is de-escalation and is never blocked — admin.models suffices and validation is skipped, including for rows with corrupt or skewed stored values. - Capabilities are compared canonically (key order, integral floats), the audience compare normalizes both sides, and staging an audience on a static row is refused on both write twins. - Calibrate writes the capabilities column under an enforced confinement invariant with a compare-and-swap persist. Admin shelf: - Backend-auth section with a per-open constraints fetch (GET /model-definitions/auth-constraints: audience allow-list, grant profile, dynamic modes), datalist audience suggestions, server-defined modes preserved on round-trip, and permission-aware visibility built on cache-skew-safe helpers shared through auth.js. - Refused live-registry swaps surface as an amber registry_warning on the write, delete, reload, and calibrate responses; audit rows carry auth_gated / auth_disarmed markers visible in the audit view. Registry and sessions: - The encryption-key requirement for dynamic auth is enforced inside ModelRegistry.reload() itself — nodes refuse with 503 and the console records coord_registry_error — and reload bumps the generation before the map swap so a racing reader can never pair a stale generation with new maps. - resolve()/resolve_binding() return the generation from inside the registry lock; sessions rebind per send on generation change with atomic client/provider/config commits, fallback-first handling of removed or unconstructable aliases, and judge/limiter resets only when the binding actually changed. - Mint refusals record per-user causes surfaced in the per-turn heartbeat logs; misconfiguration warnings are deduplicated with bounded state. Verification: 10417 tests (99 added on this branch), a 71-scenario browser harness over the real admin shelf, and a live rfc8693 token-exchange e2e run (MCP legs verified end to end; the model-leg scope gap is tracked as #955 under a narrow known-gap signature). Closes #950.
255 lines
9.7 KiB
Python
255 lines
9.7 KiB
Python
"""Tests for OpenAPI spec generation."""
|
|
|
|
import json
|
|
|
|
|
|
class TestServerSpec:
|
|
"""Validate the generated server OpenAPI spec."""
|
|
|
|
def test_valid_openapi_version(self):
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
spec = build_server_spec()
|
|
assert spec["openapi"] == "3.1.0"
|
|
|
|
def test_has_info(self):
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
spec = build_server_spec()
|
|
assert "title" in spec["info"]
|
|
assert "version" in spec["info"]
|
|
|
|
def test_has_all_api_endpoints(self):
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
spec = build_server_spec()
|
|
paths = set(spec["paths"].keys())
|
|
expected = {
|
|
"/v1/api/workstreams",
|
|
"/v1/api/workstreams/{ws_id}",
|
|
"/v1/api/workstreams/{ws_id}/history",
|
|
"/v1/api/workstreams/{ws_id}/send",
|
|
"/v1/api/workstreams/{ws_id}/approve",
|
|
"/v1/api/workstreams/{ws_id}/cancel",
|
|
"/v1/api/workstreams/{ws_id}/rewind",
|
|
"/v1/api/workstreams/{ws_id}/retry",
|
|
"/v1/api/workstreams/{ws_id}/close",
|
|
"/v1/api/workstreams/{ws_id}/events",
|
|
"/v1/api/dashboard",
|
|
"/v1/api/workstreams/saved",
|
|
"/v1/api/command",
|
|
"/v1/api/events/global",
|
|
"/v1/api/workstreams/new",
|
|
"/v1/api/workstreams/{ws_id}/speech-to-text",
|
|
"/v1/api/tts",
|
|
"/v1/api/auth/login",
|
|
"/v1/api/auth/logout",
|
|
"/health",
|
|
}
|
|
assert expected.issubset(paths), f"Missing: {expected - paths}"
|
|
|
|
def test_voice_endpoints_documented(self):
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
spec = build_server_spec()
|
|
stt = spec["paths"]["/v1/api/workstreams/{ws_id}/speech-to-text"]["post"]
|
|
tts = spec["paths"]["/v1/api/tts"]["post"]
|
|
assert "responses" in stt
|
|
assert "requestBody" in tts
|
|
assert "application/json" in tts["requestBody"]["content"]
|
|
schemas = spec["components"]["schemas"]
|
|
assert "capabilities" in schemas["AvailableModelInfo"]["properties"]
|
|
models_props = schemas["ListAvailableModelsResponse"]["properties"]
|
|
assert "stt_default_alias" in models_props
|
|
assert "tts_default_alias" in models_props
|
|
|
|
def test_workstream_history_has_limit_query_param(self):
|
|
"""Mirror of the coord-side history limit param test — server now
|
|
exposes the same endpoint via the lifted factory."""
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
spec = build_server_spec()
|
|
op = spec["paths"]["/v1/api/workstreams/{ws_id}/history"]["get"]
|
|
param_names = [p["name"] for p in op.get("parameters", [])]
|
|
assert "ws_id" in param_names
|
|
assert "limit" in param_names
|
|
|
|
def test_schemas_not_empty(self):
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
spec = build_server_spec()
|
|
assert len(spec["components"]["schemas"]) > 0
|
|
|
|
def test_json_serializable(self):
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
spec = build_server_spec()
|
|
result = json.dumps(spec)
|
|
assert len(result) > 100
|
|
|
|
def test_send_endpoint_has_request_body(self):
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
spec = build_server_spec()
|
|
send = spec["paths"]["/v1/api/workstreams/{ws_id}/send"]["post"]
|
|
assert "requestBody" in send
|
|
assert "application/json" in send["requestBody"]["content"]
|
|
|
|
def test_health_endpoint_not_versioned(self):
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
spec = build_server_spec()
|
|
assert "/health" in spec["paths"]
|
|
assert "/v1/health" not in spec["paths"]
|
|
|
|
|
|
class TestConsoleSpec:
|
|
"""Validate the generated console OpenAPI spec."""
|
|
|
|
def test_valid_openapi_version(self):
|
|
from turnstone.api.console_spec import build_console_spec
|
|
|
|
spec = build_console_spec()
|
|
assert spec["openapi"] == "3.1.0"
|
|
|
|
def test_has_cluster_endpoints(self):
|
|
from turnstone.api.console_spec import build_console_spec
|
|
|
|
spec = build_console_spec()
|
|
paths = set(spec["paths"].keys())
|
|
expected = {
|
|
"/v1/api/cluster/overview",
|
|
"/v1/api/cluster/nodes",
|
|
"/v1/api/cluster/workstreams",
|
|
"/v1/api/cluster/node/{node_id}",
|
|
"/v1/api/cluster/workstreams/new",
|
|
"/v1/api/cluster/events",
|
|
}
|
|
assert expected.issubset(paths), f"Missing: {expected - paths}"
|
|
|
|
def test_json_serializable(self):
|
|
from turnstone.api.console_spec import build_console_spec
|
|
|
|
spec = build_console_spec()
|
|
result = json.dumps(spec)
|
|
assert len(result) > 100
|
|
|
|
def test_nodes_endpoint_has_query_params(self):
|
|
from turnstone.api.console_spec import build_console_spec
|
|
|
|
spec = build_console_spec()
|
|
nodes = spec["paths"]["/v1/api/cluster/nodes"]["get"]
|
|
assert "parameters" in nodes
|
|
param_names = [p["name"] for p in nodes["parameters"]]
|
|
assert "sort" in param_names
|
|
assert "limit" in param_names
|
|
|
|
def test_has_coordinator_endpoints(self):
|
|
"""Phase 1-3 coordinator routes must appear in the OpenAPI catalog —
|
|
the spec was missing every coordinator endpoint except ``/open``,
|
|
so SDK consumers and operators couldn't discover the surface
|
|
from /docs. Pin the full set so a future regression that drops
|
|
one fails loudly."""
|
|
from turnstone.api.console_spec import build_console_spec
|
|
|
|
spec = build_console_spec()
|
|
paths = set(spec["paths"].keys())
|
|
expected = {
|
|
"/v1/api/workstreams/new",
|
|
"/v1/api/workstreams",
|
|
"/v1/api/workstreams/{ws_id}",
|
|
"/v1/api/workstreams/{ws_id}/open",
|
|
"/v1/api/workstreams/{ws_id}/send",
|
|
"/v1/api/workstreams/{ws_id}/approve",
|
|
"/v1/api/workstreams/{ws_id}/cancel",
|
|
"/v1/api/workstreams/{ws_id}/rewind",
|
|
"/v1/api/workstreams/{ws_id}/retry",
|
|
"/v1/api/workstreams/{ws_id}/close",
|
|
"/v1/api/workstreams/{ws_id}/events",
|
|
"/v1/api/workstreams/{ws_id}/history",
|
|
"/v1/api/workstreams/{ws_id}/children",
|
|
"/v1/api/workstreams/{ws_id}/tasks",
|
|
"/v1/api/cluster/ws/{ws_id}/detail",
|
|
}
|
|
assert expected.issubset(paths), f"Missing: {expected - paths}"
|
|
|
|
def test_coordinator_create_has_request_body_and_200(self):
|
|
"""Coordinator create returns 200 and accepts a body.
|
|
|
|
Pre-1.5.0 this returned 201 (REST-strict for create); the lifted
|
|
``make_create_handler`` factory converges on 200 across both
|
|
kinds for response-shape parity with every other shared verb.
|
|
"""
|
|
from turnstone.api.console_spec import build_console_spec
|
|
|
|
spec = build_console_spec()
|
|
op = spec["paths"]["/v1/api/workstreams/new"]["post"]
|
|
assert "requestBody" in op
|
|
assert "application/json" in op["requestBody"]["content"]
|
|
assert "200" in op["responses"]
|
|
|
|
def test_coordinator_history_has_limit_query_param(self):
|
|
from turnstone.api.console_spec import build_console_spec
|
|
|
|
spec = build_console_spec()
|
|
op = spec["paths"]["/v1/api/workstreams/{ws_id}/history"]["get"]
|
|
param_names = [p["name"] for p in op.get("parameters", [])]
|
|
assert "ws_id" in param_names # auto-added from path
|
|
assert "limit" in param_names
|
|
|
|
def test_coordinator_endpoints_share_tag(self):
|
|
"""All coordinator endpoints (including the cluster-inspect one)
|
|
live under the same OpenAPI tag so /docs groups them together."""
|
|
from turnstone.api.console_spec import build_console_spec
|
|
|
|
spec = build_console_spec()
|
|
coord_paths = [p for p in spec["paths"] if "/coordinator" in p]
|
|
coord_paths.append("/v1/api/cluster/ws/{ws_id}/detail")
|
|
for path in coord_paths:
|
|
for op in spec["paths"][path].values():
|
|
assert "Coordinator" in op.get("tags", []), (
|
|
f"{path} missing Coordinator tag (tags={op.get('tags')})"
|
|
)
|
|
|
|
|
|
class TestCheckedInArtifactFreshness:
|
|
"""The checked-in `sdk/typescript/*.json` specs must match their source.
|
|
|
|
``info.version`` is normalised out on purpose: it tracks
|
|
``turnstone.__version__``, so comparing it would fail every release bump
|
|
with a misdiagnosing "spec is stale" message.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _schema_only(spec: dict) -> dict:
|
|
"""The spec with the release-coupled version stripped."""
|
|
pruned = dict(spec)
|
|
pruned["info"] = {k: v for k, v in spec.get("info", {}).items() if k != "version"}
|
|
return pruned
|
|
|
|
def _checked_in(self, name: str) -> dict:
|
|
import pathlib
|
|
|
|
root = pathlib.Path(__file__).resolve().parents[1]
|
|
return json.loads((root / "sdk" / "typescript" / name).read_text())
|
|
|
|
def test_server_spec_matches_checked_in_artifact(self):
|
|
from turnstone.api.server_spec import build_server_spec
|
|
|
|
assert self._schema_only(build_server_spec()) == self._schema_only(
|
|
self._checked_in("openapi-server.json")
|
|
), (
|
|
"openapi-server.json is stale; regenerate with "
|
|
"`uv run python scripts/generate-types.py` in sdk/typescript/"
|
|
)
|
|
|
|
def test_console_spec_matches_checked_in_artifact(self):
|
|
from turnstone.api.console_spec import build_console_spec
|
|
|
|
assert self._schema_only(build_console_spec()) == self._schema_only(
|
|
self._checked_in("openapi-console.json")
|
|
), (
|
|
"openapi-console.json is stale; regenerate with "
|
|
"`uv run python scripts/generate-types.py` in sdk/typescript/"
|
|
)
|