mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
166 lines
5.5 KiB
Python
166 lines
5.5 KiB
Python
"""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")
|