mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
310 lines
11 KiB
Python
310 lines
11 KiB
Python
"""Journey StateGraph (EP-11-T02/T04/T05), against real OPA + the real seed
|
|
config/ + a real SQLite checkpointer -- not hand-built fixtures, matching
|
|
the rest of this RI's "real dependencies over mocks" testing style.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
from collections.abc import AsyncIterator, Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
from langgraph.types import Command
|
|
|
|
from emcp_bus.common.config import load_yaml_model, load_yaml_models
|
|
from emcp_bus.entitlement.manager import EntitlementManager
|
|
from emcp_bus.entitlement.models import ClientProfile
|
|
from emcp_bus.identity.models import ClientRegistration
|
|
from emcp_bus.offering_filter.service import ConsentContext, OfferingFilterService
|
|
from emcp_bus.orchestrator.decision_context import DecisionContext
|
|
from emcp_bus.orchestrator.decisioning import DEFAULT_AGENT, RuleBasedNBADecisionModel
|
|
from emcp_bus.orchestrator.graph import build_journey_graph
|
|
from emcp_bus.orchestrator.nba_model import NBADecision
|
|
from emcp_bus.pdp.client import PDPClient, compute_policy_version
|
|
from emcp_bus.profile_intelligence.rule_based_provider import RuleBasedProfileProvider
|
|
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]
|
|
POLICIES_DIR = REPO_ROOT / "config" / "policies"
|
|
PROFILES_DIR = REPO_ROOT / "config" / "clients" / "profiles"
|
|
CLIENTS_DIR = REPO_ROOT / "config" / "clients"
|
|
CAPABILITIES_DIR = REPO_ROOT / "config" / "capabilities"
|
|
|
|
SALES_READ_CLIENT_ID = "sales-read-agent"
|
|
FINANCE_PAYMENTS_CLIENT_ID = "finance-payments-agent"
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return int(s.getsockname()[1])
|
|
|
|
|
|
@pytest.fixture
|
|
async def entitlement_manager() -> AsyncIterator[EntitlementManager]:
|
|
opa_binary = shutil.which(os.environ.get("OPA_BINARY", "opa"))
|
|
if opa_binary is None:
|
|
pytest.skip("opa binary not found on PATH (set OPA_BINARY or install it)")
|
|
|
|
port = _free_port()
|
|
process = subprocess.Popen(
|
|
[opa_binary, "run", "--server", "--addr", f"127.0.0.1:{port}", str(POLICIES_DIR)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
pdp = PDPClient(
|
|
base_url=f"http://127.0.0.1:{port}", policy_version=compute_policy_version(POLICIES_DIR)
|
|
)
|
|
try:
|
|
for _ in range(100):
|
|
try:
|
|
await pdp._http.get("/health")
|
|
break
|
|
except Exception: # noqa: BLE001
|
|
await asyncio.sleep(0.05)
|
|
else:
|
|
raise RuntimeError("opa server did not become healthy in time")
|
|
|
|
manager = EntitlementManager(pdp)
|
|
manager.load_registrations(
|
|
[
|
|
load_yaml_model(path, ClientRegistration)
|
|
for path in sorted(CLIENTS_DIR.glob("*.yaml"))
|
|
]
|
|
)
|
|
manager.load_profiles(load_yaml_models(PROFILES_DIR, ClientProfile))
|
|
await manager.sync_profiles_to_pdp()
|
|
yield manager
|
|
finally:
|
|
await pdp.aclose()
|
|
process.terminate()
|
|
process.wait(timeout=5)
|
|
|
|
|
|
@pytest.fixture
|
|
def offering_filter(tmp_path: Path) -> OfferingFilterService:
|
|
store = RegistryStore(tmp_path / "registry.db")
|
|
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
|
|
return OfferingFilterService(store)
|
|
|
|
|
|
@pytest.fixture
|
|
def sqlite_saver() -> Iterator[SqliteSaver]:
|
|
with (
|
|
tempfile.TemporaryDirectory() as tmp_dir,
|
|
SqliteSaver.from_conn_string(str(Path(tmp_dir) / "checkpoints.db")) as saver,
|
|
):
|
|
yield saver
|
|
|
|
|
|
class _PreferPaymentExecuteModel:
|
|
"""A second `NBADecisionModel` (demonstrating EP-11-T03's pluggability):
|
|
unlike `RuleBasedNBADecisionModel` (least-friction-first), this one
|
|
prefers `finance.payment.execute` when it survives EP-10's filter --
|
|
used here purely to reach the approval-required branch deterministically
|
|
(`RuleBasedNBADecisionModel` would pick the R1 `invoice.get` instead,
|
|
which never requires approval)."""
|
|
|
|
def decide(self, context: DecisionContext) -> NBADecision | None:
|
|
offerings_by_name = {o.name: o for o in context.filtered_offerings}
|
|
offering = offerings_by_name.get("finance.payment.execute") or next(
|
|
iter(context.filtered_offerings), None
|
|
)
|
|
if offering is None:
|
|
return None
|
|
return NBADecision(
|
|
decision_id="dec-test-fixed",
|
|
action=offering.name,
|
|
capability_version=offering.version,
|
|
subject_ref=context.profile.subject_ref,
|
|
channel=context.runtime_context.channel,
|
|
agent=DEFAULT_AGENT,
|
|
reason_codes=["ELIGIBLE"],
|
|
policy_context={"entitlementVersion": context.entitlement_version},
|
|
expires_at=context.resolved_at,
|
|
)
|
|
|
|
|
|
async def test_journey_runs_end_to_end_when_no_approval_is_needed(
|
|
entitlement_manager: EntitlementManager,
|
|
offering_filter: OfferingFilterService,
|
|
sqlite_saver: SqliteSaver,
|
|
) -> None:
|
|
graph = build_journey_graph(
|
|
profile_provider=RuleBasedProfileProvider(),
|
|
entitlement_manager=entitlement_manager,
|
|
offering_filter=offering_filter,
|
|
decision_model=RuleBasedNBADecisionModel(),
|
|
checkpointer=sqlite_saver,
|
|
)
|
|
config: RunnableConfig = {"configurable": {"thread_id": "journey-1"}}
|
|
|
|
result = graph.invoke(
|
|
{"client_id": SALES_READ_CLIENT_ID, "raw_subject_id": "subject:test-1", "channel": "app"},
|
|
config,
|
|
)
|
|
|
|
assert result["nba"] is not None
|
|
assert result["nba"].action.startswith("sales.")
|
|
assert result["approval_status"] == "not_required"
|
|
assert "__interrupt__" not in result
|
|
|
|
|
|
async def test_journey_pauses_at_approval_gate_for_a_high_risk_nba(
|
|
entitlement_manager: EntitlementManager,
|
|
offering_filter: OfferingFilterService,
|
|
sqlite_saver: SqliteSaver,
|
|
) -> None:
|
|
"""finance.payment.execute (R3) requires approval -- README.md §26. With
|
|
consent for restricted data (so it survives EP-10's filter) and a
|
|
Finance.Payments client, the journey graph must pause at approval_gate,
|
|
never silently proceeding as if approved."""
|
|
graph = build_journey_graph(
|
|
profile_provider=RuleBasedProfileProvider(),
|
|
entitlement_manager=entitlement_manager,
|
|
offering_filter=offering_filter,
|
|
decision_model=_PreferPaymentExecuteModel(),
|
|
checkpointer=sqlite_saver,
|
|
consent_context=ConsentContext(allow_restricted_data=True),
|
|
)
|
|
config: RunnableConfig = {"configurable": {"thread_id": "journey-2"}}
|
|
|
|
result = graph.invoke(
|
|
{
|
|
"client_id": FINANCE_PAYMENTS_CLIENT_ID,
|
|
"raw_subject_id": "subject:test-2",
|
|
"channel": "app",
|
|
},
|
|
config,
|
|
)
|
|
|
|
assert "__interrupt__" in result
|
|
assert "approval_status" not in result # not yet decided -- no implicit ALLOW
|
|
|
|
state = graph.get_state(config)
|
|
assert state.next == ("approval_gate",)
|
|
|
|
|
|
async def test_resuming_an_approval_gate_does_not_reprocess_earlier_nodes(
|
|
entitlement_manager: EntitlementManager,
|
|
offering_filter: OfferingFilterService,
|
|
sqlite_saver: SqliteSaver,
|
|
) -> None:
|
|
"""EP-11-T02's checkpoint-resume acceptance criterion: resolve_profile/
|
|
resolve_entitlement/filter_offerings/decide_nba must not re-run when the
|
|
graph resumes from a paused approval_gate."""
|
|
profile_calls = 0
|
|
real_provider = RuleBasedProfileProvider()
|
|
|
|
class CountingProfileProvider:
|
|
def resolve(self, subject_ref: str, *, as_of: object = None) -> object:
|
|
nonlocal profile_calls
|
|
profile_calls += 1
|
|
return real_provider.resolve(subject_ref)
|
|
|
|
graph = build_journey_graph(
|
|
profile_provider=CountingProfileProvider(), # type: ignore[arg-type]
|
|
entitlement_manager=entitlement_manager,
|
|
offering_filter=offering_filter,
|
|
decision_model=_PreferPaymentExecuteModel(),
|
|
checkpointer=sqlite_saver,
|
|
consent_context=ConsentContext(allow_restricted_data=True),
|
|
)
|
|
config: RunnableConfig = {"configurable": {"thread_id": "journey-3"}}
|
|
|
|
graph.invoke(
|
|
{
|
|
"client_id": FINANCE_PAYMENTS_CLIENT_ID,
|
|
"raw_subject_id": "subject:test-3",
|
|
"channel": "app",
|
|
},
|
|
config,
|
|
)
|
|
assert profile_calls == 1
|
|
|
|
# Simulate "the process restarted": a fresh CompiledStateGraph, same
|
|
# checkpointer/thread_id -- the paused state must already be there.
|
|
resumed_graph = build_journey_graph(
|
|
profile_provider=CountingProfileProvider(), # type: ignore[arg-type]
|
|
entitlement_manager=entitlement_manager,
|
|
offering_filter=offering_filter,
|
|
decision_model=_PreferPaymentExecuteModel(),
|
|
checkpointer=sqlite_saver,
|
|
consent_context=ConsentContext(allow_restricted_data=True),
|
|
)
|
|
result = resumed_graph.invoke(Command(resume="approved"), config)
|
|
|
|
assert profile_calls == 1 # unchanged -- resolve_profile did not re-run
|
|
assert result["approval_status"] == "approved"
|
|
|
|
|
|
async def test_resuming_with_a_non_approved_decision_denies(
|
|
entitlement_manager: EntitlementManager,
|
|
offering_filter: OfferingFilterService,
|
|
sqlite_saver: SqliteSaver,
|
|
) -> None:
|
|
graph = build_journey_graph(
|
|
profile_provider=RuleBasedProfileProvider(),
|
|
entitlement_manager=entitlement_manager,
|
|
offering_filter=offering_filter,
|
|
decision_model=_PreferPaymentExecuteModel(),
|
|
checkpointer=sqlite_saver,
|
|
consent_context=ConsentContext(allow_restricted_data=True),
|
|
)
|
|
config: RunnableConfig = {"configurable": {"thread_id": "journey-4"}}
|
|
|
|
graph.invoke(
|
|
{
|
|
"client_id": FINANCE_PAYMENTS_CLIENT_ID,
|
|
"raw_subject_id": "subject:test-4",
|
|
"channel": "app",
|
|
},
|
|
config,
|
|
)
|
|
result = graph.invoke(Command(resume="denied"), config)
|
|
|
|
assert result["approval_status"] == "denied"
|
|
|
|
|
|
async def test_denial_reentry_recalculates_nba_excluding_the_denied_action(
|
|
entitlement_manager: EntitlementManager,
|
|
offering_filter: OfferingFilterService,
|
|
sqlite_saver: SqliteSaver,
|
|
) -> None:
|
|
"""EP-11-T04: after a real Gateway DENY for the first NBA, re-invoking
|
|
the graph with `denied_action` set recomputes NBA over the remaining
|
|
offerings -- never by calling any backend directly (fallback.py has no
|
|
such code path at all)."""
|
|
graph = build_journey_graph(
|
|
profile_provider=RuleBasedProfileProvider(),
|
|
entitlement_manager=entitlement_manager,
|
|
offering_filter=offering_filter,
|
|
decision_model=RuleBasedNBADecisionModel(),
|
|
checkpointer=sqlite_saver,
|
|
)
|
|
config: RunnableConfig = {"configurable": {"thread_id": "journey-5"}}
|
|
|
|
first = graph.invoke(
|
|
{"client_id": SALES_READ_CLIENT_ID, "raw_subject_id": "subject:test-5", "channel": "app"},
|
|
config,
|
|
)
|
|
first_action = first["nba"].action
|
|
|
|
second = graph.invoke({"denied_action": first_action}, config)
|
|
|
|
assert second["denial_outcome"] in {"recalculated", "ended"}
|
|
if second["denial_outcome"] == "recalculated":
|
|
assert second["nba"] is not None
|
|
assert second["nba"].action != first_action
|
|
else:
|
|
assert second["nba"] is None
|