# #region Test.RateLimiter [C:3] [TYPE Module] [SEMANTICS test,rate_limiter,security,throttle] # @BRIEF Verify RateLimiterModule contracts — ban/window logic, pruning, thread safety. # @RELATION BINDS_TO -> [RateLimiterModule] # @TEST_EDGE: unknown_ip_not_banned -> is_banned returns False for unseen IP # @TEST_EDGE: ban_triggers_at_limit -> MAX_ATTEMPTS+1 attempts triggers ban # @TEST_EDGE: exactly_at_limit_no_ban -> exactly MAX_ATTEMPTS does NOT ban (code uses >) # @TEST_EDGE: ban_expires -> ban auto-clears after BAN_DURATION seconds # @TEST_EDGE: old_attempts_pruned -> attempts outside window are discarded # @TEST_EDGE: success_clears_attempts -> record_success resets attempt counter # @TEST_EDGE: success_does_not_clear_ban -> record_success does NOT lift active ban # @TEST_EDGE: ban_clears_attempts -> after ban, attempt history is deleted from pathlib import Path import sys import threading sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from unittest.mock import patch import pytest from src.core.rate_limiter import RateLimiter # ── Constant overrides for fast, deterministic tests ── # MAX_ATTEMPTS=2 → ban triggers on 3rd attempt (production code uses `>` not `>=`) # ATTEMPT_WINDOW=3600 → 1-hour window (attempts stay valid within a single test) # BAN_DURATION=3600 → 1-hour ban (testable via mocked monotonic clock) @pytest.fixture(autouse=True) def _fast_constants(monkeypatch): """Monkeypatch module constants to small values for fast testing.""" monkeypatch.setattr("src.core.rate_limiter.MAX_ATTEMPTS", 2) monkeypatch.setattr("src.core.rate_limiter.ATTEMPT_WINDOW", 3600) monkeypatch.setattr("src.core.rate_limiter.BAN_DURATION", 3600) @pytest.fixture def limiter(): """Fresh RateLimiter instance per test — no shared state.""" return RateLimiter() class TestRateLimiter: """RateLimiter — in-memory sliding-window rate limiter keyed by IP.""" # #region test_is_banned_unknown_ip [C:2] [TYPE Function] # @BRIEF Unknown IP has no ban entry — is_banned returns False. def test_is_banned_unknown_ip(self, limiter): assert limiter.is_banned("192.168.1.1") is False # #endregion test_is_banned_unknown_ip # #region test_is_banned_banned_ip [C:2] [TYPE Function] # @BRIEF IP with 3 attempts (MAX_ATTEMPTS+1) is banned — is_banned returns True. def test_is_banned_banned_ip(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") # Must check inside patch — real monotonic would exceed ban expiry assert limiter.is_banned("10.0.0.1") is True # #endregion test_is_banned_banned_ip # #region test_ban_expires_after_duration [C:2] [TYPE Function] # @BRIEF Ban auto-clears once monotonic clock exceeds ban expiry. def test_ban_expires_after_duration(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") assert limiter.is_banned("10.0.0.1") is True # Advance clock past BAN_DURATION (3600s) + 1s margin mock_time.monotonic.return_value = 4601.0 assert limiter.is_banned("10.0.0.1") is False # #endregion test_ban_expires_after_duration # #region test_record_attempt_under_limit [C:2] [TYPE Function] # @BRIEF 2 attempts (exactly MAX_ATTEMPTS) does NOT trigger ban. def test_record_attempt_under_limit(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") assert limiter.is_banned("10.0.0.1") is False # #endregion test_record_attempt_under_limit # #region test_record_attempt_triggers_ban [C:2] [TYPE Function] # @BRIEF 3rd attempt (MAX_ATTEMPTS+1) triggers ban — code uses `>` not `>=`. def test_record_attempt_triggers_ban(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") assert limiter.is_banned("10.0.0.1") is False limiter.record_attempt("10.0.0.1") assert limiter.is_banned("10.0.0.1") is True # #endregion test_record_attempt_triggers_ban # #region test_old_attempts_pruned [C:2] [TYPE Function] # @BRIEF Attempts older than ATTEMPT_WINDOW are discarded; fresh start after gap. def test_old_attempts_pruned(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: # 2 attempts at t=1000 mock_time.monotonic.return_value = 1000.0 limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") # Advance past ATTEMPT_WINDOW (3600s) — old attempts expire mock_time.monotonic.return_value = 5000.0 limiter.record_attempt("10.0.0.1") # Only 1 attempt in window — not banned assert limiter.is_banned("10.0.0.1") is False # #endregion test_old_attempts_pruned # #region test_record_success_clears_attempts [C:2] [TYPE Function] # @BRIEF record_success resets attempt counter; subsequent attempts start fresh. def test_record_success_clears_attempts(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") limiter.record_success("10.0.0.1") # 2 more attempts after success — counter was reset, still under limit limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") assert limiter.is_banned("10.0.0.1") is False # #endregion test_record_success_clears_attempts # #region test_record_success_does_not_clear_ban [C:2] [TYPE Function] # @BRIEF record_success clears attempts but does NOT lift an active ban. def test_record_success_does_not_clear_ban(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") assert limiter.is_banned("10.0.0.1") is True # Success clears attempts but ban remains limiter.record_success("10.0.0.1") assert limiter.is_banned("10.0.0.1") is True # #endregion test_record_success_does_not_clear_ban # #region test_different_ips_independent [C:2] [TYPE Function] # @BRIEF Ban on one IP does not affect other IPs. def test_different_ips_independent(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 for _ in range(3): limiter.record_attempt("10.0.0.1") assert limiter.is_banned("10.0.0.1") is True assert limiter.is_banned("192.168.1.1") is False # #endregion test_different_ips_independent # #region test_ban_clears_attempts [C:2] [TYPE Function] # @BRIEF After ban, attempt history is deleted; post-expiry attempts start fresh. def test_ban_clears_attempts(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: # Trigger ban at t=1000 mock_time.monotonic.return_value = 1000.0 limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") assert limiter.is_banned("10.0.0.1") is True # Advance past ban expiry mock_time.monotonic.return_value = 4601.0 assert limiter.is_banned("10.0.0.1") is False # 2 fresh attempts — if old attempts weren't cleared, these would # stack on top of 3 old ones (5 > 2) and re-trigger ban immediately. limiter.record_attempt("10.0.0.1") limiter.record_attempt("10.0.0.1") assert limiter.is_banned("10.0.0.1") is False # #endregion test_ban_clears_attempts # #region test_thread_safety_basic [C:2] [TYPE Function] # @BRIEF Concurrent record_attempt calls from multiple threads do not raise. def test_thread_safety_basic(self, limiter): errors = [] def worker(ip, count): try: for _ in range(count): limiter.record_attempt(ip) limiter.is_banned(ip) except Exception as exc: errors.append(exc) threads = [ threading.Thread(target=worker, args=("10.0.0.1", 50)), threading.Thread(target=worker, args=("10.0.0.2", 50)), threading.Thread(target=worker, args=("10.0.0.3", 50)), ] for t in threads: t.start() for t in threads: t.join(timeout=5) assert errors == [] # #endregion test_thread_safety_basic class TestRateLimiterSingleton: """rate_limiter singleton is pre-created at module level.""" # #region test_singleton_exists [C:2] [TYPE Function] # @BRIEF Module-level singleton exists and exposes the expected interface. def test_singleton_exists(self): from src.core.rate_limiter import rate_limiter assert rate_limiter is not None assert hasattr(rate_limiter, "is_banned") assert hasattr(rate_limiter, "record_attempt") assert hasattr(rate_limiter, "record_success") # #endregion test_singleton_exists # #endregion Test.RateLimiter