"""QONTO-WP-0003-T05: REST and MCP must emit schema-identical audit events. CapabilityService._emit_audit is the single audit call site for both transports (see service.py) -- these tests pin that down with an explicit assertion rather than relying on code inspection, so a future change that special-cases either transport's audit shape breaks a test, not just a review comment. """ from pathlib import Path from qonto_assistant.audit import AuditLogger from qonto_assistant.contracts import ActorClaims from qonto_assistant.errors import PolicyDeniedError from qonto_assistant.policy import PolicyEngine from qonto_assistant.qonto_client import FixtureQontoClient from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter from qonto_assistant.service import CapabilityService FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto" POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml" # Fields whose values are expected to vary per call (timing/identifiers) or # by design (protocol). Everything else must match exactly between REST and # MCP for the same logical call. NON_COMPARABLE_FIELDS = {"request_id", "timestamp", "latency_ms", "protocol"} SECRET_FIELD_NAMES = {"api_key", "authorization", "authorization_header", "openbao_token", "secret", "token"} def _service() -> tuple[CapabilityService, list[dict[str, object]]]: events: list[dict[str, object]] = [] policy = PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False) service = CapabilityService( client=FixtureQontoClient(fixture_dir=FIXTURE_DIR), policy=policy, audit_logger=AuditLogger(sink=events.append), rate_limiter=RateLimiter(limit=20, window_seconds=60), concurrency_limiter=ConcurrencyLimiter(limit=4), ) return service, events def _claims() -> ActorClaims: return ActorClaims(actor_id="parity-test", tenant_id="binky", lane="green") def test_allow_path_audit_schema_identical_across_protocols() -> None: service, events = _service() service.get_accounts(claims=_claims(), request_id="req-rest", protocol="rest") service.get_accounts(claims=_claims(), request_id="req-mcp", protocol="mcp") rest_event, mcp_event = events assert set(rest_event) == set(mcp_event) assert rest_event["protocol"] == "rest" assert mcp_event["protocol"] == "mcp" for field in set(rest_event) - NON_COMPARABLE_FIELDS: assert rest_event[field] == mcp_event[field], f"field {field!r} diverged: {rest_event[field]!r} != {mcp_event[field]!r}" assert rest_event["decision"] == "allow" assert rest_event["capability"] == "org_summary" def test_deny_path_audit_schema_identical_across_protocols() -> None: service, events = _service() for protocol, request_id in (("rest", "req-rest-deny"), ("mcp", "req-mcp-deny")): try: service.list_transactions( claims=_claims(), request_id=request_id, account_slug=None, page=1, page_size=101, window_days=31, status="completed", side=None, protocol=protocol, ) except PolicyDeniedError: pass rest_event, mcp_event = events assert set(rest_event) == set(mcp_event) for field in set(rest_event) - NON_COMPARABLE_FIELDS: assert rest_event[field] == mcp_event[field] assert rest_event["decision"] == "deny" assert rest_event["deny_reason"] == "arg_constraint" assert rest_event["qonto_http_status"] is None assert rest_event["result_count"] is None def test_audit_event_never_contains_secret_field_names() -> None: service, events = _service() service.get_accounts(claims=_claims(), request_id="req-secret-check", protocol="mcp") for event in events: assert SECRET_FIELD_NAMES.isdisjoint(event.keys())