"""The auth endpoints are rate-limited per client IP.""" import pytest from fastapi import Depends, FastAPI from fastapi.testclient import TestClient from app import rate_limit from app.config import settings from app.main import app @pytest.fixture(autouse=True) def _clean_limiter(): # This module tests the limiter itself: force it on (the checkpoint # runner disables it suite-wide via RATE_LIMIT_ENABLED) and start each # test from an empty window. prev = settings.RATE_LIMIT_ENABLED settings.RATE_LIMIT_ENABLED = True rate_limit.reset_all() yield rate_limit.reset_all() settings.RATE_LIMIT_ENABLED = prev def test_login_is_limited_after_the_threshold(): client = TestClient(app) codes = [] for i in range(15): r = client.post("/api/auth/login", data={"username": f"nobody{i}", "password": "x"}) codes.append(r.status_code) # First 10 get through to the (failing) auth check; the rest are 429. assert codes[:10] == [401] * 10 assert 429 in codes[10:] assert codes[-1] == 429 def test_register_shares_the_same_bucket(): client = TestClient(app) # Burn the budget on login... for i in range(10): client.post("/api/auth/login", data={"username": f"x{i}", "password": "y"}) # ...register from the same IP is now blocked too. r = client.post("/api/auth/register", json={ "username": "rl_user", "email": "rl@e.com", "password": "secret123", "date_of_birth": "1990-01-01", }) assert r.status_code == 429 assert "Retry-After" in r.headers def test_limiter_window_is_per_key(): limiter = rate_limit.SlidingWindowLimiter(max_hits=2, window_seconds=60) limiter.check("a") limiter.check("a") with pytest.raises(Exception): limiter.check("a") limiter.check("b") # different key, unaffected def _make_named_limit_app(): """A tiny standalone app exercising two independent named limits, so these tests don't depend on any sibling endpoint existing yet.""" limit_a = rate_limit.rate_limit("test_named_a", max_hits=2, window_seconds=60) limit_b = rate_limit.rate_limit("test_named_b", max_hits=2, window_seconds=60) app = FastAPI() @app.post("/a", dependencies=[Depends(limit_a)]) def _a(): return {"ok": True} @app.post("/b", dependencies=[Depends(limit_b)]) def _b(): return {"ok": True} return TestClient(app) def test_named_limits_do_not_share_counters(): client = _make_named_limit_app() # Burn out limit "a" entirely. assert client.post("/a").status_code == 200 assert client.post("/a").status_code == 200 r = client.post("/a") assert r.status_code == 429 assert "Retry-After" in r.headers # Limit "b" is untouched -- same process, same client IP, different name. assert client.post("/b").status_code == 200 assert client.post("/b").status_code == 200 assert client.post("/b").status_code == 429 def test_named_limit_returns_429_with_retry_after_once_exhausted(): client = _make_named_limit_app() client.post("/a") client.post("/a") r = client.post("/a") assert r.status_code == 429 retry_after = r.headers["Retry-After"] assert int(retry_after) >= 1 def test_named_limit_is_a_noop_when_rate_limiting_disabled(): prev = settings.RATE_LIMIT_ENABLED settings.RATE_LIMIT_ENABLED = False try: client = _make_named_limit_app() codes = [client.post("/a").status_code for _ in range(10)] assert codes == [200] * 10 finally: settings.RATE_LIMIT_ENABLED = prev def test_reset_all_clears_named_limiters_too(): client = _make_named_limit_app() client.post("/a") client.post("/a") assert client.post("/a").status_code == 429 rate_limit.reset_all() assert client.post("/a").status_code == 200