from unittest.mock import patch from app.rate_limit import RateLimiter, resolve_client_ip def test_allows_up_to_limit_then_blocks(): limiter = RateLimiter(max_requests=3, window_seconds=60) assert limiter.allow("user-1") is True assert limiter.allow("user-1") is True assert limiter.allow("user-1") is True assert limiter.allow("user-1") is False def test_different_keys_tracked_independently(): limiter = RateLimiter(max_requests=1, window_seconds=60) assert limiter.allow("user-1") is True assert limiter.allow("user-2") is True assert limiter.allow("user-1") is False def test_hits_expire_after_window_elapses(): limiter = RateLimiter(max_requests=2, window_seconds=10) with patch("app.rate_limit.time.monotonic", return_value=100.0): assert limiter.allow("user-1") is True assert limiter.allow("user-1") is True assert limiter.allow("user-1") is False # at limit with patch("app.rate_limit.time.monotonic", return_value=111.0): # 11 seconds later, both prior hits (at t=100) are older than window_start (111 - 10 = 101) assert limiter.allow("user-1") is True def test_resolve_client_ip_prefers_cf_connecting_ip_over_socket_peer(): # The App CT sits behind a Cloudflare Tunnel on a separate machine — the # raw socket peer is always the tunnel, never the visitor, for every # internet-facing request. headers = {"cf-connecting-ip": "203.0.113.7"} assert resolve_client_ip(headers, "10.30.20.1") == "203.0.113.7" def test_resolve_client_ip_falls_back_to_socket_peer_without_header(): # Direct LAN/local access (no Cloudflare in front) has no such header. assert resolve_client_ip({}, "10.30.20.1") == "10.30.20.1" def test_resolve_client_ip_falls_back_to_unknown_with_no_peer_or_header(): assert resolve_client_ip({}, None) == "unknown" # --- key eviction ----------------------------------------------------------- class TestKeyEviction: """Keys must not accumulate forever. The open door provisions a real account per visitor, so every visitor contributes a distinct user-id key to each limiter. Without eviction a long-running process grows without bound. """ def test_stale_keys_are_swept(self, monkeypatch): limiter = RateLimiter(max_requests=5, window_seconds=60) clock = {"t": 1000.0} monkeypatch.setattr("app.rate_limit.time.monotonic", lambda: clock["t"]) # A burst of one-shot visitors, each seen exactly once. for i in range(RateLimiter.SWEEP_EVERY): limiter.allow(f"visitor-{i}") assert limiter.tracked_keys == RateLimiter.SWEEP_EVERY # Long after their window has closed, one more call triggers a sweep. clock["t"] += 10_000 for i in range(RateLimiter.SWEEP_EVERY): limiter.allow(f"later-{i}") # The original cohort is gone; only the recent one is retained. assert limiter.tracked_keys <= RateLimiter.SWEEP_EVERY + 1 def test_sweeping_never_forgets_an_active_key(self, monkeypatch): """A sweep must not hand someone a fresh budget mid-window.""" limiter = RateLimiter(max_requests=2, window_seconds=60) clock = {"t": 500.0} monkeypatch.setattr("app.rate_limit.time.monotonic", lambda: clock["t"]) assert limiter.allow("steady") is True assert limiter.allow("steady") is True assert limiter.allow("steady") is False # Force many sweeps while "steady" stays inside its window. for i in range(RateLimiter.SWEEP_EVERY * 2): clock["t"] += 0.001 limiter.allow(f"noise-{i}") # Still blocked — the sweep must not have dropped a live key. assert limiter.allow("steady") is False def test_a_key_recovers_normally_after_its_window(self, monkeypatch): limiter = RateLimiter(max_requests=1, window_seconds=10) clock = {"t": 0.0} monkeypatch.setattr("app.rate_limit.time.monotonic", lambda: clock["t"]) assert limiter.allow("seeker") is True assert limiter.allow("seeker") is False clock["t"] += 11 assert limiter.allow("seeker") is True