Move hardcoded constants into GlobalSettings and surface them in the Settings UI: task retention, auth rate limit, assistant history retention, translate baseline expiry, default environment, and extended logging fields. - consolidated settings API: new fields in GET/PATCH with re-validation through GlobalSettings (422 on out-of-range instead of silent persist) - rate limiter policy read live from settings with 60s cache + lock-free fast path; cache invalidated centrally in ConfigManager on auth policy change (covers PATCH /settings/global and /consolidated) - shared settings_provider.get_global_settings() replaces three copies of the fallback pattern; scheduler baseline fallback derives from model default - remove dead GlobalSettings fields (pagination_limit, ff_dataset_*, LLM_*_RETENTION_DAYS, GLOBAL_VALIDATION_WORKER_LIMIT, AppAsyncRuntimeConfig) - SystemSettings blocks save on out-of-range values; LoggingSettings gains max_bytes/backup_count/agent_view/hide_routine_infra/log_level_for_agents; EnvironmentsTab gains default environment selector - tests: rate limiter settings-driven policy, consolidated PATCH 422 paths, System tab save-blocking UX test
295 lines
13 KiB
Python
295 lines
13 KiB
Python
# #region Test.RateLimiter [C:3] [TYPE Module] [SEMANTICS test,rate_limiter,security,throttle]
|
|
# @BRIEF Verify RateLimiterModule contracts — ban/window logic, pruning, thread safety.
|
|
# @RELATION BINDS_TO -> [Core.RateLimiter.RateLimiterModule]
|
|
# @TEST_EDGE: unknown_ip_not_banned -> is_banned returns False for unseen IP
|
|
# @TEST_EDGE: ban_triggers_at_limit -> MAX_ATTEMPTS+1 attempts triggers ban
|
|
# @TEST_EDGE: exactly_at_limit_no_ban -> exactly MAX_ATTEMPTS does NOT ban (code uses >)
|
|
# @TEST_EDGE: ban_expires -> ban auto-clears after BAN_DURATION seconds
|
|
# @TEST_EDGE: old_attempts_pruned -> attempts outside window are discarded
|
|
# @TEST_EDGE: success_clears_attempts -> record_success resets attempt counter
|
|
# @TEST_EDGE: success_does_not_clear_ban -> record_success does NOT lift active ban
|
|
# @TEST_EDGE: ban_clears_attempts -> after ban, attempt history is deleted
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
import threading
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
from src.core.rate_limiter import RateLimiter, _read_config as _original_read_config
|
|
|
|
# ── Constant overrides for fast, deterministic tests ──
|
|
# MAX_ATTEMPTS=2 → ban triggers on 3rd attempt (production code uses `>` not `>=`)
|
|
# ATTEMPT_WINDOW=3600 → 1-hour window (attempts stay valid within a single test)
|
|
# BAN_DURATION=3600 → 1-hour ban (testable via mocked monotonic clock)
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fast_constants(monkeypatch):
|
|
"""Monkeypatch module constants to small values for fast testing.
|
|
|
|
The limiter resolves policy from GlobalSettings with a fallback to the
|
|
module constants, so the settings reader is patched to return the fast
|
|
values — keeping the sliding-window logic deterministic.
|
|
"""
|
|
monkeypatch.setattr("src.core.rate_limiter.MAX_ATTEMPTS", 2)
|
|
monkeypatch.setattr("src.core.rate_limiter.ATTEMPT_WINDOW", 3600)
|
|
monkeypatch.setattr("src.core.rate_limiter.BAN_DURATION", 3600)
|
|
monkeypatch.setattr(
|
|
"src.core.rate_limiter._read_config",
|
|
lambda: {"max_attempts": 2, "attempt_window": 3600, "ban_duration": 3600},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def limiter():
|
|
"""Fresh RateLimiter instance per test — no shared state."""
|
|
return RateLimiter()
|
|
|
|
|
|
class TestRateLimiter:
|
|
"""RateLimiter — in-memory sliding-window rate limiter keyed by IP."""
|
|
|
|
# #region Test.RateLimiter.TestIsBannedUnknownIp [C:2] [TYPE Function]
|
|
# @BRIEF Unknown IP has no ban entry — is_banned returns False.
|
|
def test_is_banned_unknown_ip(self, limiter):
|
|
assert limiter.is_banned("192.168.1.1") is False
|
|
# #endregion Test.RateLimiter.TestIsBannedUnknownIp
|
|
|
|
# #region Test.RateLimiter.TestIsBannedBannedIp [C:2] [TYPE Function]
|
|
# @BRIEF IP with 3 attempts (MAX_ATTEMPTS+1) is banned — is_banned returns True.
|
|
def test_is_banned_banned_ip(self, limiter):
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
mock_time.monotonic.return_value = 1000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
|
|
# Must check inside patch — real monotonic would exceed ban expiry
|
|
assert limiter.is_banned("10.0.0.1") is True
|
|
# #endregion Test.RateLimiter.TestIsBannedBannedIp
|
|
|
|
# #region Test.RateLimiter.TestBanExpiresAfterDuration [C:2] [TYPE Function]
|
|
# @BRIEF Ban auto-clears once monotonic clock exceeds ban expiry.
|
|
def test_ban_expires_after_duration(self, limiter):
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
mock_time.monotonic.return_value = 1000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
assert limiter.is_banned("10.0.0.1") is True
|
|
|
|
# Advance clock past BAN_DURATION (3600s) + 1s margin
|
|
mock_time.monotonic.return_value = 4601.0
|
|
assert limiter.is_banned("10.0.0.1") is False
|
|
# #endregion Test.RateLimiter.TestBanExpiresAfterDuration
|
|
|
|
# #region Test.RateLimiter.TestRecordAttemptUnderLimit [C:2] [TYPE Function]
|
|
# @BRIEF 2 attempts (exactly MAX_ATTEMPTS) does NOT trigger ban.
|
|
def test_record_attempt_under_limit(self, limiter):
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
mock_time.monotonic.return_value = 1000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
|
|
assert limiter.is_banned("10.0.0.1") is False
|
|
# #endregion Test.RateLimiter.TestRecordAttemptUnderLimit
|
|
|
|
# #region Test.RateLimiter.TestRecordAttemptTriggersBan [C:2] [TYPE Function]
|
|
# @BRIEF 3rd attempt (MAX_ATTEMPTS+1) triggers ban — code uses `>` not `>=`.
|
|
def test_record_attempt_triggers_ban(self, limiter):
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
mock_time.monotonic.return_value = 1000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
assert limiter.is_banned("10.0.0.1") is False
|
|
|
|
limiter.record_attempt("10.0.0.1")
|
|
assert limiter.is_banned("10.0.0.1") is True
|
|
# #endregion Test.RateLimiter.TestRecordAttemptTriggersBan
|
|
|
|
# #region Test.RateLimiter.TestOldAttemptsPruned [C:2] [TYPE Function]
|
|
# @BRIEF Attempts older than ATTEMPT_WINDOW are discarded; fresh start after gap.
|
|
def test_old_attempts_pruned(self, limiter):
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
# 2 attempts at t=1000
|
|
mock_time.monotonic.return_value = 1000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
|
|
# Advance past ATTEMPT_WINDOW (3600s) — old attempts expire
|
|
mock_time.monotonic.return_value = 5000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
|
|
# Only 1 attempt in window — not banned
|
|
assert limiter.is_banned("10.0.0.1") is False
|
|
# #endregion Test.RateLimiter.TestOldAttemptsPruned
|
|
|
|
# #region Test.RateLimiter.TestRecordSuccessClearsAttempts [C:2] [TYPE Function]
|
|
# @BRIEF record_success resets attempt counter; subsequent attempts start fresh.
|
|
def test_record_success_clears_attempts(self, limiter):
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
mock_time.monotonic.return_value = 1000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_success("10.0.0.1")
|
|
|
|
# 2 more attempts after success — counter was reset, still under limit
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
assert limiter.is_banned("10.0.0.1") is False
|
|
# #endregion Test.RateLimiter.TestRecordSuccessClearsAttempts
|
|
|
|
# #region Test.RateLimiter.TestRecordSuccessDoesNotClearBan [C:2] [TYPE Function]
|
|
# @BRIEF record_success clears attempts but does NOT lift an active ban.
|
|
def test_record_success_does_not_clear_ban(self, limiter):
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
mock_time.monotonic.return_value = 1000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
assert limiter.is_banned("10.0.0.1") is True
|
|
|
|
# Success clears attempts but ban remains
|
|
limiter.record_success("10.0.0.1")
|
|
assert limiter.is_banned("10.0.0.1") is True
|
|
# #endregion Test.RateLimiter.TestRecordSuccessDoesNotClearBan
|
|
|
|
# #region Test.RateLimiter.TestDifferentIpsIndependent [C:2] [TYPE Function]
|
|
# @BRIEF Ban on one IP does not affect other IPs.
|
|
def test_different_ips_independent(self, limiter):
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
mock_time.monotonic.return_value = 1000.0
|
|
for _ in range(3):
|
|
limiter.record_attempt("10.0.0.1")
|
|
|
|
assert limiter.is_banned("10.0.0.1") is True
|
|
assert limiter.is_banned("192.168.1.1") is False
|
|
# #endregion Test.RateLimiter.TestDifferentIpsIndependent
|
|
|
|
# #region Test.RateLimiter.TestBanClearsAttempts [C:2] [TYPE Function]
|
|
# @BRIEF After ban, attempt history is deleted; post-expiry attempts start fresh.
|
|
def test_ban_clears_attempts(self, limiter):
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
# Trigger ban at t=1000
|
|
mock_time.monotonic.return_value = 1000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
assert limiter.is_banned("10.0.0.1") is True
|
|
|
|
# Advance past ban expiry
|
|
mock_time.monotonic.return_value = 4601.0
|
|
assert limiter.is_banned("10.0.0.1") is False
|
|
|
|
# 2 fresh attempts — if old attempts weren't cleared, these would
|
|
# stack on top of 3 old ones (5 > 2) and re-trigger ban immediately.
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
assert limiter.is_banned("10.0.0.1") is False
|
|
# #endregion Test.RateLimiter.TestBanClearsAttempts
|
|
|
|
# #region Test.RateLimiter.TestThreadSafetyBasic [C:2] [TYPE Function]
|
|
# @BRIEF Concurrent record_attempt calls from multiple threads do not raise.
|
|
def test_thread_safety_basic(self, limiter):
|
|
errors = []
|
|
|
|
def worker(ip, count):
|
|
try:
|
|
for _ in range(count):
|
|
limiter.record_attempt(ip)
|
|
limiter.is_banned(ip)
|
|
except Exception as exc:
|
|
errors.append(exc)
|
|
|
|
threads = [
|
|
threading.Thread(target=worker, args=("10.0.0.1", 50)),
|
|
threading.Thread(target=worker, args=("10.0.0.2", 50)),
|
|
threading.Thread(target=worker, args=("10.0.0.3", 50)),
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=5)
|
|
|
|
assert errors == []
|
|
# #endregion Test.RateLimiter.TestThreadSafetyBasic
|
|
|
|
|
|
class TestRateLimiterSingleton:
|
|
"""rate_limiter singleton is pre-created at module level."""
|
|
|
|
# #region Test.RateLimiter.TestSingletonExists [C:2] [TYPE Function]
|
|
# @BRIEF Module-level singleton exists and exposes the expected interface.
|
|
def test_singleton_exists(self):
|
|
from src.core.rate_limiter import rate_limiter
|
|
|
|
assert rate_limiter is not None
|
|
assert hasattr(rate_limiter, "is_banned")
|
|
assert hasattr(rate_limiter, "record_attempt")
|
|
assert hasattr(rate_limiter, "record_success")
|
|
# #endregion Test.RateLimiter.TestSingletonExists
|
|
|
|
|
|
class TestRateLimiterSettingsDriven:
|
|
"""Rate limiting policy resolves from GlobalSettings with constant fallback."""
|
|
|
|
# #region Test.RateLimiter.TestSettingsOverrideConstants [C:2] [TYPE Function]
|
|
# @BRIEF GlobalSettings auth_* fields override module constants when available.
|
|
def test_settings_override_constants(self, monkeypatch):
|
|
import src.core.rate_limiter as rl
|
|
|
|
class FakeSettings:
|
|
auth_max_attempts = 1
|
|
auth_attempt_window = 60
|
|
auth_ban_duration = 120
|
|
|
|
class FakeConfig:
|
|
settings = FakeSettings()
|
|
|
|
class FakeConfigManager:
|
|
def get_config(self):
|
|
return FakeConfig()
|
|
|
|
monkeypatch.setattr(
|
|
"src.dependencies.get_config_manager", lambda: FakeConfigManager(), raising=False
|
|
)
|
|
monkeypatch.setattr(rl, "_read_config", _original_read_config)
|
|
monkeypatch.setattr(rl, "_config_cache", None)
|
|
monkeypatch.setattr(rl, "_config_cache_at", 0.0)
|
|
|
|
config = rl._read_config()
|
|
assert config == {"max_attempts": 1, "attempt_window": 60, "ban_duration": 120}
|
|
|
|
limiter = rl.RateLimiter()
|
|
with patch("src.core.rate_limiter.time") as mock_time:
|
|
mock_time.monotonic.return_value = 1000.0
|
|
limiter.record_attempt("10.0.0.1")
|
|
limiter.record_attempt("10.0.0.1")
|
|
# max_attempts=1 → 2nd attempt exceeds limit → ban
|
|
assert limiter.is_banned("10.0.0.1") is True
|
|
# #endregion Test.RateLimiter.TestSettingsOverrideConstants
|
|
|
|
# #region Test.RateLimiter.TestFallbackOnConfigError [C:2] [TYPE Function]
|
|
# @BRIEF Missing/broken config manager falls back to module constants.
|
|
def test_fallback_on_config_error(self, monkeypatch):
|
|
import src.core.rate_limiter as rl
|
|
|
|
def boom():
|
|
raise RuntimeError("no config")
|
|
|
|
monkeypatch.setattr(rl, "_read_config", _original_read_config)
|
|
monkeypatch.setattr(rl, "_config_cache", None)
|
|
monkeypatch.setattr(rl, "_config_cache_at", 0.0)
|
|
monkeypatch.setattr("src.dependencies.get_config_manager", boom, raising=False)
|
|
|
|
config = rl._read_config()
|
|
assert config["max_attempts"] == rl.MAX_ATTEMPTS
|
|
assert config["attempt_window"] == rl.ATTEMPT_WINDOW
|
|
assert config["ban_duration"] == rl.BAN_DURATION
|
|
# #endregion Test.RateLimiter.TestFallbackOnConfigError
|
|
|
|
# #endregion Test.RateLimiter
|