## 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>
248 lines
9.4 KiB
Python
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})"
|