"""Tests for per-user health-aware upload rate limiting (app/middleware/upload_rate_limit.py).""" from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from app.middleware.upload_rate_limit import compute_effective_limit # --------------------------------------------------------------------------- # Tests for compute_effective_limit (pure function, no Redis needed) # --------------------------------------------------------------------------- @pytest.mark.unit class TestComputeEffectiveLimit: """Tests for the health-aware effective-limit calculation.""" def test_normal_conditions_return_base_limit(self): """Under normal conditions the full base limit should be returned.""" effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=0.0) assert effective == 20 assert factor == 1.0 assert reason == "normal" def test_moderate_queue_halves_limit(self): """Queue depth > 50 should halve the base limit.""" effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=0.0) assert effective == 10 assert factor == 0.5 assert "moderate_queue" in reason def test_high_queue_quarters_limit(self): """Queue depth > 100 should quarter the base limit.""" effective, factor, reason = compute_effective_limit(20, queue_depth=120, cpu_load_ratio=0.0) assert effective == 5 assert factor == 0.25 assert "high_queue" in reason def test_critical_queue_drops_to_ten_percent(self): """Queue depth > 200 should drop to 10% of base limit.""" effective, factor, reason = compute_effective_limit(20, queue_depth=250, cpu_load_ratio=0.0) assert effective == 2 assert factor == 0.10 assert "critical_queue" in reason def test_moderate_cpu_halves_limit(self): """CPU load ratio > 1.5 should halve the base limit.""" effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=1.8) assert effective == 10 assert factor == 0.5 assert "moderate_cpu" in reason def test_high_cpu_quarters_limit(self): """CPU load ratio > 2.0 should quarter the base limit.""" effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=2.5) assert effective == 5 assert factor == 0.25 assert "high_cpu" in reason def test_critical_cpu_drops_to_ten_percent(self): """CPU load ratio > 3.0 should drop to 10% of base limit.""" effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=4.0) assert effective == 2 assert factor == 0.10 assert "critical_cpu" in reason def test_worst_metric_wins(self): """The lowest factor from queue and CPU should be applied.""" # Queue says 0.5, CPU says 0.25 → 0.25 wins effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=2.5) assert effective == 5 assert factor == 0.25 def test_minimum_effective_limit_is_one(self): """Even under extreme load the effective limit must be ≥ 1.""" effective, _factor, _reason = compute_effective_limit(1, queue_depth=999, cpu_load_ratio=10.0) assert effective >= 1 def test_zero_base_limit_returns_zero(self): """A base limit of 0 (disabled) should clamp to at least 1.""" effective, _factor, _reason = compute_effective_limit(0, queue_depth=0, cpu_load_ratio=0.0) # max(1, int(0 * 1.0)) = max(1, 0) = 1 # A base_limit of 0 means "disabled" and is handled upstream # (the dependency skips the check entirely), but the pure function # still clamps to 1 as a safety net. assert effective == 1 # --------------------------------------------------------------------------- # Tests for the FastAPI dependency (mocked Redis) # --------------------------------------------------------------------------- @pytest.mark.unit class TestRequireUploadRateLimit: """Tests for the require_upload_rate_limit FastAPI dependency.""" @pytest.mark.asyncio async def test_allows_request_when_redis_unavailable(self): """When Redis is down the dependency should fail open (allow the request).""" from app.middleware.upload_rate_limit import require_upload_rate_limit mock_request = MagicMock() mock_request.session = {} mock_request.client = MagicMock() mock_request.client.host = "127.0.0.1" with patch("app.middleware.upload_rate_limit._get_redis", return_value=None): # Should NOT raise result = await require_upload_rate_limit(mock_request) assert result is None @pytest.mark.asyncio async def test_allows_request_under_limit(self): """A user below the rate limit should be allowed through.""" from app.middleware.upload_rate_limit import require_upload_rate_limit mock_request = MagicMock() mock_request.session = {"user": {"username": "testuser"}} mock_request.client = MagicMock() mock_request.client.host = "10.0.0.1" mock_redis = MagicMock() mock_pipe = MagicMock() mock_pipe.execute.return_value = [ 0, # zremrangebyscore result 5, # zcard — current count (under limit of 20) [], # zrange oldest ] mock_redis.pipeline.return_value = mock_pipe mock_redis.llen.return_value = 0 # empty queues mock_pipe2 = MagicMock() mock_pipe2.execute.return_value = [True, True] # The second pipeline call (record upload) mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2] with ( patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis), patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="testuser"), patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.1), ): result = await require_upload_rate_limit(mock_request) assert result is None @pytest.mark.asyncio async def test_rejects_request_over_limit(self): """A user at or over the rate limit should receive a 429.""" from fastapi import HTTPException from app.middleware.upload_rate_limit import require_upload_rate_limit mock_request = MagicMock() mock_request.session = {"user": {"username": "spammer"}} mock_request.client = MagicMock() mock_request.client.host = "10.0.0.2" mock_redis = MagicMock() mock_pipe = MagicMock() mock_pipe.execute.return_value = [ 0, # zremrangebyscore 20, # zcard — at limit [("oldest_entry", 1000000.0)], # oldest entry for retry_after ] mock_redis.pipeline.return_value = mock_pipe mock_redis.llen.return_value = 0 with ( patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis), patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="spammer"), patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0), ): with pytest.raises(HTTPException) as exc_info: await require_upload_rate_limit(mock_request) assert exc_info.value.status_code == 429 assert "Retry-After" in exc_info.value.headers @pytest.mark.asyncio async def test_health_reduces_effective_limit(self): """When queues are deep, the effective limit should drop, causing a 429 sooner.""" from fastapi import HTTPException from app.middleware.upload_rate_limit import require_upload_rate_limit mock_request = MagicMock() mock_request.session = {"user": {"username": "normaluser"}} mock_request.client = MagicMock() mock_request.client.host = "10.0.0.3" mock_redis = MagicMock() mock_pipe = MagicMock() # 12 uploads already — under normal limit of 20 but over health-reduced limit mock_pipe.execute.return_value = [ 0, # zremrangebyscore 12, # zcard — 12 uploads in window [("oldest", 1000000.0)], ] mock_redis.pipeline.return_value = mock_pipe # Simulate deep queue (>100) → effective limit = 25% of 20 = 5 mock_redis.llen.return_value = 40 # 40 per queue * 3 = 120 total with ( patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis), patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="normaluser"), patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0), ): with pytest.raises(HTTPException) as exc_info: await require_upload_rate_limit(mock_request) assert exc_info.value.status_code == 429 @pytest.mark.asyncio async def test_falls_back_to_ip_when_no_user(self): """Unauthenticated requests should use IP-based rate limiting.""" from app.middleware.upload_rate_limit import require_upload_rate_limit mock_request = MagicMock() mock_request.session = {} mock_request.client = MagicMock() mock_request.client.host = "192.168.1.100" mock_redis = MagicMock() mock_pipe = MagicMock() mock_pipe.execute.return_value = [0, 0, []] mock_redis.pipeline.return_value = mock_pipe mock_redis.llen.return_value = 0 mock_pipe2 = MagicMock() mock_pipe2.execute.return_value = [True, True] mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2] with ( patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis), patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value=None), patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0), ): result = await require_upload_rate_limit(mock_request) assert result is None # --------------------------------------------------------------------------- # Tests for configuration # --------------------------------------------------------------------------- @pytest.mark.unit class TestUploadRateLimitConfig: """Tests for upload rate limit configuration settings.""" def test_settings_exist(self): """Verify per-user upload rate limit settings are exposed in config.""" from app.config import settings assert hasattr(settings, "upload_rate_limit_per_user") assert hasattr(settings, "upload_rate_limit_window") def test_sensible_defaults(self): """Default values should be reasonable for a multi-user system.""" from app.config import settings assert settings.upload_rate_limit_per_user >= 10 assert settings.upload_rate_limit_per_user <= 100 assert settings.upload_rate_limit_window >= 30 assert settings.upload_rate_limit_window <= 300