"""Tests for rate limiting dependency. T39: Rate limiting tests for public API. """ import time from unittest.mock import Mock, patch import pytest from fastapi import HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials from openrouter_monitor.dependencies.rate_limit import ( RateLimiter, _rate_limit_storage, check_rate_limit, get_client_ip, rate_limit_dependency, rate_limiter, ) @pytest.fixture(autouse=True) def clear_rate_limit_storage(): """Clear rate limit storage before each test.""" _rate_limit_storage.clear() yield _rate_limit_storage.clear() class TestGetClientIp: """Test suite for get_client_ip function.""" def test_x_forwarded_for_header(self): """Test IP extraction from X-Forwarded-For header.""" # Arrange request = Mock(spec=Request) request.headers = {"X-Forwarded-For": "192.168.1.1, 10.0.0.1"} request.client = Mock() request.client.host = "10.0.0.2" # Act result = get_client_ip(request) # Assert assert result == "192.168.1.1" def test_x_forwarded_for_single_ip(self): """Test IP extraction with single IP in X-Forwarded-For.""" # Arrange request = Mock(spec=Request) request.headers = {"X-Forwarded-For": "192.168.1.1"} request.client = Mock() request.client.host = "10.0.0.2" # Act result = get_client_ip(request) # Assert assert result == "192.168.1.1" def test_fallback_to_client_host(self): """Test fallback to client.host when no X-Forwarded-For.""" # Arrange request = Mock(spec=Request) request.headers = {} request.client = Mock() request.client.host = "192.168.1.100" # Act result = get_client_ip(request) # Assert assert result == "192.168.1.100" def test_unknown_when_no_client(self): """Test returns 'unknown' when no client info available.""" # Arrange request = Mock(spec=Request) request.headers = {} request.client = None # Act result = get_client_ip(request) # Assert assert result == "unknown" class TestCheckRateLimit: """Test suite for check_rate_limit function.""" def test_first_request_allowed(self): """Test first request is always allowed.""" # Arrange key = "test_key_1" # Act allowed, remaining, limit, reset_time = check_rate_limit(key, max_requests=100, window_seconds=3600) # Assert assert allowed is True assert remaining == 99 assert limit == 100 assert reset_time > time.time() def test_requests_within_limit_allowed(self): """Test requests within limit are allowed.""" # Arrange key = "test_key_2" # Act - make 5 requests for i in range(5): allowed, remaining, limit, reset_time = check_rate_limit(key, max_requests=10, window_seconds=3600) # Assert assert allowed is True assert remaining == 5 # 10 - 5 = 5 remaining def test_limit_exceeded_not_allowed(self): """Test requests exceeding limit are not allowed.""" # Arrange key = "test_key_3" # Act - make 11 requests with limit of 10 for i in range(10): allowed, remaining, limit, reset_time = check_rate_limit(key, max_requests=10, window_seconds=3600) # 11th request should be blocked allowed, remaining, limit, reset_time = check_rate_limit(key, max_requests=10, window_seconds=3600) # Assert assert allowed is False assert remaining == 0 def test_window_resets_after_expiry(self): """Test rate limit window resets after expiry.""" # Arrange key = "test_key_4" # Exhaust the limit for i in range(10): check_rate_limit(key, max_requests=10, window_seconds=1) # Verify limit exceeded allowed, _, _, _ = check_rate_limit(key, max_requests=10, window_seconds=1) assert allowed is False # Wait for window to expire time.sleep(1.1) # Act - new request should be allowed allowed, remaining, limit, reset_time = check_rate_limit(key, max_requests=10, window_seconds=3600) # Assert assert allowed is True assert remaining == 9 class TestRateLimiter: """Test suite for RateLimiter class.""" @pytest.fixture def mock_request(self): """Create a mock request.""" request = Mock(spec=Request) request.headers = {} request.client = Mock() request.client.host = "192.168.1.100" return request @pytest.fixture def mock_credentials(self): """Create mock API token credentials.""" creds = Mock(spec=HTTPAuthorizationCredentials) creds.credentials = "or_api_test_token_12345" return creds @pytest.mark.asyncio async def test_token_based_rate_limit_allowed(self, mock_request, mock_credentials): """Test token-based rate limiting allows requests within limit.""" # Arrange limiter = RateLimiter(token_limit=100, token_window=3600) # Act result = await limiter(mock_request, mock_credentials) # Assert assert result["X-RateLimit-Limit"] == 100 assert result["X-RateLimit-Remaining"] == 99 @pytest.mark.asyncio async def test_token_based_rate_limit_exceeded(self, mock_request, mock_credentials): """Test token-based rate limit raises 429 when exceeded.""" # Arrange limiter = RateLimiter(token_limit=2, token_window=3600) # Use up the limit await limiter(mock_request, mock_credentials) await limiter(mock_request, mock_credentials) # Act & Assert - 3rd request should raise 429 with pytest.raises(HTTPException) as exc_info: await limiter(mock_request, mock_credentials) assert exc_info.value.status_code == 429 assert "Rate limit exceeded" in exc_info.value.detail assert "X-RateLimit-Limit" in exc_info.value.headers assert "X-RateLimit-Remaining" in exc_info.value.headers assert "Retry-After" in exc_info.value.headers @pytest.mark.asyncio async def test_ip_based_rate_limit_fallback(self, mock_request): """Test IP-based rate limiting when no credentials provided.""" # Arrange limiter = RateLimiter(ip_limit=30, ip_window=60) # Act result = await limiter(mock_request, None) # Assert assert result["X-RateLimit-Limit"] == 30 assert result["X-RateLimit-Remaining"] == 29 @pytest.mark.asyncio async def test_ip_based_rate_limit_exceeded(self, mock_request): """Test IP-based rate limit raises 429 when exceeded.""" # Arrange limiter = RateLimiter(ip_limit=2, ip_window=60) # Use up the limit await limiter(mock_request, None) await limiter(mock_request, None) # Act & Assert - 3rd request should raise 429 with pytest.raises(HTTPException) as exc_info: await limiter(mock_request, None) assert exc_info.value.status_code == 429 class TestRateLimitDependency: """Test suite for rate_limit_dependency function.""" @pytest.fixture def mock_request(self): """Create a mock request.""" request = Mock(spec=Request) request.headers = {} request.client = Mock() request.client.host = "192.168.1.100" return request @pytest.fixture def mock_credentials(self): """Create mock API token credentials.""" creds = Mock(spec=HTTPAuthorizationCredentials) creds.credentials = "or_api_test_token_12345" return creds @pytest.mark.asyncio async def test_default_token_limits(self, mock_request, mock_credentials): """Test default token rate limits (100/hour).""" # Act result = await rate_limit_dependency(mock_request, mock_credentials) # Assert assert result["X-RateLimit-Limit"] == 100 assert result["X-RateLimit-Remaining"] == 99 @pytest.mark.asyncio async def test_default_ip_limits(self, mock_request): """Test default IP rate limits (30/minute).""" # Act result = await rate_limit_dependency(mock_request, None) # Assert assert result["X-RateLimit-Limit"] == 30 assert result["X-RateLimit-Remaining"] == 29 @pytest.mark.asyncio async def test_different_tokens_have_separate_limits(self, mock_request): """Test that different API tokens have separate rate limits.""" # Arrange creds1 = Mock(spec=HTTPAuthorizationCredentials) creds1.credentials = "or_api_token_1" creds2 = Mock(spec=HTTPAuthorizationCredentials) creds2.credentials = "or_api_token_2" # Act - exhaust limit for token 1 limiter = RateLimiter(token_limit=2, token_window=3600) await limiter(mock_request, creds1) await limiter(mock_request, creds1) # Assert - token 1 should be limited with pytest.raises(HTTPException) as exc_info: await limiter(mock_request, creds1) assert exc_info.value.status_code == 429 # But token 2 should still be allowed result = await limiter(mock_request, creds2) assert result["X-RateLimit-Remaining"] == 1 class TestRateLimitHeaders: """Test suite for rate limit headers.""" @pytest.mark.asyncio async def test_headers_present_on_allowed_request(self): """Test that rate limit headers are present on allowed requests.""" # Arrange request = Mock(spec=Request) request.headers = {} request.client = Mock() request.client.host = "192.168.1.100" creds = Mock(spec=HTTPAuthorizationCredentials) creds.credentials = "or_api_test_token" # Act result = await rate_limit_dependency(request, creds) # Assert assert "X-RateLimit-Limit" in result assert "X-RateLimit-Remaining" in result assert isinstance(result["X-RateLimit-Limit"], int) assert isinstance(result["X-RateLimit-Remaining"], int) @pytest.mark.asyncio async def test_headers_present_on_429_response(self): """Test that rate limit headers are present on 429 response.""" # Arrange request = Mock(spec=Request) request.headers = {} request.client = Mock() request.client.host = "192.168.1.100" limiter = RateLimiter(token_limit=1, token_window=3600) creds = Mock(spec=HTTPAuthorizationCredentials) creds.credentials = "or_api_test_token_429" # Use up the limit await limiter(request, creds) # Act & Assert with pytest.raises(HTTPException) as exc_info: await limiter(request, creds) headers = exc_info.value.headers assert "X-RateLimit-Limit" in headers assert "X-RateLimit-Remaining" in headers assert "X-RateLimit-Reset" in headers assert "Retry-After" in headers assert headers["X-RateLimit-Limit"] == "1" assert headers["X-RateLimit-Remaining"] == "0" class TestRateLimiterCleanup: """Test suite for rate limit storage cleanup.""" def test_storage_cleanup_on_many_entries(self): """Test that storage is cleaned when too many entries.""" # This is an internal implementation detail test # We can verify it doesn't crash with many entries # Arrange - create many entries for i in range(100): key = f"test_key_{i}" check_rate_limit(key, max_requests=100, window_seconds=3600) # Act - add one more to trigger cleanup key = "trigger_cleanup" allowed, remaining, limit, reset_time = check_rate_limit(key, max_requests=100, window_seconds=3600) # Assert - should still work assert allowed is True assert remaining == 99