e518bce922
Resolve all merge conflicts between our automation feature branch and current main (v0.163.0, 920 commits ahead). Conflicts resolved: - app/api/__init__.py: add automation_router alongside main's new routers (classification_rules, qr_auth, sessions, system_reset) - app/config.py: add main's new settings (dropbox_use_global_credentials, factory_reset_on_startup, enable_factory_reset) - app/models.py: add main's new models (ClassificationRuleModel, UserSession, QRLoginChallenge, SharePoint integration type) - app/utils/settings_service.py: merge automation_hooks_enabled with main's new metadata entries - docs/API.md: merge automation API docs with main's classification rules docs - docs/ConfigurationGuide.md: add factory reset settings - tests/conftest.py: import both AutomationHook and new main models Migration renumbered: - 037_add_automation_hooks → 040_add_automation_hooks - down_revision: 039_add_classification_rules (was 036_add_document_translation_fields) - Chain: 036 → 037 → 038 → 039 → 040 (automation hooks) For all non-automation files with conflicts, main's version was taken since our branch did not modify those files (conflicts were from a stale prior merge). Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/cb62f012-3b69-4415-835e-3857ce3e9f45
266 lines
11 KiB
Python
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
|