Files
openrouter-watcher/tests/unit/dependencies/test_rate_limit.py
Luca Sacchi Ricciardi d274970358 test(public-api): T40 add comprehensive public API endpoint tests
- Schema tests: 25 tests (100% coverage)
- Rate limit tests: 18 tests (98% coverage)
- Endpoint tests: 27 tests for stats/usage/keys
- Security tests: JWT rejection, inactive tokens, missing auth
- Total: 70 tests for public API v1
2026-04-07 16:16:18 +02:00

377 lines
12 KiB
Python

"""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