mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M3 implementada e backlog atualizado
This commit is contained in:
309
RI/tests/unit/test_orchestrator_graph.py
Normal file
309
RI/tests/unit/test_orchestrator_graph.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user