"""ip_throttle_service: the escalation engine backing resend-verification, resend-password-reset, and failed-login throttling. Every stage is walked by monkeypatching `_now()` forward - no real waiting, no manual brute-forcing, per the explicit "test it smarter" requirement this feature was built under.""" from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from app.db.base import ensure_aware_utc from app.models.enums import ThrottleAction from app.repositories.ip_throttle_repository import IpThrottleRepository from app.services import ip_throttle_service from app.services.ip_throttle_service import ( LOGIN_BACKOFF_SECONDS, RESEND_BACKOFF_SECONDS, TIMEOUT_LADDER_SECONDS, peek_throttle, record_attempt, reset_on_success, ) def _unique_ip() -> str: return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}" class _Clock: """A monkeypatchable fake clock - advances only when told to, so tests can jump straight past a 5-hour timeout without real time passing.""" def __init__(self) -> None: self.now = datetime(2026, 1, 1, tzinfo=UTC) def __call__(self) -> datetime: return self.now def advance(self, seconds: float) -> None: self.now += timedelta(seconds=seconds) @pytest.fixture() def clock(monkeypatch): fake_clock = _Clock() monkeypatch.setattr(ip_throttle_service, "_now", fake_clock) return fake_clock async def _exhaust_stages_into_timeout(db_session, clock, ip: str, action: ThrottleAction) -> None: """Drives one full stage array to exhaustion (advancing the clock past each required wait, exactly as a real repeated-offender caller would), landing the state in a fresh timeout. Always starts from a peek (so a prior *completed* timeout cycle is lazily reset first, mirroring how every real call site checks peek_throttle before record_attempt).""" for _ in RESEND_BACKOFF_SECONDS: peeked = await peek_throttle(db_session, ip, action) assert peeked.allowed is True await record_attempt(db_session, ip, action, RESEND_BACKOFF_SECONDS) state = await IpThrottleRepository(db_session).get_state(ip, action) if state.next_allowed_at is not None: next_allowed_at = ensure_aware_utc(state.next_allowed_at) clock.advance((next_allowed_at - clock.now).total_seconds()) # One more attempt now that every stage is consumed - this is the one # that exhausts the array and enters a timeout. await record_attempt(db_session, ip, action, RESEND_BACKOFF_SECONDS) async def test_first_attempt_is_always_allowed(db_session, clock): ip = _unique_ip() result = await peek_throttle(db_session, ip, ThrottleAction.RESEND_VERIFICATION) assert result.allowed is True assert result.banned is False async def test_resend_backoff_stages_match_spec_exactly(db_session, clock): ip = _unique_ip() action = ThrottleAction.RESEND_VERIFICATION for expected_wait in RESEND_BACKOFF_SECONDS: result = await peek_throttle(db_session, ip, action) assert result.allowed is True await record_attempt(db_session, ip, action, RESEND_BACKOFF_SECONDS) blocked = await peek_throttle(db_session, ip, action) assert blocked.allowed is False assert blocked.retry_after_seconds == expected_wait clock.advance(expected_wait) async def test_resend_exhausting_stages_enters_first_timeout(db_session, clock): ip = _unique_ip() action = ThrottleAction.RESEND_RESET await _exhaust_stages_into_timeout(db_session, clock, ip, action) blocked = await peek_throttle(db_session, ip, action) assert blocked.allowed is False assert blocked.retry_after_seconds == TIMEOUT_LADDER_SECONDS[0] async def test_timeout_expires_resets_attempts_but_keeps_offense_memory(db_session, clock): ip = _unique_ip() action = ThrottleAction.RESEND_RESET await _exhaust_stages_into_timeout(db_session, clock, ip, action) repo = IpThrottleRepository(db_session) state = await repo.get_state(ip, action) assert state.offense_count == 1 clock.advance(TIMEOUT_LADDER_SECONDS[0]) result = await peek_throttle(db_session, ip, action) assert result.allowed is True state = await repo.get_state(ip, action) assert state.attempt_count == 0 assert state.offense_count == 1 # memory kept, exactly as specified async def test_repeat_offense_uses_next_longer_timeout(db_session, clock): ip = _unique_ip() action = ThrottleAction.RESEND_RESET await _exhaust_stages_into_timeout(db_session, clock, ip, action) # offense #1 -> 30min clock.advance(TIMEOUT_LADDER_SECONDS[0]) await _exhaust_stages_into_timeout(db_session, clock, ip, action) # offense #2 -> 1h blocked = await peek_throttle(db_session, ip, action) assert blocked.retry_after_seconds == TIMEOUT_LADDER_SECONDS[1] async def test_escalation_past_the_ladder_results_in_permanent_ban(db_session, clock): ip = _unique_ip() action = ThrottleAction.RESEND_RESET for i, timeout_seconds in enumerate(TIMEOUT_LADDER_SECONDS): await _exhaust_stages_into_timeout(db_session, clock, ip, action) clock.advance(timeout_seconds) result = await peek_throttle(db_session, ip, action) assert result.banned is False, f"should not be banned yet after offense {i + 1}" # One more full cycle exhausts past the ladder entirely -> permanent ban. await _exhaust_stages_into_timeout(db_session, clock, ip, action) result = await peek_throttle(db_session, ip, action) assert result.banned is True assert result.allowed is False async def test_login_five_instant_attempts_then_escalating_delays(db_session, clock): ip = _unique_ip() action = ThrottleAction.FAILED_LOGIN for _ in range(5): result = await peek_throttle(db_session, ip, action) assert result.allowed is True await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS) # All 5 were genuinely free - no wait was ever imposed before any of them. blocked = await peek_throttle(db_session, ip, action) assert blocked.allowed is False assert blocked.retry_after_seconds == 5 # first real delay stage, gating attempt 6 async def test_login_all_delay_stages_match_spec_in_order(db_session, clock): ip = _unique_ip() action = ThrottleAction.FAILED_LOGIN for _ in range(5): await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS) expected_delays = [5, 15, 30, 60, 120, 300, 900] for expected_wait in expected_delays: blocked = await peek_throttle(db_session, ip, action) assert blocked.retry_after_seconds == expected_wait clock.advance(expected_wait) await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS) # That was the 12th recorded attempt (5 free + 7 delayed) - stages are # now exhausted, so the IP itself enters its first timeout. blocked = await peek_throttle(db_session, ip, action) assert blocked.retry_after_seconds == TIMEOUT_LADDER_SECONDS[0] async def test_ban_blocks_every_action_type_for_that_ip(db_session, clock): ip = _unique_ip() action = ThrottleAction.RESEND_RESET for timeout_seconds in TIMEOUT_LADDER_SECONDS: await _exhaust_stages_into_timeout(db_session, clock, ip, action) clock.advance(timeout_seconds) await _exhaust_stages_into_timeout(db_session, clock, ip, action) # permanent ban # A totally different action from the same IP is also blocked - bans are # global per-IP, not scoped to the action that triggered them. login_result = await peek_throttle(db_session, ip, ThrottleAction.FAILED_LOGIN) assert login_result.banned is True async def test_reset_on_success_clears_stage_but_not_offense_count(db_session, clock): ip = _unique_ip() action = ThrottleAction.FAILED_LOGIN for _ in range(6): await record_attempt(db_session, ip, action, LOGIN_BACKOFF_SECONDS) repo = IpThrottleRepository(db_session) state = await repo.get_state(ip, action) assert state.attempt_count == 6 await reset_on_success(db_session, ip, action) state = await repo.get_state(ip, action) assert state.attempt_count == 0 assert state.next_allowed_at is None result = await peek_throttle(db_session, ip, action) assert result.allowed is True async def test_admin_unban_gives_a_clean_slate(db_session, clock): ip = _unique_ip() action = ThrottleAction.RESEND_RESET for timeout_seconds in TIMEOUT_LADDER_SECONDS: await _exhaust_stages_into_timeout(db_session, clock, ip, action) clock.advance(timeout_seconds) await _exhaust_stages_into_timeout(db_session, clock, ip, action) # permanent ban result = await peek_throttle(db_session, ip, action) assert result.banned is True repo = IpThrottleRepository(db_session) cleared = await repo.clear_ban_and_state(ip) assert cleared is True result = await peek_throttle(db_session, ip, action) assert result.allowed is True assert result.banned is False state = await repo.get_state(ip, action) assert state.offense_count == 0 # a real pardon, not just lifting the ban