qonto-assistant/tests/test_policy.py
tegwick d4656be310 QONTO-WP-0003-T01: MCP adapter skeleton on shared capability core
Add a streamable-HTTP MCP adapter (mcp_server.py, official FastMCP SDK)
mounted at /mcp in the existing FastAPI app, with a combined lifespan so
the MCP session manager starts/stops with the service. Ships one smoke
tool (qonto_ping, no bank call) — real capability tools land in T02.

Prove REST and MCP hit the identical policy path: PolicyEngine.decide()
never branches on request.protocol, verified by a parametrized test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:41:24 +02:00

120 lines
3.8 KiB
Python

from pathlib import Path
import pytest
from qonto_assistant.contracts import ActorClaims, CapabilityRequest
from qonto_assistant.policy import PolicyEngine
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
def _policy(*, enforce_scope: bool = False) -> PolicyEngine:
return PolicyEngine.from_file(
POLICY_FILE,
required_scope="finance.qonto.read",
enforce_scope=enforce_scope,
)
def _request(capability_id: str, **request_args: object) -> CapabilityRequest:
claims = ActorClaims(
actor_id="agent-1",
tenant_id="binky",
lane="green",
scopes=frozenset({"finance.qonto.read"}),
)
return CapabilityRequest(
capability_id=capability_id,
tenant_id="binky",
actor_claims=claims,
resource_scope="test",
request_args=dict(request_args),
protocol="rest",
)
def test_policy_allows_known_read_capability() -> None:
decision = _policy().decide(_request("org_summary"))
assert decision.allowed is True
assert decision.reason == "allow"
def test_policy_denies_cross_tenant_requests() -> None:
claims = ActorClaims(actor_id="agent-1", tenant_id="other", lane="green")
request = CapabilityRequest(
capability_id="org_summary",
tenant_id="binky",
actor_claims=claims,
resource_scope="accounts",
request_args={},
protocol="rest",
)
decision = _policy().decide(request)
assert decision.allowed is False
assert decision.reason == "tenant_scope"
def test_policy_denies_excessive_page_size() -> None:
decision = _policy().decide(_request("list_transactions", page=1, page_size=101, window_days=31))
assert decision.allowed is False
assert decision.reason == "arg_constraint"
def test_policy_denies_volume_cost_shaped_requests() -> None:
decision = _policy().decide(
_request("list_transactions", page=1, page_size=50, window_days=31, operation_type="card_operation")
)
assert decision.allowed is False
assert decision.reason == "volume_cost"
def test_policy_denies_credential_exfiltration_flags() -> None:
decision = _policy().decide(_request("list_transactions", page=1, page_size=50, window_days=31, include_full_iban=True))
assert decision.allowed is False
assert decision.reason == "credential_exfil"
def test_policy_enforces_scope_when_enabled() -> None:
claims = ActorClaims(actor_id="agent-1", tenant_id="binky", lane="green", scopes=frozenset())
request = CapabilityRequest(
capability_id="org_summary",
tenant_id="binky",
actor_claims=claims,
resource_scope="accounts",
request_args={},
protocol="rest",
)
decision = _policy(enforce_scope=True).decide(request)
assert decision.allowed is False
assert decision.reason == "authz_denied"
@pytest.mark.parametrize("protocol", ["rest", "mcp"])
def test_policy_decision_identical_across_protocols(protocol: str) -> None:
"""QONTO-WP-0003-T01: REST and MCP must hit the identical policy path.
The decision for a known-deny case (a spend-shaped capability id) must
not depend on which transport originated the request.
"""
claims = ActorClaims(
actor_id="agent-1",
tenant_id="binky",
lane="green",
scopes=frozenset({"finance.qonto.read"}),
)
request = CapabilityRequest(
capability_id="list_transactions",
tenant_id="binky",
actor_claims=claims,
resource_scope="test",
request_args={
"page": 1,
"page_size": 50,
"window_days": 31,
"requested_action": "transfer_funds",
},
protocol=protocol,
)
decision = _policy().decide(request)
assert decision.allowed is False
assert decision.reason == "spend"