Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user