from pathlib import Path import pytest 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.security_watch import ( ActorLockedOutError, DenyEscalationTracker, ) from qonto_assistant.service import CapabilityService POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml" FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto" class _FakeClock: def __init__(self) -> None: self.now = 0.0 def __call__(self) -> float: return self.now def advance(self, seconds: float) -> None: self.now += seconds def _claims(actor_id: str = "prober") -> ActorClaims: return ActorClaims(actor_id=actor_id, tenant_id="binky", lane="green") def _service(tracker: DenyEscalationTracker, events: list[dict[str, object]]) -> CapabilityService: return CapabilityService( client=FixtureQontoClient(fixture_dir=FIXTURE_DIR), policy=PolicyEngine.from_file( POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False, ), audit_logger=AuditLogger(sink=events.append), rate_limiter=RateLimiter(limit=100, window_seconds=60), concurrency_limiter=ConcurrencyLimiter(limit=4), deny_escalation_tracker=tracker, ) # --- Unit tests for the tracker itself ------------------------------------------------- def test_tracker_allows_denies_below_threshold() -> None: clock = _FakeClock() tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock) tracker.record_deny("binky:prober", "arg_constraint") tracker.record_deny("binky:prober", "arg_constraint") tracker.check("binky:prober") # should not raise def test_tracker_locks_out_after_threshold_within_window() -> None: clock = _FakeClock() tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock) for _ in range(3): tracker.record_deny("binky:prober", "arg_constraint") with pytest.raises(ActorLockedOutError): tracker.check("binky:prober") def test_tracker_ignores_non_escalating_deny_reasons() -> None: clock = _FakeClock() tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock) for _ in range(10): tracker.record_deny("binky:prober", "tenant_scope") tracker.record_deny("binky:prober", "authz_denied") tracker.check("binky:prober") # should not raise -- neither reason escalates def test_tracker_denies_outside_window_do_not_accumulate() -> None: clock = _FakeClock() tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock) tracker.record_deny("binky:prober", "arg_constraint") clock.advance(61) tracker.record_deny("binky:prober", "arg_constraint") clock.advance(61) tracker.record_deny("binky:prober", "arg_constraint") tracker.check("binky:prober") # should not raise -- each deny fell outside the prior window def test_tracker_lockout_expires_after_lockout_window() -> None: clock = _FakeClock() tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock) for _ in range(3): tracker.record_deny("binky:prober", "arg_constraint") with pytest.raises(ActorLockedOutError): tracker.check("binky:prober") clock.advance(301) tracker.check("binky:prober") # should not raise -- lockout window elapsed # Escalation state was cleared, not just the lockout: it takes a full # fresh threshold of denies to lock out again. tracker.record_deny("binky:prober", "arg_constraint") tracker.record_deny("binky:prober", "arg_constraint") tracker.check("binky:prober") # should not raise -- only 2 denies since the reset def test_tracker_is_scoped_per_actor() -> None: clock = _FakeClock() tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock) for _ in range(3): tracker.record_deny("binky:prober", "arg_constraint") tracker.check("binky:other-actor") # should not raise -- different actor, untouched # --- Integration through CapabilityService --------------------------------------------- def test_service_locks_out_actor_after_repeated_arg_constraint_denies() -> None: clock = _FakeClock() tracker = DenyEscalationTracker(threshold=2, window_seconds=60, lockout_seconds=300, clock=clock) events: list[dict[str, object]] = [] service = _service(tracker, events) def _oversized_page_call() -> None: service.list_transactions( claims=_claims(), request_id="req-probe", account_slug=None, page=1, page_size=10_000, # far beyond max_per_page -> arg_constraint window_days=31, status="completed", side=None, protocol="rest", ) # First two denies cross the threshold=2 escalation bar. for _ in range(2): with pytest.raises(PolicyDeniedError): _oversized_page_call() # A third call -- even to an unrelated, otherwise-allowed capability -- # is now rejected before the policy kernel is consulted at all. with pytest.raises(ActorLockedOutError): service.get_accounts(claims=_claims(), request_id="req-locked", protocol="rest") lockout_events = [event for event in events if event.get("deny_reason") == "actor_locked_out"] assert len(lockout_events) == 1 def test_service_does_not_lock_out_for_ordinary_denies() -> None: clock = _FakeClock() tracker = DenyEscalationTracker(threshold=2, window_seconds=60, lockout_seconds=300, clock=clock) events: list[dict[str, object]] = [] service = _service(tracker, events) red_lane_claims = ActorClaims(actor_id="prober", tenant_id="binky", lane="red") for _ in range(5): with pytest.raises(PolicyDeniedError): service.get_accounts(claims=red_lane_claims, request_id="req-lane", protocol="rest") # authz_denied (wrong lane) never escalates -- the actor should still be # able to make a legitimate, correctly-scoped call afterwards. payload = service.get_accounts(claims=_claims(), request_id="req-ok", protocol="rest") assert "organization" in payload