- 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
377 lines
12 KiB
Python
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 |