mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
M0 e M1 revisados e fixes implementados
This commit is contained in:
@@ -37,8 +37,28 @@ ISSUER = "https://keycloak.example/realms/emcp"
|
||||
AUDIENCE = "emcp-gateway"
|
||||
KID = "test-key-1"
|
||||
|
||||
GATEWAY_PORT = 18000
|
||||
SALES_PORT = 18100
|
||||
_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:
|
||||
@@ -180,19 +200,34 @@ async def governed_stack(
|
||||
) -> 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)."""
|
||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{SALES_PORT}/mcp")
|
||||
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,
|
||||
port=sales_port,
|
||||
log_level="warning",
|
||||
)
|
||||
sales_server = RunningServer(sales_config)
|
||||
@@ -202,23 +237,91 @@ async def governed_stack(
|
||||
|
||||
auth_settings, token_verifier = gateway_module.build_auth()
|
||||
gateway_config = uvicorn.Config(
|
||||
gateway_module.server.streamable_http_app(
|
||||
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
|
||||
),
|
||||
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=GATEWAY_PORT,
|
||||
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]],
|
||||
*,
|
||||
@@ -232,7 +335,7 @@ async def run_against_gateway[ResultT](
|
||||
Gateway defect: production deployments run one process per service.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
url = f"http://127.0.0.1:{GATEWAY_PORT}/mcp"
|
||||
url = _gateway_url()
|
||||
last_error: MCPError | None = None
|
||||
for attempt in range(3):
|
||||
if attempt:
|
||||
|
||||
@@ -136,13 +136,13 @@ async def test_transaction_policy_denies_over_limit_even_though_entitled(
|
||||
assert "TRANSACTION_POLICY_DENIED" in _text(result)
|
||||
|
||||
|
||||
async def test_require_approval_is_denied_pending_ep14(
|
||||
async def test_require_approval_is_denied_without_a_grant(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §26: Finance.Payments allows payment.execute below the approval
|
||||
"""README.md §26: Finance.Payments allows payment.execute above the approval
|
||||
threshold (README.md §7.2/§17 profile) but must never silently upgrade a
|
||||
REQUIRE_APPROVAL to ALLOW -- and EP-14 (approval workflow) does not exist
|
||||
yet in this milestone, so the only safe outcome is DENY."""
|
||||
REQUIRE_APPROVAL to ALLOW -- the first attempt of any such call is always
|
||||
denied, since no approval has been granted for it yet (EP-14-T02)."""
|
||||
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(
|
||||
@@ -154,6 +154,152 @@ async def test_require_approval_is_denied_pending_ep14(
|
||||
assert "REQUIRE_APPROVAL" in _text(result)
|
||||
|
||||
|
||||
async def test_full_approval_cycle_grant_then_execute_then_replay_denied(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""EP-14 end to end: deny (no grant) -> out-of-band grant -> the *same*
|
||||
call now reaches the real backend -> a second attempt of the identical
|
||||
call is denied again (README.md §46, single-use "approval replay").
|
||||
|
||||
Uses Sales.Write + sales.quote.create (approval_threshold=100000,
|
||||
config/clients/profiles/sales-write.yaml); see
|
||||
`test_finance_full_approval_cycle_routes_through_fabric_to_finance_domain`
|
||||
below for the same cycle proven against the Finance domain (EP-06-T03).
|
||||
"""
|
||||
import emcp_bus.gateway.server as gateway_module
|
||||
|
||||
token = sign_token(SALES_WRITE_CLIENT_ID)
|
||||
arguments = {"customer_id": "cust-001", "amount": 150_000, "region": "BR-SP"}
|
||||
|
||||
first_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.quote.create", arguments), token=token
|
||||
)
|
||||
assert first_attempt.is_error is True
|
||||
assert "REQUIRE_APPROVAL" in _text(first_attempt)
|
||||
approval_id = _text(first_attempt).rsplit(":", 1)[-1]
|
||||
|
||||
# Out-of-band: an approver grants the specific request the Gateway itself
|
||||
# created (see `on_call_tool`'s REQUIRE_APPROVAL branch) -- not a new one
|
||||
# constructed by the test, proving the Gateway and the approver are
|
||||
# looking at the same `ApprovalService` instance (`get_current_state()`).
|
||||
gateway_module.get_current_state().approval_service.grant(approval_id)
|
||||
|
||||
granted_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.quote.create", arguments), token=token
|
||||
)
|
||||
assert granted_attempt.is_error is not True
|
||||
assert granted_attempt.structured_content is not None
|
||||
assert granted_attempt.structured_content["status"] == "draft"
|
||||
|
||||
replay_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.quote.create", arguments), token=token
|
||||
)
|
||||
assert replay_attempt.is_error is True
|
||||
assert "REQUIRE_APPROVAL" in _text(replay_attempt)
|
||||
|
||||
|
||||
async def test_sales_client_tools_list_never_includes_finance_tools(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""EP-06-T01: the Fabric router queries only the backends a client's
|
||||
entitlement actually touches -- a Sales.Read client's tools/list never
|
||||
even connects to the Finance backend."""
|
||||
token = sign_token(SALES_READ_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
|
||||
tool_names = {tool.name for tool in result.tools}
|
||||
assert tool_names == {
|
||||
"sales.customer.search",
|
||||
"sales.customer.get",
|
||||
"sales.order.get",
|
||||
"sales.inventory.check",
|
||||
}
|
||||
|
||||
|
||||
async def test_finance_client_tools_list_never_includes_sales_tools(
|
||||
governed_stack_with_finance: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""EP-06-T01, the mirror image: a Finance.Payments client's tools/list
|
||||
routes to the Finance backend only, never Sales."""
|
||||
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
|
||||
tool_names = {tool.name for tool in result.tools}
|
||||
assert tool_names == {
|
||||
"finance.invoice.get",
|
||||
"finance.payment.create",
|
||||
"finance.payment.execute",
|
||||
}
|
||||
|
||||
|
||||
# EP-05-T03's SEP-2549 `ttl_ms`/`cache_scope` fields are per-version wire
|
||||
# vocabulary (`mcp_types._v2026_07_28`) that the SDK's own serialization
|
||||
# sieve strips for every earlier protocol revision (verified: neither field
|
||||
# exists in any pre-2026-07-28 per-version result model at all). This RI's
|
||||
# entire transport uses the classic session `initialize()` handshake, which
|
||||
# never negotiates 2026-07-28 (that revision is reached only via the
|
||||
# stateless per-request `server/discover` mode this RI does not use) -- so
|
||||
# there is no e2e test here asserting on `result.cache_scope`/`result.ttl_ms`:
|
||||
# whatever the Gateway sets internally, a client on this transport only ever
|
||||
# sees the field's own class default, making such an assertion pass
|
||||
# regardless of what the Gateway actually computed. `tools_list_cache_hint()`
|
||||
# itself (always `scope="private"`, configurable `ttl_ms`) is the real,
|
||||
# meaningful test -- see tests/unit/test_gateway_cache.py -- and
|
||||
# `emcp_bus.gateway.cache`'s module docstring has the full, verified
|
||||
# explanation.
|
||||
|
||||
|
||||
async def test_finance_full_approval_cycle_routes_through_fabric_to_finance_domain(
|
||||
governed_stack_with_finance: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""EP-06-T01/T03 + EP-14 end to end against the *Finance* domain server:
|
||||
the Fabric router resolves finance.payment.create/execute to the Finance
|
||||
backend, and the same deny -> grant -> execute -> replay-denied cycle as
|
||||
`test_full_approval_cycle_grant_then_execute_then_replay_denied` (Sales)
|
||||
holds identically here."""
|
||||
import emcp_bus.gateway.server as gateway_module
|
||||
|
||||
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||
|
||||
create_result = await run_against_gateway(
|
||||
lambda session: session.call_tool(
|
||||
"finance.payment.create", {"invoice_id": "inv-002", "amount": 5_000}
|
||||
),
|
||||
token=token,
|
||||
)
|
||||
assert create_result.is_error is not True
|
||||
assert create_result.structured_content is not None
|
||||
payment_id = create_result.structured_content["payment_id"]
|
||||
|
||||
execute_arguments = {"payment_id": payment_id, "amount": 50_000}
|
||||
first_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
|
||||
token=token,
|
||||
)
|
||||
assert first_attempt.is_error is True
|
||||
assert "REQUIRE_APPROVAL" in _text(first_attempt)
|
||||
approval_id = _text(first_attempt).rsplit(":", 1)[-1]
|
||||
|
||||
gateway_module.get_current_state().approval_service.grant(approval_id)
|
||||
|
||||
granted_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
|
||||
token=token,
|
||||
)
|
||||
assert granted_attempt.is_error is not True
|
||||
assert granted_attempt.structured_content is not None
|
||||
assert granted_attempt.structured_content["status"] == "executed"
|
||||
|
||||
replay_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
|
||||
token=token,
|
||||
)
|
||||
assert replay_attempt.is_error is True
|
||||
assert "REQUIRE_APPROVAL" in _text(replay_attempt)
|
||||
|
||||
|
||||
async def test_gateway_lifespan_fails_closed_when_pdp_unreachable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -188,10 +334,19 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
from tests.e2e.conftest import GATEWAY_PORT, SALES_PORT, RunningServer, run_against_gateway
|
||||
from tests.e2e.conftest import (
|
||||
RunningServer,
|
||||
free_port,
|
||||
run_against_gateway,
|
||||
set_current_gateway_port,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{SALES_PORT}/mcp")
|
||||
sales_port = free_port()
|
||||
gateway_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("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||
monkeypatch.delenv("OIDC_JWKS_URL", raising=False)
|
||||
monkeypatch.delenv("OIDC_ISSUER", raising=False)
|
||||
|
||||
@@ -201,7 +356,7 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
uvicorn.Config(
|
||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=SALES_PORT,
|
||||
port=sales_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
@@ -214,15 +369,14 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
|
||||
gateway_server = RunningServer(
|
||||
uvicorn.Config(
|
||||
gateway_module.server.streamable_http_app(
|
||||
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
|
||||
),
|
||||
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=GATEWAY_PORT,
|
||||
port=gateway_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await gateway_server.start()
|
||||
set_current_gateway_port(gateway_port)
|
||||
try:
|
||||
list_result = await run_against_gateway(lambda session: session.list_tools())
|
||||
assert list_result.tools == []
|
||||
@@ -233,6 +387,7 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
assert call_result.is_error is True
|
||||
assert "UNAUTHENTICATED" in _text(call_result)
|
||||
finally:
|
||||
set_current_gateway_port(None)
|
||||
await gateway_server.stop()
|
||||
await sales_server.stop()
|
||||
|
||||
|
||||
249
RI/tests/e2e/test_real_keycloak.py
Normal file
249
RI/tests/e2e/test_real_keycloak.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""EP-01-T01: the *actual* Keycloak, not the JWKS-server stand-in the rest of
|
||||
the e2e suite uses (see tests/e2e/conftest.py's module docstring). Real
|
||||
Keycloak 26.7.3 container (Docker required -- skips otherwise), our real
|
||||
`deploy/keycloak/realm-export.json`, real `client_credentials` grants, real
|
||||
tokens validated by the real Gateway/PDP/Entitlement chain.
|
||||
|
||||
Session-scoped: Keycloak's cold start (schema init + realm import) takes
|
||||
~15-20s, too slow to pay per-test. Gated on `docker` being on PATH, exactly
|
||||
like `opa_url`/`OPA_BINARY` in conftest.py -- this whole file is skipped, not
|
||||
failed, in an environment without Docker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from emcp_bus.identity.authn import AuthenticationError, TokenValidator
|
||||
from tests.e2e.conftest import RunningServer, free_port, set_current_gateway_port
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
REALM_EXPORT = REPO_ROOT / "deploy" / "keycloak" / "realm-export.json"
|
||||
|
||||
REALM_NAME = "emcp"
|
||||
CLIENT_SECRETS = {
|
||||
"sales-read-agent": "dev-sales-read-agent-secret",
|
||||
"sales-write-agent": "dev-sales-write-agent-secret",
|
||||
"finance-payments-agent": "dev-finance-payments-agent-secret",
|
||||
}
|
||||
|
||||
|
||||
def _docker_binary() -> str | None:
|
||||
return shutil.which("docker")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def keycloak_url() -> Iterator[str]:
|
||||
docker_binary = _docker_binary()
|
||||
if docker_binary is None:
|
||||
pytest.skip("docker not found on PATH -- required for the real-Keycloak suite")
|
||||
|
||||
port = free_port()
|
||||
container_name = f"emcp-ri-test-keycloak-{port}"
|
||||
# `KC_HOSTNAME` fixed to `127.0.0.1:<port>` so every issued token's `iss`
|
||||
# matches what *this test* (running on the host) uses to reach it --
|
||||
# unlike docker-compose.yml's `keycloak` service, which fixes it to the
|
||||
# internal `keycloak:8080` DNS name instead, since there the Gateway
|
||||
# reaches it over the compose network, not localhost. Same underlying
|
||||
# gotcha either way: Keycloak derives `iss` from the request's Host
|
||||
# header unless pinned, so a token fetched via one path and validated
|
||||
# via another fails issuer validation for a boring reason.
|
||||
subprocess.run(
|
||||
[
|
||||
docker_binary,
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
container_name,
|
||||
"-p",
|
||||
f"{port}:8080",
|
||||
"-e",
|
||||
"KC_BOOTSTRAP_ADMIN_USERNAME=admin",
|
||||
"-e",
|
||||
"KC_BOOTSTRAP_ADMIN_PASSWORD=admin",
|
||||
"-e",
|
||||
# Full URL form, not a bare "host:port" -- Keycloak 26's
|
||||
# `KC_HOSTNAME` only accepts a plain hostname or a complete URL
|
||||
# with scheme ("Provided hostname is neither a plain hostname
|
||||
# nor a valid URL" otherwise); a plain hostname alone can't carry
|
||||
# this test's dynamically allocated port.
|
||||
f"KC_HOSTNAME=http://127.0.0.1:{port}",
|
||||
"-e",
|
||||
"KC_HOSTNAME_STRICT=false",
|
||||
"-v",
|
||||
f"{REALM_EXPORT}:/opt/keycloak/data/import/realm-export.json:ro",
|
||||
"quay.io/keycloak/keycloak:26.7.3",
|
||||
"start-dev",
|
||||
"--import-realm",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
for _ in range(120):
|
||||
try:
|
||||
response = httpx.get(f"{base_url}/realms/{REALM_NAME}", timeout=1)
|
||||
if response.status_code == 200:
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise RuntimeError("Keycloak did not become ready in time")
|
||||
yield base_url
|
||||
finally:
|
||||
subprocess.run([docker_binary, "rm", "-f", container_name], capture_output=True)
|
||||
|
||||
|
||||
def _fetch_token(keycloak_url: str, client_id: str) -> str:
|
||||
response = httpx.post(
|
||||
f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/token",
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": CLIENT_SECRETS[client_id],
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token: str = response.json()["access_token"]
|
||||
return token
|
||||
|
||||
|
||||
# --- TokenValidator against real Keycloak (no Gateway involved) -----------
|
||||
|
||||
|
||||
def test_real_keycloak_token_validates_with_the_real_azp_claim(keycloak_url: str) -> None:
|
||||
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||
validator = TokenValidator(
|
||||
jwks_url=f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs",
|
||||
issuer=f"{keycloak_url}/realms/{REALM_NAME}",
|
||||
audience="emcp-gateway",
|
||||
)
|
||||
|
||||
authenticated = validator.validate(token)
|
||||
|
||||
assert authenticated.client_id == "sales-read-agent"
|
||||
|
||||
|
||||
def test_real_keycloak_token_rejected_for_wrong_audience(keycloak_url: str) -> None:
|
||||
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||
validator = TokenValidator(
|
||||
jwks_url=f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs",
|
||||
issuer=f"{keycloak_url}/realms/{REALM_NAME}",
|
||||
audience="some-other-resource-server",
|
||||
)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
validator.validate(token)
|
||||
|
||||
|
||||
# --- Full Gateway chain against real Keycloak ------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def governed_stack_with_real_keycloak(
|
||||
monkeypatch: pytest.MonkeyPatch, opa_url: str, keycloak_url: str
|
||||
) -> AsyncIterator[None]:
|
||||
"""Same shape as conftest.py's `governed_stack`, except OIDC points at
|
||||
the real, dockerized Keycloak from `keycloak_url` instead of the JWKS
|
||||
stand-in the rest of the suite uses."""
|
||||
import uvicorn
|
||||
|
||||
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", f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs"
|
||||
)
|
||||
monkeypatch.setenv("OIDC_ISSUER", f"{keycloak_url}/realms/{REALM_NAME}")
|
||||
monkeypatch.setenv("OIDC_AUDIENCE", "emcp-gateway")
|
||||
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-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 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 sales_server.stop()
|
||||
|
||||
|
||||
async def test_full_chain_with_real_keycloak_token(
|
||||
governed_stack_with_real_keycloak: None, keycloak_url: str
|
||||
) -> None:
|
||||
"""README.md §45 Teste 1, against a real IdP end to end: the Gateway
|
||||
validates a token *actually issued by Keycloak*, resolves the real
|
||||
Sales.Read entitlement, and reaches the real downstream Sales server."""
|
||||
from tests.e2e.conftest import run_against_gateway
|
||||
|
||||
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||
|
||||
tools = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
assert {t.name for t in tools.tools} == {
|
||||
"sales.customer.search",
|
||||
"sales.customer.get",
|
||||
"sales.order.get",
|
||||
"sales.inventory.check",
|
||||
}
|
||||
|
||||
result = await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.customer.get", {"customer_id": "cust-001"}),
|
||||
token=token,
|
||||
)
|
||||
assert result.is_error is not True
|
||||
assert result.structured_content == {
|
||||
"customer_id": "cust-001",
|
||||
"name": "Aurora Ltda",
|
||||
"region": "BR-SP",
|
||||
}
|
||||
|
||||
|
||||
async def test_cross_domain_denial_with_real_keycloak_token(
|
||||
governed_stack_with_real_keycloak: None, keycloak_url: str
|
||||
) -> None:
|
||||
"""A real Sales.Read token from real Keycloak still cannot reach Finance."""
|
||||
from tests.e2e.conftest import run_against_gateway
|
||||
|
||||
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||
|
||||
result = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", {"amount": 999}),
|
||||
token=token,
|
||||
)
|
||||
assert result.is_error is True
|
||||
163
RI/tests/unit/test_approval.py
Normal file
163
RI/tests/unit/test_approval.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Approval workflow (EP-14-T01/T02/T03, README.md §26, §46 approval replay)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from emcp_bus.approval.models import compute_operation_hash
|
||||
from emcp_bus.approval.service import ApprovalNotFoundError, ApprovalService
|
||||
|
||||
|
||||
def test_operation_hash_is_stable_regardless_of_argument_order() -> None:
|
||||
a = compute_operation_hash(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"amount": 50_000, "currency": "BRL"},
|
||||
)
|
||||
b = compute_operation_hash(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"currency": "BRL", "amount": 50_000},
|
||||
)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_operation_hash_differs_for_different_arguments() -> None:
|
||||
"""A grant for one exact call must never satisfy a different one (README.md §26)."""
|
||||
a = compute_operation_hash(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"amount": 50_000},
|
||||
)
|
||||
b = compute_operation_hash(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"amount": 999_999},
|
||||
)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_unapproved_operation_is_not_consumed() -> None:
|
||||
service = ApprovalService()
|
||||
|
||||
consumed = service.check_and_consume(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"amount": 50_000},
|
||||
)
|
||||
|
||||
assert consumed is False
|
||||
|
||||
|
||||
def test_request_then_grant_then_consume_succeeds_exactly_once() -> None:
|
||||
service = ApprovalService()
|
||||
arguments = {"amount": 50_000}
|
||||
request = service.request(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments=arguments,
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
)
|
||||
|
||||
service.grant(request.approval_id)
|
||||
|
||||
assert (
|
||||
service.check_and_consume(
|
||||
client_profile="Finance.Payments", tool="finance.payment.execute", arguments=arguments
|
||||
)
|
||||
is True
|
||||
)
|
||||
# README.md §46 "approval replay": the second attempt of the *same* call
|
||||
# must not be satisfied by an already-consumed grant.
|
||||
assert (
|
||||
service.check_and_consume(
|
||||
client_profile="Finance.Payments", tool="finance.payment.execute", arguments=arguments
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_denied_request_is_never_consumable() -> None:
|
||||
service = ApprovalService()
|
||||
arguments = {"amount": 50_000}
|
||||
request = service.request(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments=arguments,
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
)
|
||||
|
||||
service.deny(request.approval_id)
|
||||
|
||||
assert (
|
||||
service.check_and_consume(
|
||||
client_profile="Finance.Payments", tool="finance.payment.execute", arguments=arguments
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_grant_does_not_satisfy_a_different_operation() -> None:
|
||||
"""Granting approval for one amount must not let a *different* amount through."""
|
||||
service = ApprovalService()
|
||||
request = service.request(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"amount": 50_000},
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
)
|
||||
service.grant(request.approval_id)
|
||||
|
||||
consumed = service.check_and_consume(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"amount": 999_999}, # different operation entirely
|
||||
)
|
||||
|
||||
assert consumed is False
|
||||
|
||||
|
||||
def test_expired_grant_is_not_consumable() -> None:
|
||||
service = ApprovalService()
|
||||
arguments = {"amount": 50_000}
|
||||
request = service.request(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments=arguments,
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
ttl_seconds=0,
|
||||
)
|
||||
service.grant(request.approval_id)
|
||||
|
||||
consumed = service.check_and_consume(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
assert consumed is False
|
||||
|
||||
|
||||
def test_repeated_pending_requests_for_the_same_operation_do_not_duplicate() -> None:
|
||||
service = ApprovalService()
|
||||
first = service.request(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"amount": 50_000},
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
)
|
||||
second = service.request(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"amount": 50_000},
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
)
|
||||
|
||||
assert first.approval_id == second.approval_id
|
||||
|
||||
|
||||
def test_grant_unknown_approval_id_raises() -> None:
|
||||
service = ApprovalService()
|
||||
|
||||
with pytest.raises(ApprovalNotFoundError):
|
||||
service.grant("no-such-id")
|
||||
139
RI/tests/unit/test_audit.py
Normal file
139
RI/tests/unit/test_audit.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Audit events, sinks and correlation (EP-08-T02/T03, README.md §27/§28)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from emcp_bus.audit import events
|
||||
from emcp_bus.audit.correlation import (
|
||||
DecisionCorrelation,
|
||||
compute_policy_decision_id,
|
||||
new_decision_id,
|
||||
)
|
||||
from emcp_bus.audit.models import EventCategory
|
||||
from emcp_bus.audit.sink import InMemoryAuditSink, JSONLFileAuditSink
|
||||
|
||||
|
||||
def test_tool_call_denied_is_classified_as_grl() -> None:
|
||||
event = events.tool_call_denied(
|
||||
client_id="sales-read-agent",
|
||||
profile_name="Sales.Read",
|
||||
tool="finance.payment.execute",
|
||||
reason_code="NOT_IN_ENTITLEMENT",
|
||||
)
|
||||
|
||||
assert event.category is EventCategory.GRL
|
||||
assert event.reason_code == "NOT_IN_ENTITLEMENT"
|
||||
|
||||
|
||||
def test_tool_call_allowed_is_classified_as_ic() -> None:
|
||||
correlation = DecisionCorrelation(
|
||||
decision_id="d1",
|
||||
policy_decision_id="p1",
|
||||
entitlement_version="e1",
|
||||
policy_version="v1",
|
||||
mcp_request_id="1",
|
||||
)
|
||||
event = events.tool_call_allowed(
|
||||
client_id="sales-read-agent",
|
||||
profile_name="Sales.Read",
|
||||
tool="sales.customer.get",
|
||||
correlation=correlation,
|
||||
)
|
||||
|
||||
assert event.category is EventCategory.IC
|
||||
assert event.decision_id == "d1"
|
||||
assert event.policy_decision_id == "p1"
|
||||
assert event.entitlement_version == "e1"
|
||||
|
||||
|
||||
def test_pdp_unavailable_is_classified_as_noc() -> None:
|
||||
event = events.pdp_unavailable(client_id="sales-read-agent", tool="sales.customer.get")
|
||||
|
||||
assert event.category is EventCategory.NOC
|
||||
|
||||
|
||||
def test_bypass_attempt_detected_is_classified_as_grl() -> None:
|
||||
event = events.bypass_attempt_detected(backend="sales-domain", reason="missing_credential")
|
||||
|
||||
assert event.category is EventCategory.GRL
|
||||
assert event.payload == {"backend": "sales-domain", "reason": "missing_credential"}
|
||||
|
||||
|
||||
def test_event_payload_never_contains_a_raw_token_value() -> None:
|
||||
"""README.md §27, 'Não registrar': a payload key that looks like a secret
|
||||
is replaced by a hash reference, never left as the raw value."""
|
||||
event = events.tool_call_allowed(
|
||||
client_id="c1",
|
||||
profile_name="Sales.Read",
|
||||
tool="sales.customer.get",
|
||||
correlation=DecisionCorrelation(
|
||||
decision_id="d1",
|
||||
policy_decision_id="p1",
|
||||
entitlement_version=None,
|
||||
policy_version=None,
|
||||
mcp_request_id=None,
|
||||
),
|
||||
)
|
||||
event.payload = {"backend_token": "super-secret-value", "customer_id": "cust-001"}
|
||||
# Re-trigger sanitization the way construction does (model_post_init runs
|
||||
# once at construction; this simulates a caller building payload then
|
||||
# assigning it, which the real call sites never do -- included as an
|
||||
# explicit regression guard on the sanitizer itself).
|
||||
from emcp_bus.audit.models import _sanitize
|
||||
|
||||
sanitized = _sanitize(event.payload)
|
||||
assert sanitized["backend_token"].startswith("sha256:")
|
||||
assert sanitized["backend_token"] != "super-secret-value"
|
||||
assert sanitized["customer_id"] == "cust-001"
|
||||
|
||||
|
||||
def test_in_memory_sink_collects_emitted_events() -> None:
|
||||
sink = InMemoryAuditSink()
|
||||
event = events.client_authenticated(client_id="c1", mcp_request_id="1")
|
||||
|
||||
sink.emit(event)
|
||||
|
||||
assert sink.events == [event]
|
||||
|
||||
|
||||
def test_jsonl_file_sink_appends_one_line_per_event(tmp_path: Path) -> None:
|
||||
path = tmp_path / "audit" / "events.jsonl"
|
||||
sink = JSONLFileAuditSink(path)
|
||||
|
||||
sink.emit(events.client_authenticated(client_id="c1", mcp_request_id="1"))
|
||||
sink.emit(events.client_authenticated(client_id="c2", mcp_request_id="2"))
|
||||
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
assert len(lines) == 2
|
||||
assert json.loads(lines[0])["client_id"] == "c1"
|
||||
assert json.loads(lines[1])["client_id"] == "c2"
|
||||
|
||||
|
||||
def test_new_decision_id_is_unique_per_call() -> None:
|
||||
assert new_decision_id() != new_decision_id()
|
||||
|
||||
|
||||
def test_policy_decision_id_is_deterministic_for_identical_inputs() -> None:
|
||||
def _compute() -> str:
|
||||
return compute_policy_decision_id(
|
||||
policy_version="v1",
|
||||
client_profile="Sales.Write",
|
||||
tool="sales.quote.create",
|
||||
arguments={"amount": 100, "region": "BR-SP"},
|
||||
)
|
||||
|
||||
assert _compute() == _compute()
|
||||
|
||||
|
||||
def test_policy_decision_id_differs_for_different_arguments() -> None:
|
||||
def _compute(amount: int) -> str:
|
||||
return compute_policy_decision_id(
|
||||
policy_version="v1",
|
||||
client_profile="Sales.Write",
|
||||
tool="sales.quote.create",
|
||||
arguments={"amount": amount},
|
||||
)
|
||||
|
||||
assert _compute(100) != _compute(200)
|
||||
87
RI/tests/unit/test_bypass_detection.py
Normal file
87
RI/tests/unit/test_bypass_detection.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Anti-bypass credential gate for domain servers (EP-05-T07, README.md §21).
|
||||
|
||||
Real ASGI request handling (`httpx.ASGITransport`) against a real
|
||||
`BackendCredentialGate`-wrapped app -- not a hand-mocked request object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from emcp_bus.audit.bypass_detection import BackendCredentialGate
|
||||
from emcp_bus.audit.sink import InMemoryAuditSink
|
||||
|
||||
|
||||
async def _ok(request: object) -> JSONResponse:
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
def _gated_app(audit_sink: InMemoryAuditSink | None = None) -> BackendCredentialGate:
|
||||
inner = Starlette(routes=[Route("/mcp", _ok, methods=["POST"])])
|
||||
return BackendCredentialGate(
|
||||
inner,
|
||||
expected_token="the-real-gateway-credential",
|
||||
backend="sales-domain",
|
||||
audit_sink=audit_sink,
|
||||
)
|
||||
|
||||
|
||||
async def test_request_with_correct_bearer_token_reaches_the_app() -> None:
|
||||
app = _gated_app()
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/mcp", headers={"Authorization": "Bearer the-real-gateway-credential"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
|
||||
|
||||
async def test_request_with_no_authorization_header_is_rejected() -> None:
|
||||
sink = InMemoryAuditSink()
|
||||
app = _gated_app(sink)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
response = await client.post("/mcp")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert len(sink.events) == 1
|
||||
assert sink.events[0].event_type == "bypass_attempt_detected"
|
||||
assert sink.events[0].payload["reason"] == "missing_credential"
|
||||
|
||||
|
||||
async def test_request_with_wrong_bearer_token_is_rejected() -> None:
|
||||
"""This is the concrete Teste 7 (§45, 'Gateway bypass') scenario: someone
|
||||
who can reach the domain server's port directly, but does not hold the
|
||||
Gateway's outbound service credential, must not reach a tool handler."""
|
||||
sink = InMemoryAuditSink()
|
||||
app = _gated_app(sink)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/mcp", headers={"Authorization": "Bearer someone-elses-token"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert sink.events[0].payload["reason"] == "wrong_credential"
|
||||
|
||||
|
||||
async def test_a_stolen_inbound_client_token_does_not_satisfy_the_gate() -> None:
|
||||
"""README.md §20/ADR-006: even a real, valid *inbound* MCP Client token
|
||||
is not the outbound credential -- presenting one here must still fail."""
|
||||
app = _gated_app()
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/mcp", headers={"Authorization": "Bearer some-inbound-oidc-access-token"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
72
RI/tests/unit/test_canonical_request.py
Normal file
72
RI/tests/unit/test_canonical_request.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Header/body consistency check (EP-05-T08, README.md §22, SEP-2243)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from emcp_bus.gateway.canonical_request import find_header_body_mismatch
|
||||
|
||||
|
||||
def _body(method: str, params: dict[str, object] | None = None) -> bytes:
|
||||
return json.dumps(
|
||||
{"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}
|
||||
).encode()
|
||||
|
||||
|
||||
def test_no_headers_present_is_never_a_mismatch() -> None:
|
||||
assert (
|
||||
find_header_body_mismatch(_body("tools/call"), method_header=None, name_header=None) is None
|
||||
)
|
||||
|
||||
|
||||
def test_matching_method_header_is_not_a_mismatch() -> None:
|
||||
body = _body("tools/list")
|
||||
|
||||
assert find_header_body_mismatch(body, method_header="tools/list", name_header=None) is None
|
||||
|
||||
|
||||
def test_mismatched_method_header_is_rejected() -> None:
|
||||
body = _body("tools/list")
|
||||
|
||||
reason = find_header_body_mismatch(body, method_header="tools/call", name_header=None)
|
||||
|
||||
assert reason is not None
|
||||
assert "mcp-method" in reason
|
||||
|
||||
|
||||
def test_matching_name_header_for_tools_call_is_not_a_mismatch() -> None:
|
||||
body = _body("tools/call", {"name": "sales.customer.get", "arguments": {}})
|
||||
|
||||
reason = find_header_body_mismatch(
|
||||
body, method_header="tools/call", name_header="sales.customer.get"
|
||||
)
|
||||
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_mismatched_name_header_for_tools_call_is_rejected() -> None:
|
||||
"""The concrete attack this closes: a header claiming one tool while the
|
||||
body actually invokes another -- whichever a naive reader trusted, the
|
||||
request must instead be rejected outright (README.md §22)."""
|
||||
body = _body("tools/call", {"name": "finance.payment.execute", "arguments": {}})
|
||||
|
||||
reason = find_header_body_mismatch(
|
||||
body, method_header="tools/call", name_header="sales.customer.get"
|
||||
)
|
||||
|
||||
assert reason is not None
|
||||
assert "mcp-name" in reason
|
||||
|
||||
|
||||
def test_name_header_is_irrelevant_for_a_non_name_bearing_method() -> None:
|
||||
body = _body("tools/list")
|
||||
|
||||
reason = find_header_body_mismatch(body, method_header="tools/list", name_header="anything")
|
||||
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_unparseable_body_is_left_to_the_app_itself() -> None:
|
||||
reason = find_header_body_mismatch(b"not json", method_header="tools/list", name_header=None)
|
||||
|
||||
assert reason is None
|
||||
127
RI/tests/unit/test_downstream_identity.py
Normal file
127
RI/tests/unit/test_downstream_identity.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Outbound (backend) identity (EP-07-T01/T02, README.md §20)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from emcp_bus.downstream.identity import (
|
||||
BackendCredentialProvider,
|
||||
BackendCredentialUnavailableError,
|
||||
UnknownBackendError,
|
||||
)
|
||||
from emcp_bus.downstream.models import BackendConfig
|
||||
|
||||
|
||||
def test_backend_config_rejects_a_literal_secret_in_the_env_var_name_field() -> None:
|
||||
"""README.md §35.5: credentials must never live inline in config."""
|
||||
with pytest.raises(ValidationError):
|
||||
BackendConfig(
|
||||
backend="sales-domain",
|
||||
url="http://127.0.0.1:8100/mcp",
|
||||
audience="sales-domain-service",
|
||||
service_account_token_env_var="eyJhbGciOiJSUzI1NiJ9.not-an-env-var-name",
|
||||
)
|
||||
|
||||
|
||||
def test_backend_config_requires_env_var_for_service_account_mode() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
BackendConfig(
|
||||
backend="sales-domain", url="http://127.0.0.1:8100/mcp", audience="sales-domain-service"
|
||||
)
|
||||
|
||||
|
||||
def test_backend_config_requires_endpoint_for_token_exchange_mode() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
BackendConfig(
|
||||
backend="sales-domain",
|
||||
url="http://127.0.0.1:8100/mcp",
|
||||
audience="sales-domain-service",
|
||||
credential_mode="token_exchange",
|
||||
)
|
||||
|
||||
|
||||
def test_credential_for_configured_backend_resolves_from_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "backend-secret-token")
|
||||
provider = BackendCredentialProvider(
|
||||
[
|
||||
BackendConfig(
|
||||
backend="sales-domain",
|
||||
url="http://127.0.0.1:8100/mcp",
|
||||
audience="sales-domain-service",
|
||||
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
credential = provider.credential_for("sales-domain")
|
||||
|
||||
assert credential.token == "backend-secret-token"
|
||||
assert credential.audience == "sales-domain-service"
|
||||
|
||||
|
||||
def test_credential_differs_from_a_hypothetical_inbound_client_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The property README.md §20/ADR-006 actually cares about: whatever the
|
||||
inbound MCP Client token was, the outbound credential is never it."""
|
||||
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "backend-secret-token")
|
||||
provider = BackendCredentialProvider(
|
||||
[
|
||||
BackendConfig(
|
||||
backend="sales-domain",
|
||||
url="http://127.0.0.1:8100/mcp",
|
||||
audience="sales-domain-service",
|
||||
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||
)
|
||||
]
|
||||
)
|
||||
inbound_client_token = "inbound-oidc-access-token" # nosec -- test fixture, not a real secret
|
||||
|
||||
credential = provider.credential_for("sales-domain")
|
||||
|
||||
assert credential.token != inbound_client_token
|
||||
assert credential.audience != "emcp-gateway" # the Gateway's own inbound audience
|
||||
|
||||
|
||||
def test_unknown_backend_raises() -> None:
|
||||
provider = BackendCredentialProvider([])
|
||||
|
||||
with pytest.raises(UnknownBackendError):
|
||||
provider.credential_for("no-such-backend")
|
||||
|
||||
|
||||
def test_missing_env_var_at_call_time_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("SALES_DOMAIN_SERVICE_TOKEN", raising=False)
|
||||
provider = BackendCredentialProvider(
|
||||
[
|
||||
BackendConfig(
|
||||
backend="sales-domain",
|
||||
url="http://127.0.0.1:8100/mcp",
|
||||
audience="sales-domain-service",
|
||||
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(BackendCredentialUnavailableError):
|
||||
provider.credential_for("sales-domain")
|
||||
|
||||
|
||||
def test_token_exchange_mode_is_a_documented_but_unimplemented_extension() -> None:
|
||||
provider = BackendCredentialProvider(
|
||||
[
|
||||
BackendConfig(
|
||||
backend="sales-domain",
|
||||
url="http://127.0.0.1:8100/mcp",
|
||||
audience="sales-domain-service",
|
||||
credential_mode="token_exchange",
|
||||
token_exchange_endpoint="https://keycloak.example/realms/emcp/token",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
provider.credential_for("sales-domain")
|
||||
114
RI/tests/unit/test_fabric_router.py
Normal file
114
RI/tests/unit/test_fabric_router.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Fabric core capability router (EP-06-T01, README.md §6.7).
|
||||
|
||||
Real Registry (seeded from the actual config/capabilities/ manifests, same
|
||||
as tests/unit/test_registry_seed_manifests.py) + real BackendCredentialProvider,
|
||||
not hand-built fixtures -- a config mistake in either fails here too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from emcp_bus.downstream.identity import BackendCredentialProvider
|
||||
from emcp_bus.downstream.models import BackendConfig
|
||||
from emcp_bus.fabric.router import CapabilityRouter, UnroutableCapabilityError
|
||||
from emcp_bus.registry.models import LifecycleStatus
|
||||
from emcp_bus.registry.seed import load_all_capability_manifests, publish_all
|
||||
from emcp_bus.registry.store import RegistryStore
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
CAPABILITIES_DIR = REPO_ROOT / "config" / "capabilities"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CapabilityRouter:
|
||||
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "sales-secret")
|
||||
monkeypatch.setenv("FINANCE_DOMAIN_SERVICE_TOKEN", "finance-secret")
|
||||
|
||||
store = RegistryStore(tmp_path / "registry.db")
|
||||
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
|
||||
|
||||
backend_credentials = BackendCredentialProvider(
|
||||
[
|
||||
BackendConfig(
|
||||
backend="sales-domain",
|
||||
url="http://127.0.0.1:8100/mcp",
|
||||
audience="sales-domain-service",
|
||||
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||
),
|
||||
BackendConfig(
|
||||
backend="finance-domain",
|
||||
url="http://127.0.0.1:8101/mcp",
|
||||
audience="finance-domain-service",
|
||||
service_account_token_env_var="FINANCE_DOMAIN_SERVICE_TOKEN",
|
||||
),
|
||||
]
|
||||
)
|
||||
return CapabilityRouter(store, backend_credentials)
|
||||
|
||||
|
||||
def test_sales_capability_routes_to_sales_backend_not_finance(router: CapabilityRouter) -> None:
|
||||
"""README.md §6.7 EP-06-T01 acceptance: a Sales capability must reach the
|
||||
Sales backend, never Finance."""
|
||||
route = router.route("sales.customer.get")
|
||||
|
||||
assert route.backend_id == "sales-domain"
|
||||
assert route.url == "http://127.0.0.1:8100/mcp"
|
||||
assert route.credential_token == "sales-secret"
|
||||
|
||||
|
||||
def test_finance_capability_routes_to_finance_backend(router: CapabilityRouter) -> None:
|
||||
route = router.route("finance.payment.execute")
|
||||
|
||||
assert route.backend_id == "finance-domain"
|
||||
assert route.url == "http://127.0.0.1:8101/mcp"
|
||||
assert route.credential_token == "finance-secret"
|
||||
|
||||
|
||||
def test_unknown_capability_is_unroutable(router: CapabilityRouter) -> None:
|
||||
with pytest.raises(UnroutableCapabilityError):
|
||||
router.route("sales.does.not.exist")
|
||||
|
||||
|
||||
def test_deprecated_capability_is_unroutable(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Only ACTIVE capabilities are routable (README.md §6.6) -- a capability
|
||||
that was published then deprecated must stop being reachable."""
|
||||
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "sales-secret")
|
||||
store = RegistryStore(tmp_path / "registry.db")
|
||||
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
|
||||
store.set_lifecycle_status("sales.customer.get", LifecycleStatus.DEPRECATED)
|
||||
|
||||
backend_credentials = BackendCredentialProvider(
|
||||
[
|
||||
BackendConfig(
|
||||
backend="sales-domain",
|
||||
url="http://127.0.0.1:8100/mcp",
|
||||
audience="sales-domain-service",
|
||||
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||
)
|
||||
]
|
||||
)
|
||||
router = CapabilityRouter(store, backend_credentials)
|
||||
|
||||
with pytest.raises(UnroutableCapabilityError):
|
||||
router.route("sales.customer.get")
|
||||
|
||||
|
||||
def test_distinct_backends_for_sales_only_entitlement_excludes_finance(
|
||||
router: CapabilityRouter,
|
||||
) -> None:
|
||||
sales_read_tools = frozenset(
|
||||
{"sales.customer.search", "sales.customer.get", "sales.order.get", "sales.inventory.check"}
|
||||
)
|
||||
|
||||
assert router.distinct_backends_for(sales_read_tools) == {"sales-domain"}
|
||||
|
||||
|
||||
def test_distinct_backends_for_mixed_entitlement_includes_both(router: CapabilityRouter) -> None:
|
||||
mixed_tools = frozenset({"sales.customer.get", "finance.invoice.get"})
|
||||
|
||||
assert router.distinct_backends_for(mixed_tools) == {"sales-domain", "finance-domain"}
|
||||
23
RI/tests/unit/test_gateway_cache.py
Normal file
23
RI/tests/unit/test_gateway_cache.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Private tools/list cache hint (EP-05-T03, README.md §13/§28)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from emcp_bus.gateway.cache import tools_list_cache_hint
|
||||
|
||||
|
||||
def test_default_hint_is_private_scope_with_a_positive_ttl() -> None:
|
||||
hint = tools_list_cache_hint()
|
||||
|
||||
assert hint.scope == "private"
|
||||
assert hint.ttl_ms > 0
|
||||
|
||||
|
||||
def test_ttl_is_configurable_via_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("EMCP_TOOLS_LIST_CACHE_TTL_MS", "12345")
|
||||
|
||||
hint = tools_list_cache_hint()
|
||||
|
||||
assert hint.ttl_ms == 12345
|
||||
assert hint.scope == "private" # never configurable to "public" (Teste 5, §45)
|
||||
165
RI/tests/unit/test_registry.py
Normal file
165
RI/tests/unit/test_registry.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Registry store + publishing pipeline + lifecycle state machine (EP-02-T02/T03/T04)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from emcp_bus.registry.lifecycle import InvalidLifecycleTransitionError, validate_transition
|
||||
from emcp_bus.registry.models import CapabilityManifest, LifecycleStatus
|
||||
from emcp_bus.registry.pipeline import PublishingPipeline, PublishingRejectedError
|
||||
from emcp_bus.registry.store import CapabilityNotFoundError, RegistryStore
|
||||
|
||||
|
||||
def _manifest(**overrides: object) -> CapabilityManifest:
|
||||
data: dict[str, object] = {
|
||||
"name": "sales.customer.get",
|
||||
"version": "1.0.0",
|
||||
"domain": "sales",
|
||||
"owner": "sales-platform",
|
||||
"risk_tier": "R1",
|
||||
"side_effects": False,
|
||||
"idempotent": True,
|
||||
"entitlements": ["Sales.Read"],
|
||||
"backend": {"type": "mcp", "service": "sales-domain"},
|
||||
}
|
||||
data.update(overrides)
|
||||
return CapabilityManifest.model_validate(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> Iterator[RegistryStore]:
|
||||
yield RegistryStore(tmp_path / "registry.db")
|
||||
|
||||
|
||||
def test_upsert_and_get_round_trip(store: RegistryStore) -> None:
|
||||
store.upsert(_manifest())
|
||||
|
||||
fetched = store.get("sales.customer.get")
|
||||
|
||||
assert fetched.owner == "sales-platform"
|
||||
assert fetched.risk_tier.value == "R1"
|
||||
|
||||
|
||||
def test_get_missing_capability_raises(store: RegistryStore) -> None:
|
||||
with pytest.raises(CapabilityNotFoundError):
|
||||
store.get("no.such.capability")
|
||||
|
||||
|
||||
def test_list_active_excludes_non_active_capabilities(store: RegistryStore) -> None:
|
||||
store.upsert(_manifest(name="sales.customer.get")) # default lifecycle: NOMINATE
|
||||
active = _manifest(name="sales.order.get")
|
||||
active = active.model_copy(
|
||||
update={"lifecycle": active.lifecycle.model_copy(update={"status": LifecycleStatus.ACTIVE})}
|
||||
)
|
||||
store.upsert(active)
|
||||
|
||||
result = store.list_active()
|
||||
|
||||
assert [m.name for m in result] == ["sales.order.get"]
|
||||
|
||||
|
||||
def test_list_by_domain_filters_correctly(store: RegistryStore) -> None:
|
||||
store.upsert(_manifest(name="sales.customer.get", domain="sales"))
|
||||
store.upsert(
|
||||
_manifest(
|
||||
name="finance.invoice.get",
|
||||
domain="finance",
|
||||
entitlements=["Finance.Read"],
|
||||
backend={"type": "mcp", "service": "finance-domain"},
|
||||
)
|
||||
)
|
||||
|
||||
assert [m.name for m in store.list_by_domain("finance")] == ["finance.invoice.get"]
|
||||
|
||||
|
||||
# --- lifecycle state machine ------------------------------------------------
|
||||
|
||||
|
||||
def test_nominate_to_review_is_allowed() -> None:
|
||||
validate_transition(LifecycleStatus.NOMINATE, LifecycleStatus.REVIEW)
|
||||
|
||||
|
||||
def test_nominate_directly_to_active_is_rejected() -> None:
|
||||
"""The task's own acceptance criterion: never skip REVIEW on the way to ACTIVE."""
|
||||
with pytest.raises(InvalidLifecycleTransitionError):
|
||||
validate_transition(LifecycleStatus.NOMINATE, LifecycleStatus.ACTIVE)
|
||||
|
||||
|
||||
def test_register_directly_to_active_requires_no_skip_but_is_the_publish_step() -> None:
|
||||
validate_transition(LifecycleStatus.REGISTER, LifecycleStatus.ACTIVE)
|
||||
|
||||
|
||||
def test_retired_is_terminal() -> None:
|
||||
with pytest.raises(InvalidLifecycleTransitionError):
|
||||
validate_transition(LifecycleStatus.RETIRED, LifecycleStatus.ACTIVE)
|
||||
|
||||
|
||||
# --- publishing pipeline -----------------------------------------------------
|
||||
|
||||
|
||||
def test_pipeline_submit_then_publish_reaches_active(store: RegistryStore) -> None:
|
||||
pipeline = PublishingPipeline(store)
|
||||
|
||||
registered = pipeline.submit(_manifest())
|
||||
assert registered.lifecycle.status is LifecycleStatus.REGISTER
|
||||
|
||||
published = pipeline.publish("sales.customer.get")
|
||||
assert published.lifecycle.status is LifecycleStatus.ACTIVE
|
||||
|
||||
|
||||
def test_pipeline_rejects_high_risk_capability_without_approval(store: RegistryStore) -> None:
|
||||
pipeline = PublishingPipeline(store)
|
||||
manifest = _manifest(
|
||||
name="finance.payment.execute",
|
||||
domain="finance",
|
||||
risk_tier="R3",
|
||||
side_effects=True,
|
||||
idempotent=False,
|
||||
entitlements=["Finance.Payments"],
|
||||
backend={"type": "mcp", "service": "finance-domain"},
|
||||
)
|
||||
|
||||
with pytest.raises(PublishingRejectedError) as exc_info:
|
||||
pipeline.submit(manifest)
|
||||
|
||||
assert any("approval" in reason for reason in exc_info.value.reasons)
|
||||
|
||||
|
||||
def test_pipeline_accepts_high_risk_capability_with_approval_declared(store: RegistryStore) -> None:
|
||||
pipeline = PublishingPipeline(store)
|
||||
manifest = _manifest(
|
||||
name="finance.payment.execute",
|
||||
domain="finance",
|
||||
risk_tier="R3",
|
||||
side_effects=True,
|
||||
idempotent=False,
|
||||
entitlements=["Finance.Payments"],
|
||||
backend={"type": "mcp", "service": "finance-domain"},
|
||||
approval={"required": True},
|
||||
)
|
||||
|
||||
registered = pipeline.submit(manifest)
|
||||
|
||||
assert registered.lifecycle.status is LifecycleStatus.REGISTER
|
||||
|
||||
|
||||
def test_pipeline_rejects_manifest_with_blank_owner(store: RegistryStore) -> None:
|
||||
pipeline = PublishingPipeline(store)
|
||||
|
||||
with pytest.raises(PublishingRejectedError) as exc_info:
|
||||
pipeline.submit(_manifest(owner=" "))
|
||||
|
||||
assert any("owner" in reason for reason in exc_info.value.reasons)
|
||||
|
||||
|
||||
def test_publish_before_register_is_rejected(store: RegistryStore) -> None:
|
||||
"""A manifest sitting in NOMINATE/REVIEW (e.g. review not yet approved)
|
||||
must not be publishable -- README.md §23 governance lifecycle."""
|
||||
store.upsert(_manifest()) # lifecycle defaults to NOMINATE
|
||||
|
||||
pipeline = PublishingPipeline(store)
|
||||
with pytest.raises(InvalidLifecycleTransitionError):
|
||||
pipeline.publish("sales.customer.get")
|
||||
57
RI/tests/unit/test_registry_seed_manifests.py
Normal file
57
RI/tests/unit/test_registry_seed_manifests.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""EP-02-T05: the real seed manifests under config/capabilities/ go through the
|
||||
real pipeline end to end -- not hand-built fixtures, so a schema mistake or a
|
||||
missing `approval.required` on an R3 capability fails here too."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from emcp_bus.registry.models import LifecycleStatus
|
||||
from emcp_bus.registry.seed import load_all_capability_manifests, publish_all
|
||||
from emcp_bus.registry.store import RegistryStore
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
CAPABILITIES_DIR = REPO_ROOT / "config" / "capabilities"
|
||||
|
||||
|
||||
def test_all_seed_manifests_publish_successfully(tmp_path: Path) -> None:
|
||||
store = RegistryStore(tmp_path / "registry.db")
|
||||
manifests = load_all_capability_manifests(CAPABILITIES_DIR)
|
||||
|
||||
assert len(manifests) == 9 # 6 sales + 3 finance -- update this if seed data changes
|
||||
|
||||
publish_all(store, manifests)
|
||||
|
||||
active = store.list_active()
|
||||
assert len(active) == len(manifests)
|
||||
assert all(m.lifecycle.status is LifecycleStatus.ACTIVE for m in active)
|
||||
|
||||
|
||||
def test_seed_manifests_by_domain(tmp_path: Path) -> None:
|
||||
store = RegistryStore(tmp_path / "registry.db")
|
||||
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
|
||||
|
||||
sales_tools = {m.name for m in store.list_by_domain("sales")}
|
||||
finance_tools = {m.name for m in store.list_by_domain("finance")}
|
||||
|
||||
assert sales_tools == {
|
||||
"sales.customer.search",
|
||||
"sales.customer.get",
|
||||
"sales.order.get",
|
||||
"sales.inventory.check",
|
||||
"sales.quote.create",
|
||||
"sales.quote.update",
|
||||
}
|
||||
assert finance_tools == {
|
||||
"finance.invoice.get",
|
||||
"finance.payment.create",
|
||||
"finance.payment.execute",
|
||||
}
|
||||
|
||||
|
||||
def test_finance_payment_execute_is_r3_with_approval_required() -> None:
|
||||
manifests = {m.name: m for m in load_all_capability_manifests(CAPABILITIES_DIR)}
|
||||
payment_execute = manifests["finance.payment.execute"]
|
||||
|
||||
assert payment_execute.risk_tier.value == "R3"
|
||||
assert payment_execute.approval.required is True
|
||||
104
RI/tests/unit/test_rest_adapter.py
Normal file
104
RI/tests/unit/test_rest_adapter.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Generic REST adapter (EP-06-T04, README.md §6.7).
|
||||
|
||||
Real httpx request/response handling against an `httpx.MockTransport`
|
||||
in-process endpoint -- no real REST backend exists in this RI (both example
|
||||
domains are MCP-native, see the module docstring in rest_adapter.py), so this
|
||||
is the closest equivalent to "real dependency, fake network" available for
|
||||
this specific adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from emcp_bus.fabric.adapters.rest_adapter import (
|
||||
RestAdapter,
|
||||
RestBackendConfig,
|
||||
RestOperation,
|
||||
UnknownOperationError,
|
||||
)
|
||||
|
||||
CONFIG = RestBackendConfig(
|
||||
backend="crm-rest",
|
||||
base_url="https://crm.example",
|
||||
operations={
|
||||
"crm.customer.get": RestOperation(method="GET", path="/v2/customers/{customer_id}"),
|
||||
"crm.customer.create": RestOperation(method="POST", path="/v2/customers"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _adapter(handler: httpx.MockTransport) -> RestAdapter:
|
||||
client = httpx.AsyncClient(base_url=CONFIG.base_url, transport=handler)
|
||||
return RestAdapter(CONFIG, client=client)
|
||||
|
||||
|
||||
async def test_get_operation_substitutes_path_placeholder_and_sends_remaining_as_query() -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def handle(request: httpx.Request) -> httpx.Response:
|
||||
captured["request"] = request
|
||||
return httpx.Response(200, json={"customer_id": "cust-001", "name": "Aurora Ltda"})
|
||||
|
||||
adapter = _adapter(httpx.MockTransport(handle))
|
||||
|
||||
result = await adapter.call("crm.customer.get", {"customer_id": "cust-001", "region": "BR-SP"})
|
||||
|
||||
assert result == {"customer_id": "cust-001", "name": "Aurora Ltda"}
|
||||
request = captured["request"]
|
||||
assert request.url.path == "/v2/customers/cust-001"
|
||||
assert dict(request.url.params) == {"region": "BR-SP"}
|
||||
await adapter.aclose()
|
||||
|
||||
|
||||
async def test_post_operation_sends_remaining_arguments_as_json_body() -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def handle(request: httpx.Request) -> httpx.Response:
|
||||
captured["request"] = request
|
||||
return httpx.Response(201, json={"customer_id": "cust-002"})
|
||||
|
||||
adapter = _adapter(httpx.MockTransport(handle))
|
||||
|
||||
result = await adapter.call("crm.customer.create", {"name": "Boreal S.A."})
|
||||
|
||||
assert result == {"customer_id": "cust-002"}
|
||||
body = json.loads(captured["request"].content)
|
||||
assert body == {"name": "Boreal S.A."}
|
||||
await adapter.aclose()
|
||||
|
||||
|
||||
async def test_bearer_token_is_forwarded_as_authorization_header() -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def handle(request: httpx.Request) -> httpx.Response:
|
||||
captured["request"] = request
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
adapter = _adapter(httpx.MockTransport(handle))
|
||||
|
||||
await adapter.call(
|
||||
"crm.customer.get", {"customer_id": "cust-001"}, bearer_token="outbound-token"
|
||||
)
|
||||
|
||||
assert captured["request"].headers["authorization"] == "Bearer outbound-token"
|
||||
await adapter.aclose()
|
||||
|
||||
|
||||
async def test_unconfigured_capability_raises() -> None:
|
||||
adapter = _adapter(httpx.MockTransport(lambda request: httpx.Response(200, json={})))
|
||||
|
||||
with pytest.raises(UnknownOperationError):
|
||||
await adapter.call("crm.customer.delete", {})
|
||||
await adapter.aclose()
|
||||
|
||||
|
||||
async def test_backend_error_status_raises() -> None:
|
||||
adapter = _adapter(httpx.MockTransport(lambda request: httpx.Response(500)))
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await adapter.call("crm.customer.get", {"customer_id": "cust-001"})
|
||||
await adapter.aclose()
|
||||
72
RI/tests/unit/test_shared_client_lint.py
Normal file
72
RI/tests/unit/test_shared_client_lint.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Shared-client lint (EP-01-T05, README.md §18/§35)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from emcp_bus.identity.models import ClientRegistration
|
||||
from emcp_bus.identity.shared_client_lint import (
|
||||
SharedClientLintError,
|
||||
assert_no_shared_client_violations,
|
||||
find_shared_client_violations,
|
||||
)
|
||||
|
||||
|
||||
def test_distinct_client_ids_are_never_a_violation() -> None:
|
||||
registrations = [
|
||||
ClientRegistration(client_id="sales-read-agent", profile="Sales.Read"),
|
||||
ClientRegistration(client_id="sales-write-agent", profile="Sales.Write"),
|
||||
]
|
||||
|
||||
assert find_shared_client_violations(registrations) == []
|
||||
|
||||
|
||||
def test_same_client_id_and_same_profile_is_not_a_violation() -> None:
|
||||
"""Sharing is fine when both registrations agree on the profile -- this
|
||||
is the legitimate case README.md §18 describes."""
|
||||
registrations = [
|
||||
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
|
||||
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
|
||||
]
|
||||
|
||||
assert find_shared_client_violations(registrations) == []
|
||||
|
||||
|
||||
def test_same_client_id_with_different_profiles_is_a_violation() -> None:
|
||||
registrations = [
|
||||
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
|
||||
ClientRegistration(client_id="shared-agent", profile="Finance.Payments"),
|
||||
]
|
||||
|
||||
violations = find_shared_client_violations(registrations)
|
||||
|
||||
assert len(violations) == 1
|
||||
assert "shared-agent" in violations[0]
|
||||
|
||||
|
||||
def test_assert_raises_with_all_violations_when_present() -> None:
|
||||
registrations = [
|
||||
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
|
||||
ClientRegistration(client_id="shared-agent", profile="Finance.Payments"),
|
||||
]
|
||||
|
||||
with pytest.raises(SharedClientLintError):
|
||||
assert_no_shared_client_violations(registrations)
|
||||
|
||||
|
||||
def test_assert_does_not_raise_when_clean() -> None:
|
||||
registrations = [ClientRegistration(client_id="sales-read-agent", profile="Sales.Read")]
|
||||
|
||||
assert_no_shared_client_violations(registrations) # must not raise
|
||||
|
||||
|
||||
def test_the_real_seed_client_registrations_have_no_violations() -> None:
|
||||
"""The RI's own config/clients/*.yaml must pass its own lint."""
|
||||
from pathlib import Path
|
||||
|
||||
from emcp_bus.common.config import load_yaml_models
|
||||
|
||||
repo_root = Path(__file__).parents[2]
|
||||
registrations = load_yaml_models(repo_root / "config" / "clients", ClientRegistration)
|
||||
|
||||
assert_no_shared_client_violations(registrations) # must not raise
|
||||
Reference in New Issue
Block a user