mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
377 lines
13 KiB
Python
377 lines
13 KiB
Python
"""Shared e2e fixtures: a real OPA process, a real JWKS HTTP endpoint + RS256
|
|
token signing, and helpers to run the Gateway/Sales servers in-process
|
|
against them. No Keycloak available in this environment (no Java) -- see
|
|
RI/README.md for exactly what that does and does not validate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from pathlib import Path
|
|
|
|
import jwt
|
|
import pytest
|
|
import uvicorn
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from jwt.algorithms import RSAAlgorithm
|
|
from mcp.client.session import ClientSession
|
|
from mcp.client.streamable_http import ( # type: ignore[attr-defined]
|
|
create_mcp_http_client,
|
|
streamable_http_client,
|
|
)
|
|
from mcp.shared.exceptions import MCPError
|
|
|
|
REPO_ROOT = Path(__file__).parents[2]
|
|
POLICIES_DIR = REPO_ROOT / "config" / "policies"
|
|
|
|
ISSUER = "https://keycloak.example/realms/emcp"
|
|
AUDIENCE = "emcp-gateway"
|
|
KID = "test-key-1"
|
|
|
|
_current_gateway_port: int | None = None
|
|
"""Set by `governed_stack` (or a test managing its own stack, via
|
|
`set_current_gateway_port`) for the duration of one test. A *fresh* port
|
|
per test -- rather than a fixed constant -- avoids TIME_WAIT/bind
|
|
contention between one test's `RunningServer.stop()` and the next test's
|
|
bind on the same port, which showed up as intermittent "SSE stream ended
|
|
without a response" failures once enough e2e tests accumulated to make
|
|
that race likely within a single pytest run."""
|
|
|
|
|
|
def set_current_gateway_port(port: int | None) -> None:
|
|
global _current_gateway_port
|
|
_current_gateway_port = port
|
|
|
|
|
|
def _gateway_url() -> str:
|
|
if _current_gateway_port is None:
|
|
raise RuntimeError(
|
|
"No gateway is currently running for this test (see governed_stack fixture "
|
|
"or set_current_gateway_port)"
|
|
)
|
|
return f"http://127.0.0.1:{_current_gateway_port}/mcp"
|
|
|
|
|
|
def free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return int(s.getsockname()[1])
|
|
|
|
|
|
# --- Real OPA process (EP-03) --------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def opa_url() -> Iterator[str]:
|
|
opa_binary = shutil.which(os.environ.get("OPA_BINARY", "opa"))
|
|
if opa_binary is None:
|
|
pytest.skip(
|
|
"opa binary not found on PATH (set OPA_BINARY or install it -- see RI/README.md)"
|
|
)
|
|
|
|
port = free_port()
|
|
process = subprocess.Popen(
|
|
[opa_binary, "run", "--server", "--addr", f"127.0.0.1:{port}", str(POLICIES_DIR)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
try:
|
|
import httpx
|
|
|
|
for _ in range(100):
|
|
try:
|
|
httpx.get(f"{base_url}/health", timeout=1)
|
|
break
|
|
except httpx.HTTPError:
|
|
time.sleep(0.05)
|
|
else:
|
|
raise RuntimeError("opa server did not become healthy in time")
|
|
yield base_url
|
|
finally:
|
|
process.terminate()
|
|
process.wait(timeout=5)
|
|
|
|
|
|
# --- Real JWKS HTTP endpoint + RS256 signing (EP-01-T03) ------------------
|
|
|
|
|
|
class _JWKSHandler(BaseHTTPRequestHandler):
|
|
jwks_document: dict[str, object] = {}
|
|
|
|
def do_GET(self) -> None: # noqa: N802
|
|
body = json.dumps(self.jwks_document).encode("utf-8")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
|
pass
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def rsa_keypair() -> tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]:
|
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
return private_key, private_key.public_key()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def jwks_url(rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]) -> Iterator[str]:
|
|
_, public_key = rsa_keypair
|
|
jwk = RSAAlgorithm.to_jwk(public_key, as_dict=True)
|
|
jwk.update({"kid": KID, "use": "sig", "alg": "RS256"})
|
|
_JWKSHandler.jwks_document = {"keys": [jwk]}
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _JWKSHandler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield f"http://127.0.0.1:{server.server_port}/jwks"
|
|
finally:
|
|
server.shutdown()
|
|
thread.join(timeout=5)
|
|
|
|
|
|
@pytest.fixture
|
|
def sign_token(
|
|
rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey],
|
|
) -> Callable[..., str]:
|
|
private_key, _ = rsa_keypair
|
|
|
|
def _sign(
|
|
client_id: str,
|
|
*,
|
|
issuer: str = ISSUER,
|
|
audience: str = AUDIENCE,
|
|
expires_in_seconds: int = 300,
|
|
signing_key: rsa.RSAPrivateKey | None = None,
|
|
) -> str:
|
|
now = int(time.time())
|
|
claims = {
|
|
"iss": issuer,
|
|
"aud": audience,
|
|
"sub": f"service-account-{client_id}",
|
|
"azp": client_id,
|
|
"iat": now,
|
|
"exp": now + expires_in_seconds,
|
|
}
|
|
return jwt.encode(
|
|
claims, signing_key or private_key, algorithm="RS256", headers={"kid": KID}
|
|
)
|
|
|
|
return _sign
|
|
|
|
|
|
# --- Running Gateway + Sales servers, wired to the fixtures above --------
|
|
|
|
|
|
class RunningServer:
|
|
def __init__(self, config: uvicorn.Config) -> None:
|
|
self.server = uvicorn.Server(config)
|
|
self._task: asyncio.Task[None] | None = None
|
|
|
|
async def start(self) -> None:
|
|
self._task = asyncio.create_task(self.server.serve())
|
|
while not self.server.started:
|
|
await asyncio.sleep(0.01)
|
|
# See test_walking_skeleton.py's original note: give the ASGI app's
|
|
# own lifespan (session manager task group) one extra tick.
|
|
await asyncio.sleep(0.05)
|
|
|
|
async def stop(self) -> None:
|
|
self.server.should_exit = True
|
|
assert self._task is not None
|
|
await self._task
|
|
|
|
|
|
@pytest.fixture
|
|
async def governed_stack(
|
|
monkeypatch: pytest.MonkeyPatch, opa_url: str, jwks_url: str
|
|
) -> AsyncIterator[None]:
|
|
"""Real OPA + real JWKS-backed OIDC auth, wired into fresh Gateway/Sales
|
|
instances for one test (EP-01/EP-03/EP-04/EP-05 all real, only Keycloak
|
|
itself is substituted by the JWKS server -- see RI/README.md).
|
|
|
|
Deliberately Sales-only, not Sales+Finance: most tests using this fixture
|
|
never reach a live backend call for a Finance tool (denied earlier, at
|
|
entitlement or REQUIRE_APPROVAL), and this file's own flakiness note
|
|
documents that more in-process ASGI server pairs per test measurably
|
|
increases the known e2e flakiness rate. `governed_stack_with_finance`
|
|
below is the (currently two) tests that actually need a live Finance
|
|
backend -- keeping the blast radius of that cost limited to just those.
|
|
"""
|
|
gateway_port = free_port()
|
|
sales_port = free_port()
|
|
|
|
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
|
monkeypatch.setenv("OPA_URL", opa_url)
|
|
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
|
|
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
|
|
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
|
|
# EP-07: the Gateway's outbound credential to the sales-domain backend --
|
|
# deliberately a different value/shape than any inbound client token.
|
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
|
|
|
import example_mcp_servers.sales_domain.server as sales_module
|
|
|
|
sales_config = uvicorn.Config(
|
|
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
|
host="127.0.0.1",
|
|
port=sales_port,
|
|
log_level="warning",
|
|
)
|
|
sales_server = RunningServer(sales_config)
|
|
await sales_server.start()
|
|
|
|
import emcp_bus.gateway.server as gateway_module
|
|
|
|
auth_settings, token_verifier = gateway_module.build_auth()
|
|
gateway_config = uvicorn.Config(
|
|
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
|
host="127.0.0.1",
|
|
port=gateway_port,
|
|
log_level="warning",
|
|
)
|
|
gateway_server = RunningServer(gateway_config)
|
|
await gateway_server.start()
|
|
set_current_gateway_port(gateway_port)
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
set_current_gateway_port(None)
|
|
await gateway_server.stop()
|
|
await sales_server.stop()
|
|
|
|
|
|
@pytest.fixture
|
|
async def governed_stack_with_finance(
|
|
monkeypatch: pytest.MonkeyPatch, opa_url: str, jwks_url: str
|
|
) -> AsyncIterator[None]:
|
|
"""Same as `governed_stack`, plus a real Finance domain server (EP-06-T03) --
|
|
for the handful of tests that need a live Finance backend to actually
|
|
reach (e.g. proving the Fabric router dispatches Finance capabilities
|
|
there, or a full approval-then-execute cycle against it)."""
|
|
gateway_port = free_port()
|
|
sales_port = free_port()
|
|
finance_port = free_port()
|
|
|
|
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
|
monkeypatch.setenv("FINANCE_DOMAIN_URL", f"http://127.0.0.1:{finance_port}/mcp")
|
|
monkeypatch.setenv("OPA_URL", opa_url)
|
|
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
|
|
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
|
|
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
|
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
|
monkeypatch.setenv("FINANCE_DOMAIN_SERVICE_TOKEN", "test-finance-domain-backend-credential")
|
|
|
|
import example_mcp_servers.sales_domain.server as sales_module
|
|
|
|
sales_server = RunningServer(
|
|
uvicorn.Config(
|
|
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
|
host="127.0.0.1",
|
|
port=sales_port,
|
|
log_level="warning",
|
|
)
|
|
)
|
|
await sales_server.start()
|
|
|
|
import example_mcp_servers.finance_domain.server as finance_module
|
|
|
|
finance_server = RunningServer(
|
|
uvicorn.Config(
|
|
finance_module.server.streamable_http_app(host="127.0.0.1"),
|
|
host="127.0.0.1",
|
|
port=finance_port,
|
|
log_level="warning",
|
|
)
|
|
)
|
|
await finance_server.start()
|
|
|
|
import emcp_bus.gateway.server as gateway_module
|
|
|
|
auth_settings, token_verifier = gateway_module.build_auth()
|
|
gateway_server = RunningServer(
|
|
uvicorn.Config(
|
|
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
|
host="127.0.0.1",
|
|
port=gateway_port,
|
|
log_level="warning",
|
|
)
|
|
)
|
|
await gateway_server.start()
|
|
set_current_gateway_port(gateway_port)
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
set_current_gateway_port(None)
|
|
await gateway_server.stop()
|
|
await finance_server.stop()
|
|
await sales_server.stop()
|
|
|
|
|
|
async def run_against_gateway[ResultT](
|
|
operation: Callable[[ClientSession], Awaitable[ResultT]],
|
|
*,
|
|
token: str | None = None,
|
|
) -> ResultT:
|
|
"""Connect to the gateway (optionally authenticated) and run `operation(session)`.
|
|
|
|
Retries on `MCPError` ("SSE stream ended without a response" has been
|
|
observed, rarely, under this in-process dual-uvicorn test setup under
|
|
scheduler contention) -- a test-harness robustness concern, not a
|
|
Gateway defect: production deployments run one process per service.
|
|
"""
|
|
headers = {"Authorization": f"Bearer {token}"} if token else None
|
|
url = _gateway_url()
|
|
last_error: MCPError | None = None
|
|
for attempt in range(3):
|
|
if attempt:
|
|
await asyncio.sleep(0.1 * attempt)
|
|
try:
|
|
async with (
|
|
create_mcp_http_client(headers=headers) as http_client,
|
|
streamable_http_client(url, http_client=http_client) as (read, write),
|
|
ClientSession(read, write) as session,
|
|
):
|
|
await session.initialize()
|
|
return await operation(session)
|
|
except MCPError as exc:
|
|
last_error = exc
|
|
except BaseExceptionGroup as group:
|
|
# Each nested anyio TaskGroup (`streamable_http_client`'s, then
|
|
# `ClientSession`'s) wraps the one below it in its own
|
|
# ExceptionGroup, so a handler-raised MCPError (e.g. an auth
|
|
# rejection during `initialize`) can arrive doubly-wrapped.
|
|
# Unwrap fully so callers can `pytest.raises(MCPError)`
|
|
# uniformly, same as for one raised directly.
|
|
unwrapped = _unwrap_single_mcp_error(group)
|
|
if unwrapped is not None:
|
|
last_error = unwrapped
|
|
else:
|
|
raise
|
|
assert last_error is not None
|
|
raise last_error
|
|
|
|
|
|
def _unwrap_single_mcp_error(exc: BaseException) -> MCPError | None:
|
|
"""If `exc` is an MCPError, or a (possibly nested) ExceptionGroup wrapping
|
|
exactly one, return it; otherwise `None`."""
|
|
if isinstance(exc, MCPError):
|
|
return exc
|
|
if isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1:
|
|
return _unwrap_single_mcp_error(exc.exceptions[0])
|
|
return None
|