Files
gh-christianlouis-docuelevate/tests/test_upload_rate_limit.py
T
copilot-swe-agent[bot] 1a195a96bd fix: merge main, address code review feedback for security fix PR #816
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html)
- Add defensive JSON parsing with try/except for integration.config
- Wrap tester() call in try/except to prevent 500 errors from bad config
- Add i18n key integrations.connection_test_failed_fallback in en.json
- Reference i18n key in template JS fallback message
- Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date
- Remove accidental revert.sh file
- Fix missing MagicMock/patch imports in test file
- Add tests for invalid JSON config and tester exception error paths

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
2026-03-23 16:21:09 +00:00

266 lines
11 KiB
Python

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