159 lines
6.1 KiB
Python
159 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.testclient import TestClient
|
|
|
|
from packages.hub_clients import (
|
|
ClientKind,
|
|
ClientRegistry,
|
|
FakeVirtualServer,
|
|
StdioBridge,
|
|
run_matrix,
|
|
)
|
|
from services.control_api.clients.router import build_client_router
|
|
from services.control_api.clients.service import ClientService
|
|
|
|
NOW = datetime(2026, 8, 1, 18, 0, tzinfo=UTC)
|
|
|
|
|
|
def test_client_profiles_are_independent_and_configs_are_secret_free() -> None:
|
|
registry = ClientRegistry()
|
|
first = registry.create(
|
|
name="Codex", project="ludarium", environment="prod", kind=ClientKind.NATIVE_2026, now=NOW
|
|
)
|
|
second = registry.create(
|
|
name="Inspector", project="ludarium", environment="prod", kind=ClientKind.INSPECTOR, now=NOW
|
|
)
|
|
assert first.id != second.id
|
|
assert registry.authorize(first.id, project="ludarium", environment="prod").id == first.id
|
|
with pytest.raises(PermissionError):
|
|
registry.authorize(first.id, project="other", environment="prod")
|
|
native = __import__("packages.hub_clients", fromlist=["generate_config"]).generate_config(first)
|
|
assert native.protocol_version == "2026-07-28"
|
|
assert "token" not in native.model_dump_json().lower()
|
|
assert "secret" not in native.model_dump_json().lower()
|
|
|
|
|
|
def test_disable_revoke_and_one_time_bootstrap_are_bounded() -> None:
|
|
registry = ClientRegistry()
|
|
client = registry.create(
|
|
name="Bridge", project="ludarium", environment="prod", kind=ClientKind.STDIO_BRIDGE, now=NOW
|
|
)
|
|
bootstrap = registry.issue_bootstrap(client.id, now=NOW)
|
|
consumed = registry.consume_bootstrap(bootstrap.reference, now=NOW + timedelta(seconds=1))
|
|
assert consumed.consumed is True
|
|
with pytest.raises(PermissionError):
|
|
registry.consume_bootstrap(bootstrap.reference, now=NOW + timedelta(seconds=2))
|
|
registry.disable(client.id, now=NOW + timedelta(seconds=3))
|
|
with pytest.raises(PermissionError):
|
|
registry.authorize(client.id, project="ludarium", environment="prod")
|
|
registry.revoke(client.id, now=NOW + timedelta(seconds=4))
|
|
assert registry.get(client.id).revoked_at == NOW + timedelta(seconds=4)
|
|
|
|
|
|
class _Transport:
|
|
def __init__(self) -> None:
|
|
self.tokens: list[str] = []
|
|
|
|
def send(self, request: dict[str, Any], *, token: str, endpoint: str) -> dict[str, Any]:
|
|
self.tokens.append(token)
|
|
return {"jsonrpc": "2.0", "id": request.get("id"), "result": {"endpoint": endpoint}}
|
|
|
|
|
|
def test_stdio_bridge_allows_bounded_mcp_methods_without_token_output() -> None:
|
|
transport = _Transport()
|
|
bridge = StdioBridge(endpoint="https://mcp.example.com/mcp/ludarium/prod")
|
|
output = tuple(
|
|
bridge.forward(
|
|
['{"jsonrpc":"2.0","id":1,"method":"tools/list"}', '{"method":"exec"}', "not-json"],
|
|
transport,
|
|
environ={"ITWORX_MCP_ACCESS_TOKEN": "fixture-token"},
|
|
)
|
|
)
|
|
assert '"endpoint"' in output[0]
|
|
assert "fixture-token" not in "".join(output)
|
|
assert transport.tokens == ["fixture-token"]
|
|
assert "unsupported_method" in output[1]
|
|
assert "invalid_json" in output[2]
|
|
|
|
|
|
def test_discovery_invocation_matrix_covers_all_profiles_and_connectors() -> None:
|
|
results = run_matrix(FakeVirtualServer())
|
|
assert len(results) == 12
|
|
assert {result.client_kind for result in results} == set(ClientKind)
|
|
assert all(result.discovered and result.invoked for result in results)
|
|
server = FakeVirtualServer()
|
|
server.revoked.add(ClientKind.NATIVE_2026.value)
|
|
with pytest.raises(PermissionError):
|
|
server.discover(ClientKind.NATIVE_2026.value, "2026-07-28")
|
|
|
|
|
|
def test_client_router_is_denied_by_default_and_supports_safe_lifecycle() -> None:
|
|
service = ClientService()
|
|
denied_app = FastAPI()
|
|
denied_app.include_router(build_client_router(service))
|
|
denied = TestClient(denied_app)
|
|
denied_response = denied.post(
|
|
"/clients",
|
|
json={"name": "x", "project": "ludarium", "environment": "prod", "kind": "native-2026"},
|
|
)
|
|
assert denied_response.status_code == 403
|
|
|
|
def authenticate_any(_: Request) -> str:
|
|
return "test-principal"
|
|
|
|
def allow_all(_: Any, __: str) -> None:
|
|
return None
|
|
|
|
allowed_app = FastAPI()
|
|
allowed_app.include_router(
|
|
build_client_router(service, authenticate=authenticate_any, authorize=allow_all)
|
|
)
|
|
allowed = TestClient(allowed_app)
|
|
created = allowed.post(
|
|
"/clients",
|
|
json={"name": "x", "project": "ludarium", "environment": "prod", "kind": "native-2026"},
|
|
)
|
|
assert created.status_code == 201
|
|
body = created.json()
|
|
assert "access_token" not in created.text.lower()
|
|
assert "secret" not in created.text.lower()
|
|
assert body["config"]["protocolVersion"] == "2026-07-28"
|
|
assert "expiresAt" in body["bootstrap"]
|
|
client_id = body["client"]["id"]
|
|
config = allowed.get(f"/clients/{client_id}/config")
|
|
assert config.status_code == 200
|
|
revoked = allowed.post(f"/clients/{client_id}/revoke")
|
|
assert revoked.status_code == 200
|
|
assert revoked.json()["status"] == "revoked"
|
|
|
|
|
|
def test_client_router_enforces_per_action_authorization() -> None:
|
|
"""A principal authenticated but denied a *specific* action gets 403 for
|
|
that action only -- distinct from the router being entirely unwired."""
|
|
service = ClientService()
|
|
|
|
def authenticate_any(_: Request) -> str:
|
|
return "test-principal"
|
|
|
|
def allow_reads_only(_: Any, action: str) -> None:
|
|
if action != "client.read":
|
|
raise HTTPException(status_code=403, detail=f"role does not permit {action}")
|
|
|
|
app = FastAPI()
|
|
app.include_router(
|
|
build_client_router(service, authenticate=authenticate_any, authorize=allow_reads_only)
|
|
)
|
|
client = TestClient(app)
|
|
|
|
assert client.get("/clients").status_code == 200
|
|
create_response = client.post(
|
|
"/clients",
|
|
json={"name": "x", "project": "ludarium", "environment": "prod", "kind": "native-2026"},
|
|
)
|
|
assert create_response.status_code == 403
|