"""B-56: RateLimiter._buckets is keyed by strings the caller chooses — any username, and (before B-54) any IP — and used to only ever grow. decay_seconds aged a bucket's *counter* but never removed the entry, so hammering login with random usernames was an unbounded memory leak. These tests pin both halves of the bound: spent entries are swept, and the dict has a hard cap. The throttling behaviour itself is exercised end-to-end in test_auth.py; what matters here is that pruning never hands an attacker a free pass. """ import time from app.auth.rate_limit import RateLimiter, RollingQuota def _limiter(**kwargs) -> RateLimiter: # sweep_interval_seconds=0 makes every record_failure sweep, so the tests are # deterministic instead of depending on wall-clock timing. kwargs.setdefault("sweep_interval_seconds", 0.0) return RateLimiter(**kwargs) def test_spent_buckets_are_swept_on_the_next_failure(): limiter = _limiter(decay_seconds=0.05) for i in range(50): limiter.record_failure(f"user:{i}") assert len(limiter._buckets) == 50 time.sleep(0.06) # every bucket is now past decay_seconds and unlocked limiter.record_failure("user:fresh") assert list(limiter._buckets) == ["user:fresh"] def test_a_bucket_still_locking_someone_out_is_never_swept(): """The whole point of the entry: evicting it would reset the backoff and let the attacker start over from a free attempt.""" limiter = _limiter(threshold=1, base_delay=300.0, decay_seconds=0.05) limiter.record_failure("user:victim") assert limiter.retry_after("user:victim") > 0 time.sleep(0.06) # past decay_seconds, but the lockout is still running limiter.record_failure("user:someone-else") assert "user:victim" in limiter._buckets assert limiter.retry_after("user:victim") > 0 def test_the_dict_is_capped_even_within_one_sweep_interval(): """The cap is the backstop for a burst faster than the sweep interval, where nothing has had time to expire yet.""" limiter = _limiter(max_buckets=10, sweep_interval_seconds=3600.0, decay_seconds=3600.0) for i in range(200): limiter.record_failure(f"ip:{i}") assert len(limiter._buckets) <= 11 # the cap, plus the entry recorded after the last prune def test_the_cap_evicts_the_entries_closest_to_expiry_first(): """What gets dropped under pressure must buy an attacker the least. The deepest lockout — the one built up over the most failures, and so the one actually holding an attack back — has to be the last thing evicted, not collateral of a flood of one-failure keys.""" limiter = _limiter(max_buckets=3, threshold=1, base_delay=1.0, max_delay=600.0) for _ in range(10): limiter.record_failure("ip:persistent") # backoff doubles: locked for ~512s for i in range(20): limiter.record_failure(f"ip:filler{i}") # one failure each: locked for ~1s assert "ip:persistent" in limiter._buckets assert limiter.retry_after("ip:persistent") > 100 def test_retry_after_drops_a_spent_bucket_it_looks_at(): limiter = _limiter(decay_seconds=0.05) limiter.record_failure("user:probe") time.sleep(0.06) assert limiter.retry_after("user:probe") == 0.0 assert limiter._buckets == {} def test_pruning_does_not_reset_a_live_failure_count(): """A bucket below the lockout threshold still carries state worth keeping: the next failure must count as the second, not the first.""" limiter = _limiter(threshold=2, base_delay=300.0, decay_seconds=3600.0) limiter.record_failure("user:a") limiter.record_failure("user:b") # triggers a sweep limiter.record_failure("user:a") assert limiter.retry_after("user:a") > 0 # --- B-58: registration is a quota, not failure backoff ------------------------- def test_quota_allows_up_to_the_limit_then_asks_for_a_wait(): quota = RollingQuota(limit=3, window_seconds=60.0) for _ in range(3): assert quota.retry_after("ip:1") == 0.0 quota.record("ip:1") wait = quota.retry_after("ip:1") assert 0 < wait <= 60.0 def test_quota_is_per_key(): quota = RollingQuota(limit=1, window_seconds=60.0) quota.record("ip:1") assert quota.retry_after("ip:1") > 0 assert quota.retry_after("ip:2") == 0.0 def test_quota_frees_a_slot_once_the_oldest_event_leaves_the_window(): """The point of a rolling window over failure backoff: the caller waits exactly until there's room again, and waiting doesn't make the next wait longer.""" quota = RollingQuota(limit=2, window_seconds=0.05) quota.record("ip:1") quota.record("ip:1") assert quota.retry_after("ip:1") > 0 time.sleep(0.06) assert quota.retry_after("ip:1") == 0.0 def test_quota_prunes_spent_keys_and_caps_its_dict(): """Same bound as the failure limiter (B-56): caller-chosen keys, so both a sweep and a hard cap.""" quota = RollingQuota(limit=1, window_seconds=0.05, sweep_interval_seconds=0.0) for i in range(50): quota.record(f"ip:{i}") time.sleep(0.06) quota.record("ip:fresh") assert list(quota._events) == ["ip:fresh"] capped = RollingQuota(limit=1, window_seconds=3600.0, max_keys=10, sweep_interval_seconds=0.0) for i in range(200): capped.record(f"ip:{i}") assert len(capped._events) <= 11