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
This commit is contained in:
377
tests/unit/dependencies/test_rate_limit.py
Normal file
377
tests/unit/dependencies/test_rate_limit.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user