Files
turnstone/tests/test_api_versioning.py
T
Patrick Buckley 62a4ceac96 Dev/api versioning openapi (#18)
* Add API versioning under /v1/ prefix with OpenAPI 3.1 spec

All API endpoints move to /v1/api/* (clean break, no unversioned
aliases). Non-API routes (/, /health, /metrics, /static, /shared,
/node proxy) stay unversioned.

New turnstone/api/ package:
- Pydantic v2 models for all request/response schemas (server +
  console) used for OpenAPI spec generation
- Programmatic OpenAPI 3.1 spec builder with EndpointSpec catalog
- /openapi.json serves machine-readable spec, /docs serves Swagger UI

Route changes:
- Both servers use Mount("/v1", routes=[...API routes...])
- Auth middleware strips /v1/ prefix before path classification
  (PUBLIC_PATHS/WRITE_PATHS stay unversioned internally)
- Console proxy handles /node/{id}/v1/api/ upstream forwarding
- Bridge and CLI HTTP clients updated to /v1/api/ paths
- /openapi.json and /docs added to PUBLIC_PATHS and rate limiter
  EXEMPT_PATHS

Security fix from review: required_role() now correctly handles
/node/{id}/v1/api/{path} proxy routes (previously the v1 segment
caused write-path detection to fail, allowing read-only token
escalation).

42 new tests (830 total). All frontend JS, docs, and diagrams updated.

* Fix mypy type errors in turnstone/api/ package

- Add generic type params to dict fields in console_schemas.py
- Add return type annotations to docs.py handler factories
- Move type-only imports (BaseModel, Callable, Awaitable) into
  TYPE_CHECKING blocks to satisfy TC002/TC003 ruff rules

* Address PR #18 review feedback + fix mypy errors

Review fixes:
- Add pydantic>=2.0 as explicit dependency in pyproject.toml
  (was only transitively available via openai/mcp)
- Auto-detect path parameters from {param} segments in OpenAPI
  spec builder (fixes missing required path params)
- Use startswith() with concrete prefix for proxy version
  detection instead of fragile substring check
- Make Swagger UI base URL configurable via swagger_ui_base_url
  parameter for air-gapped deployments

Mypy fixes:
- Add generic type params to dict fields in console_schemas
- Add return type annotations to docs.py handler factories
- Move type-only imports into TYPE_CHECKING blocks
2026-03-03 20:28:49 -08:00

121 lines
3.7 KiB
Python

"""Integration tests for API versioning and OpenAPI/docs endpoints."""
import queue
import threading
from unittest.mock import MagicMock
import pytest
class TestServerVersioning:
"""Test /v1/ routes and OpenAPI endpoints on the server."""
@pytest.fixture()
def client(self):
from starlette.testclient import TestClient
from turnstone.core.auth import AuthConfig
from turnstone.server import create_app
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = []
app = create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_v1_workstreams(self, client):
resp = client.get("/v1/api/workstreams")
assert resp.status_code == 200
assert "workstreams" in resp.json()
def test_unversioned_api_404(self, client):
resp = client.get("/api/workstreams")
assert resp.status_code == 404
def test_openapi_json(self, client):
resp = client.get("/openapi.json")
assert resp.status_code == 200
spec = resp.json()
assert spec["openapi"] == "3.1.0"
assert "/v1/api/send" in spec["paths"]
def test_docs_page(self, client):
resp = client.get("/docs")
assert resp.status_code == 200
assert "swagger-ui" in resp.text.lower()
def test_health_unversioned(self, client):
resp = client.get("/health")
assert resp.status_code == 200
assert "status" in resp.json()
def test_shared_static_unversioned(self, client):
resp = client.get("/shared/base.css")
assert resp.status_code == 200
class TestConsoleVersioning:
"""Test /v1/ routes and OpenAPI endpoints on the console."""
@pytest.fixture()
def client(self):
from starlette.testclient import TestClient
from turnstone.console.collector import ClusterCollector
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 0,
"workstreams": 0,
"states": {},
"aggregate": {},
}
app = create_app(
collector=collector,
broker=MagicMock(),
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_v1_cluster_overview(self, client):
resp = client.get("/v1/api/cluster/overview")
assert resp.status_code == 200
def test_unversioned_api_404(self, client):
resp = client.get("/api/cluster/overview")
assert resp.status_code == 404
def test_openapi_json(self, client):
resp = client.get("/openapi.json")
assert resp.status_code == 200
spec = resp.json()
assert spec["openapi"] == "3.1.0"
assert "/v1/api/cluster/overview" in spec["paths"]
def test_docs_page(self, client):
resp = client.get("/docs")
assert resp.status_code == 200
assert "swagger-ui" in resp.text.lower()
def test_health_unversioned(self, client):
resp = client.get("/health")
assert resp.status_code == 200
def test_console_app_js_uses_v1_paths(self, client):
resp = client.get("/static/app.js")
body = resp.text
assert "/v1/api/cluster" in body