""" Rate limiting tests — D-11, D-12, Assumption A1. Plan 06-05: all 8 xfail stubs promoted to real assertions. D-11: trusted-proxy CIDR logic in get_client_ip (deps/utils.py). D-12: per-account rate limiter keyed by user_id (100 req/min). A1: slowapi key_func ordering assumption — request.state.current_user must be set as the FIRST line of the handler body before slowapi reads the key. """ from __future__ import annotations import uuid import pytest import pytest_asyncio from fastapi import FastAPI from fastapi.testclient import TestClient from slowapi.errors import RateLimitExceeded from slowapi import _rate_limit_exceeded_handler from slowapi.middleware import SlowAPIMiddleware from starlette.requests import Request from starlette.responses import JSONResponse # ── Helpers: build a minimal Starlette Request for unit tests ───────────────── def _make_request( client_host: str | None, xff: str | None = None, ) -> Request: """Build a minimal starlette.requests.Request with the given peer IP and XFF header.""" headers_list: list[tuple[bytes, bytes]] = [] if xff is not None: headers_list.append((b"x-forwarded-for", xff.encode())) scope = { "type": "http", "method": "GET", "path": "/", "query_string": b"", "headers": headers_list, "client": (client_host, 12345) if client_host else None, } return Request(scope) # ── D-11: trusted-proxy CIDR logic in get_client_ip ────────────────────────── def test_get_client_ip_untrusted_returns_direct_peer(): """When request.client.host is 8.8.8.8 (not in trusted CIDRs), ignore X-Forwarded-For and return the direct peer IP '8.8.8.8'.""" from deps.utils import get_client_ip req = _make_request("8.8.8.8", xff="1.2.3.4") assert get_client_ip(req) == "8.8.8.8" def test_get_client_ip_trusted_proxy_reads_xff_leftmost(): """When request.client.host is 127.0.0.1 (trusted), return the leftmost address from X-Forwarded-For: '1.2.3.4, 5.6.7.8' → '1.2.3.4'.""" from deps.utils import get_client_ip req = _make_request("127.0.0.1", xff="1.2.3.4, 5.6.7.8") assert get_client_ip(req) == "1.2.3.4" def test_get_client_ip_trusted_proxy_no_xff_falls_back(): """When the direct peer is trusted (172.16.5.5) but no X-Forwarded-For header is present, return the direct peer IP as fallback.""" from deps.utils import get_client_ip req = _make_request("172.16.5.5", xff=None) assert get_client_ip(req) == "172.16.5.5" def test_get_client_ip_invalid_peer_returns_none_or_string(): """When request.client is None, get_client_ip returns None without raising.""" from deps.utils import get_client_ip req = _make_request(None, xff=None) result = get_client_ip(req) assert result is None # ── D-12: per-account rate limiter keyed by user_id ────────────────────────── def test_account_limiter_key_uses_user_id(): """_account_key(request) where request.state.current_user has id=UUID(...) returns str(user.id), not request.client.host.""" from services.rate_limiting import _account_key req = _make_request("8.8.8.8") class _FakeUser: id = uuid.uuid4() req.state.current_user = _FakeUser() result = _account_key(req) assert result == str(_FakeUser.id) assert result != "8.8.8.8" def test_account_limiter_key_falls_back_to_ip_when_no_user(): """When request.state.current_user is missing, the key function returns the direct peer IP — must not crash (Pitfall 3 guard).""" from services.rate_limiting import _account_key req = _make_request("9.9.9.9") # Do NOT set request.state.current_user result = _account_key(req) assert result == "9.9.9.9" # ── A1: key_func ordering assumption — unit verification ───────────────────── def test_account_limiter_key_ordering_assumption(): """A1 verification: construct a FastAPI test app with one endpoint decorated @account_limiter.limit('100/minute') that sets request.state.current_user as its first line; call it 101 times with the same user; assert the 101st response is 429. This validates that slowapi reads the key_func AFTER the first line of the handler has already set request.state.current_user (the ordering assumption that makes per-account limiting work). Key correctness is verified separately via the returned body — the endpoint echoes back the key so we can confirm it is the user.id, not the peer IP. """ from services.rate_limiting import _account_key _user_id = uuid.uuid4() # Build an isolated limiter so this test never pollutes the shared instance from slowapi import Limiter _isolated_limiter = Limiter(key_func=_account_key) test_app = FastAPI() test_app.state.limiter = _isolated_limiter test_app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) test_app.add_middleware(SlowAPIMiddleware) class _FakeUser: id = _user_id @test_app.get("/limited") @_isolated_limiter.limit("100/minute") async def _limited_endpoint(request: Request): # FIRST line: set current_user so the key_func can read it request.state.current_user = _FakeUser() # Echo the key back so the test can assert it is the user.id key = _account_key(request) return {"ok": True, "key": key} with TestClient(test_app, raise_server_exceptions=False) as client: responses = [client.get("/limited") for _ in range(101)] status_codes = [r.status_code for r in responses] assert status_codes[-1] == 429, ( f"Expected 429 on 101st request, got {status_codes[-1]}" ) assert all(c == 200 for c in status_codes[:100]), ( "First 100 requests should all be 200" ) # Verify the key returned in the body is the user.id, not the IP first_body = responses[0].json() assert first_body.get("key") == str(_user_id), ( f"Expected key '{_user_id}', got '{first_body.get('key')}'" ) # ── D-12: full integration — 429 after 100 requests/minute ─────────────────── @pytest.mark.asyncio async def test_authenticated_endpoint_429_after_100_per_minute( async_client, auth_user ): """Full integration: GET /api/documents/ called 101 times with the same auth_user returns 429 on the 101st request.""" headers = auth_user["headers"] responses = [] for _ in range(101): r = await async_client.get("/api/documents", headers=headers) responses.append(r) status_codes = [r.status_code for r in responses] assert status_codes[-1] == 429, ( f"Expected 429 on 101st request, got {status_codes[-1]}" ) assert all(c == 200 for c in status_codes[:100]), ( f"First 100 should be 200, got: {[c for c in status_codes[:100] if c != 200]}" )