""" Test suite for rate limiting functionality. Tests the rate limiting feature on various endpoints to ensure protection against brute force attacks and DoS attempts. Rate Limit Tiers: - Standard (60/minute): Task CRUD, comments - Sensitive (20/minute): Attachments, report exports - Heavy (5/minute): Report generation, bulk operations - Login (5/minute): Authentication """ import pytest from unittest.mock import patch, MagicMock, AsyncMock from app.services.auth_client import AuthAPIError class TestLoginRateLimiting: """Test rate limiting on the login endpoint.""" def test_login_rate_limit_exceeded(self, client): """ Test that the login endpoint returns 429 after exceeding rate limit. GIVEN a client IP has made 5 login attempts within 1 minute WHEN the client attempts another login THEN the system returns HTTP 429 Too Many Requests AND the response includes a Retry-After header """ # Mock the external auth service to return auth error with patch("app.api.auth.router.verify_credentials", new_callable=AsyncMock) as mock_verify: mock_verify.side_effect = AuthAPIError("Invalid credentials") login_data = {"email": "test@example.com", "password": "wrongpassword"} # Make 5 requests (the limit) for i in range(5): response = client.post("/api/auth/login", json=login_data) # These should fail due to invalid credentials (401), but not rate limit assert response.status_code == 401, f"Request {i+1} expected 401, got {response.status_code}" # The 6th request should be rate limited response = client.post("/api/auth/login", json=login_data) assert response.status_code == 429, f"Expected 429 Too Many Requests, got {response.status_code}" # Response should contain error details data = response.json() assert "error" in data or "detail" in data, "Response should contain error details" def test_login_within_rate_limit(self, client): """ Test that requests within the rate limit are allowed. GIVEN a client IP has not exceeded the rate limit WHEN the client makes login requests THEN the requests are processed normally (not rate limited) """ with patch("app.api.auth.router.verify_credentials", new_callable=AsyncMock) as mock_verify: mock_verify.side_effect = AuthAPIError("Invalid credentials") login_data = {"email": "test@example.com", "password": "wrongpassword"} # Make requests within the limit for i in range(3): response = client.post("/api/auth/login", json=login_data) # These should fail due to invalid credentials (401), but not be rate limited assert response.status_code == 401, f"Request {i+1} expected 401, got {response.status_code}" def test_rate_limit_response_format(self, client): """ Test that the 429 response format matches API standards. GIVEN the rate limit has been exceeded WHEN the client receives a 429 response THEN the response body contains appropriate error information """ with patch("app.api.auth.router.verify_credentials", new_callable=AsyncMock) as mock_verify: mock_verify.side_effect = AuthAPIError("Invalid credentials") login_data = {"email": "test@example.com", "password": "wrongpassword"} # Exhaust the rate limit for _ in range(5): client.post("/api/auth/login", json=login_data) # The next request should be rate limited response = client.post("/api/auth/login", json=login_data) assert response.status_code == 429 # Check response body contains error information data = response.json() assert "error" in data or "detail" in data, "Response should contain error details" class TestRateLimiterConfiguration: """Test rate limiter configuration.""" def test_limiter_uses_redis_storage(self): """ Test that the limiter is configured with Redis storage. GIVEN the rate limiter configuration WHEN we inspect the storage URI THEN it should be configured to use Redis """ from app.core.rate_limiter import limiter from app.core.config import settings # The limiter should be configured assert limiter is not None # Verify Redis URL is properly configured assert settings.REDIS_URL.startswith("redis://") def test_limiter_uses_remote_address_key(self): """ Test that the limiter uses client IP as the key. GIVEN the rate limiter configuration WHEN we check the key function THEN it should use get_remote_address """ from app.core.rate_limiter import limiter from slowapi.util import get_remote_address # The key function should be get_remote_address assert limiter._key_func == get_remote_address def test_rate_limit_tiers_configured(self): """ Test that rate limit tiers are properly configured. GIVEN the settings configuration WHEN we check the rate limit tier values THEN they should match the expected defaults """ from app.core.config import settings # Standard tier: 60/minute assert settings.RATE_LIMIT_STANDARD == "60/minute" # Sensitive tier: 20/minute assert settings.RATE_LIMIT_SENSITIVE == "20/minute" # Heavy tier: 5/minute assert settings.RATE_LIMIT_HEAVY == "5/minute" def test_rate_limit_helper_functions(self): """ Test that rate limit helper functions return correct values. GIVEN the rate limiter module WHEN we call the helper functions THEN they should return the configured rate limit strings """ from app.core.rate_limiter import ( get_rate_limit_standard, get_rate_limit_sensitive, get_rate_limit_heavy ) assert get_rate_limit_standard() == "60/minute" assert get_rate_limit_sensitive() == "20/minute" assert get_rate_limit_heavy() == "5/minute" class TestRateLimitHeaders: """Test rate limit headers in responses.""" def test_rate_limit_headers_present(self, client): """ Test that rate limit headers are included in responses. GIVEN a rate-limited endpoint WHEN a request is made THEN the response includes X-RateLimit-* headers """ with patch("app.api.auth.router.verify_credentials", new_callable=AsyncMock) as mock_verify: mock_verify.side_effect = AuthAPIError("Invalid credentials") login_data = {"email": "test@example.com", "password": "wrongpassword"} response = client.post("/api/auth/login", json=login_data) # Check that rate limit headers are present # Note: slowapi uses these header names when headers_enabled=True headers = response.headers # The exact header names depend on slowapi version # Common patterns: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset # or: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset rate_limit_headers = [ key for key in headers.keys() if "ratelimit" in key.lower() or "rate-limit" in key.lower() ] # At minimum, we should have rate limit information in headers # when the limiter has headers_enabled=True assert len(rate_limit_headers) > 0 or response.status_code == 401, \ "Rate limit headers should be present in response" class TestEndpointRateLimits: """Test rate limits on specific endpoint categories.""" def test_rate_limit_tier_values_are_valid(self): """ Test that rate limit tier values are in valid format. GIVEN the rate limit configuration WHEN we validate the format THEN all values should be in "{number}/{period}" format """ from app.core.config import settings import re pattern = r"^\d+/(second|minute|hour|day)$" assert re.match(pattern, settings.RATE_LIMIT_STANDARD), \ f"Invalid format: {settings.RATE_LIMIT_STANDARD}" assert re.match(pattern, settings.RATE_LIMIT_SENSITIVE), \ f"Invalid format: {settings.RATE_LIMIT_SENSITIVE}" assert re.match(pattern, settings.RATE_LIMIT_HEAVY), \ f"Invalid format: {settings.RATE_LIMIT_HEAVY}" def test_rate_limit_ordering(self): """ Test that rate limit tiers are ordered correctly. GIVEN the rate limit configuration WHEN we compare the limits THEN heavy < sensitive < standard """ from app.core.config import settings def extract_limit(rate_str): """Extract numeric limit from rate string like '60/minute'.""" return int(rate_str.split("/")[0]) standard_limit = extract_limit(settings.RATE_LIMIT_STANDARD) sensitive_limit = extract_limit(settings.RATE_LIMIT_SENSITIVE) heavy_limit = extract_limit(settings.RATE_LIMIT_HEAVY) assert heavy_limit < sensitive_limit < standard_limit, \ f"Rate limits should be ordered: heavy({heavy_limit}) < sensitive({sensitive_limit}) < standard({standard_limit})"