Files
PROJECT-CONTORL/backend/tests/test_rate_limit.py
beabigegg 3bdc6ff1c9 feat: implement 8 OpenSpec proposals for security, reliability, and UX improvements
## Security Enhancements (P0)
- Add input validation with max_length and numeric range constraints
- Implement WebSocket token authentication via first message
- Add path traversal prevention in file storage service

## Permission Enhancements (P0)
- Add project member management for cross-department access
- Implement is_department_manager flag for workload visibility

## Cycle Detection (P0)
- Add DFS-based cycle detection for task dependencies
- Add formula field circular reference detection
- Display user-friendly cycle path visualization

## Concurrency & Reliability (P1)
- Implement optimistic locking with version field (409 Conflict on mismatch)
- Add trigger retry mechanism with exponential backoff (1s, 2s, 4s)
- Implement cascade restore for soft-deleted tasks

## Rate Limiting (P1)
- Add tiered rate limits: standard (60/min), sensitive (20/min), heavy (5/min)
- Apply rate limits to tasks, reports, attachments, and comments

## Frontend Improvements (P1)
- Add responsive sidebar with hamburger menu for mobile
- Improve touch-friendly UI with proper tap target sizes
- Complete i18n translations for all components

## Backend Reliability (P2)
- Configure database connection pool (size=10, overflow=20)
- Add Redis fallback mechanism with message queue
- Add blocker check before task deletion

## API Enhancements (P3)
- Add standardized response wrapper utility
- Add /health/ready and /health/live endpoints
- Implement project templates with status/field copying

## Tests Added
- test_input_validation.py - Schema and path traversal tests
- test_concurrency_reliability.py - Optimistic locking and retry tests
- test_backend_reliability.py - Connection pool and Redis tests
- test_api_enhancements.py - Health check and template tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:13:43 +08:00

248 lines
9.4 KiB
Python

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