M0 e M1 revisados e fixes implementados

This commit is contained in:
Marcos Lohmann
2026-09-14 08:18:53 -03:00
parent 439f60598e
commit f3efcc3414
72 changed files with 4831 additions and 128 deletions

View File

@@ -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:

View File

@@ -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()

View 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

View 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
View 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)

View 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

View 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

View 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")

View 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"}

View 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)

View 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")

View 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

View 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()

View 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