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

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