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