7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
657 lines
24 KiB
Python
657 lines
24 KiB
Python
"""Tests for security/rate_limiter.py."""
|
|
|
|
import importlib
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
from flask import Flask
|
|
|
|
|
|
def _reload_rate_limiter_with_limits(user_limit, ip_limit):
|
|
"""Reload security.rate_limiter with patched upload limits.
|
|
|
|
Decorators bind the limit string at module-import time, so functional
|
|
tests must reload the module after patching `load_server_config` to
|
|
re-bind the upload decorators with the test-controlled values.
|
|
"""
|
|
from local_deep_research.web import server_config as sc
|
|
|
|
fake_config = {
|
|
"host": "0.0.0.0",
|
|
"port": 5000,
|
|
"debug": False,
|
|
"use_https": True,
|
|
"allow_registrations": True,
|
|
"rate_limit_default": "5000 per hour;50000 per day",
|
|
"rate_limit_login": "5 per 15 minutes",
|
|
"rate_limit_registration": "3 per hour",
|
|
"rate_limit_settings": "30 per minute",
|
|
"rate_limit_upload_user": user_limit,
|
|
"rate_limit_upload_ip": ip_limit,
|
|
}
|
|
with patch.object(sc, "load_server_config", return_value=fake_config):
|
|
from local_deep_research.security import rate_limiter
|
|
|
|
importlib.reload(rate_limiter)
|
|
return rate_limiter
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
"""Create test Flask application."""
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def restore_rate_limiter_module():
|
|
"""Snapshot rate_limiter's module dict and restore it after the test.
|
|
|
|
The functional tests reload the module to re-bind the upload decorators
|
|
under test-controlled limits. Restoring the snapshot puts back the
|
|
original objects (the module-level Limiter instance included), instead
|
|
of reloading a third time under a hardcoded fake config — which would
|
|
leave fake limits and a fresh Limiter in the module for later tests
|
|
while earlier `from rate_limiter import ...` bindings kept the
|
|
originals.
|
|
"""
|
|
from local_deep_research.security import rate_limiter
|
|
|
|
snapshot = dict(rate_limiter.__dict__)
|
|
yield
|
|
rate_limiter.__dict__.clear()
|
|
rate_limiter.__dict__.update(snapshot)
|
|
|
|
|
|
class TestGetClientIp:
|
|
"""Tests for get_client_ip function."""
|
|
|
|
def test_returns_first_ip_from_x_forwarded_for(self, app):
|
|
"""Test that first IP from X-Forwarded-For chain is returned."""
|
|
with app.test_request_context(
|
|
environ_base={
|
|
"HTTP_X_FORWARDED_FOR": "192.168.1.100, 10.0.0.1, 172.16.0.1"
|
|
}
|
|
):
|
|
from local_deep_research.security.rate_limiter import get_client_ip
|
|
|
|
result = get_client_ip()
|
|
assert result == "192.168.1.100"
|
|
|
|
def test_strips_whitespace_from_forwarded_ip(self, app):
|
|
"""Test that whitespace is stripped from forwarded IP."""
|
|
with app.test_request_context(
|
|
environ_base={"HTTP_X_FORWARDED_FOR": " 192.168.1.100 , 10.0.0.1"}
|
|
):
|
|
from local_deep_research.security.rate_limiter import get_client_ip
|
|
|
|
result = get_client_ip()
|
|
assert result == "192.168.1.100"
|
|
|
|
def test_returns_x_real_ip_when_no_forwarded_for(self, app):
|
|
"""Test that X-Real-IP is used when X-Forwarded-For is absent."""
|
|
with app.test_request_context(
|
|
environ_base={"HTTP_X_REAL_IP": "10.20.30.40"}
|
|
):
|
|
from local_deep_research.security.rate_limiter import get_client_ip
|
|
|
|
result = get_client_ip()
|
|
assert result == "10.20.30.40"
|
|
|
|
def test_strips_whitespace_from_real_ip(self, app):
|
|
"""Test that whitespace is stripped from X-Real-IP."""
|
|
with app.test_request_context(
|
|
environ_base={"HTTP_X_REAL_IP": " 10.20.30.40 "}
|
|
):
|
|
from local_deep_research.security.rate_limiter import get_client_ip
|
|
|
|
result = get_client_ip()
|
|
assert result == "10.20.30.40"
|
|
|
|
def test_falls_back_to_remote_address(self, app):
|
|
"""Test fallback to get_remote_address when no proxy headers."""
|
|
with app.test_request_context():
|
|
from local_deep_research.security.rate_limiter import get_client_ip
|
|
|
|
# When no proxy headers are set, get_remote_address returns the remote addr
|
|
result = get_client_ip()
|
|
# Result should be some IP (default is typically 127.0.0.1)
|
|
assert result is not None
|
|
|
|
def test_prefers_x_forwarded_for_over_x_real_ip(self, app):
|
|
"""Test that X-Forwarded-For takes precedence over X-Real-IP."""
|
|
with app.test_request_context(
|
|
environ_base={
|
|
"HTTP_X_FORWARDED_FOR": "192.168.1.1",
|
|
"HTTP_X_REAL_IP": "10.0.0.1",
|
|
}
|
|
):
|
|
from local_deep_research.security.rate_limiter import get_client_ip
|
|
|
|
result = get_client_ip()
|
|
assert result == "192.168.1.1"
|
|
|
|
|
|
class TestRateLimiterConfiguration:
|
|
"""Tests for rate limiter configuration."""
|
|
|
|
def test_limiter_uses_get_client_ip_as_key_func(self):
|
|
"""Test that limiter is configured with get_client_ip as key function."""
|
|
from local_deep_research.security.rate_limiter import (
|
|
limiter,
|
|
get_client_ip,
|
|
)
|
|
|
|
assert limiter._key_func == get_client_ip
|
|
|
|
def test_limiter_uses_memory_storage(self):
|
|
"""Test that limiter uses in-memory storage by default."""
|
|
from local_deep_research.security.rate_limiter import limiter
|
|
|
|
# The storage URI should be memory
|
|
assert limiter._storage_uri == "memory://"
|
|
|
|
def test_limiter_has_headers_enabled(self):
|
|
"""Test that rate limit headers are enabled."""
|
|
from local_deep_research.security.rate_limiter import limiter
|
|
|
|
assert limiter._headers_enabled is True
|
|
|
|
|
|
class TestRateLimitConstants:
|
|
"""Tests for rate limit configuration constants."""
|
|
|
|
def test_default_rate_limit_loaded_from_config(self):
|
|
"""Test that DEFAULT_RATE_LIMIT is loaded from server config."""
|
|
from local_deep_research.security.rate_limiter import (
|
|
DEFAULT_RATE_LIMIT,
|
|
)
|
|
|
|
# Should be a string like "X per hour" or similar
|
|
assert isinstance(DEFAULT_RATE_LIMIT, str)
|
|
assert DEFAULT_RATE_LIMIT # Not empty
|
|
|
|
def test_login_rate_limit_loaded_from_config(self):
|
|
"""Test that LOGIN_RATE_LIMIT is loaded from server config."""
|
|
from local_deep_research.security.rate_limiter import LOGIN_RATE_LIMIT
|
|
|
|
assert isinstance(LOGIN_RATE_LIMIT, str)
|
|
assert LOGIN_RATE_LIMIT # Not empty
|
|
|
|
def test_registration_rate_limit_loaded_from_config(self):
|
|
"""Test that REGISTRATION_RATE_LIMIT is loaded from server config."""
|
|
from local_deep_research.security.rate_limiter import (
|
|
REGISTRATION_RATE_LIMIT,
|
|
)
|
|
|
|
assert isinstance(REGISTRATION_RATE_LIMIT, str)
|
|
assert REGISTRATION_RATE_LIMIT # Not empty
|
|
|
|
def test_settings_rate_limit_loaded_from_config(self):
|
|
"""Test that SETTINGS_RATE_LIMIT is loaded from config with default fallback."""
|
|
from local_deep_research.security.rate_limiter import (
|
|
SETTINGS_RATE_LIMIT,
|
|
)
|
|
|
|
assert isinstance(SETTINGS_RATE_LIMIT, str)
|
|
assert SETTINGS_RATE_LIMIT # Not empty
|
|
# Should contain a rate expression like "30 per minute"
|
|
assert "per" in SETTINGS_RATE_LIMIT
|
|
|
|
|
|
class TestSettingsLimit:
|
|
"""Tests for settings_limit shared rate limiter (PR #2021)."""
|
|
|
|
def test_settings_limit_is_shared_limit(self):
|
|
"""Test that settings_limit is a SharedLimitItem from flask-limiter."""
|
|
from local_deep_research.security.rate_limiter import settings_limit
|
|
|
|
# SharedLimitItem has a __call__ method (it's a decorator)
|
|
assert callable(settings_limit)
|
|
|
|
def test_settings_limit_can_decorate_function(self):
|
|
"""Test that settings_limit can be used as a decorator."""
|
|
from local_deep_research.security.rate_limiter import settings_limit
|
|
|
|
@settings_limit
|
|
def dummy_view():
|
|
return "ok"
|
|
|
|
# The decorated function should still be callable
|
|
assert callable(dummy_view)
|
|
|
|
def test_settings_limit_default_value(self):
|
|
"""Test that SETTINGS_RATE_LIMIT defaults to 30 per minute."""
|
|
from local_deep_research.security.rate_limiter import (
|
|
SETTINGS_RATE_LIMIT,
|
|
)
|
|
|
|
# The config.get uses "30 per minute" as default
|
|
# If not set in config, it should be "30 per minute"
|
|
assert isinstance(SETTINGS_RATE_LIMIT, str)
|
|
assert SETTINGS_RATE_LIMIT # Not empty
|
|
|
|
|
|
class TestApiRateLimit:
|
|
"""Tests for api_rate_limit shared limiter."""
|
|
|
|
def test_api_rate_limit_is_callable(self):
|
|
"""Test that api_rate_limit can be used as a decorator."""
|
|
from local_deep_research.security.rate_limiter import api_rate_limit
|
|
|
|
assert callable(api_rate_limit)
|
|
|
|
def test_get_user_api_rate_limit_default(self, app):
|
|
"""Test that _get_user_api_rate_limit returns default when no user."""
|
|
|
|
with app.test_request_context():
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_user_api_rate_limit,
|
|
)
|
|
|
|
result = _get_user_api_rate_limit()
|
|
assert result == 60
|
|
|
|
def test_get_api_rate_limit_string_format(self, app):
|
|
"""Test that _get_api_rate_limit_string returns correct format."""
|
|
from unittest.mock import patch
|
|
|
|
with app.test_request_context():
|
|
with patch(
|
|
"local_deep_research.security.rate_limiter._get_user_api_rate_limit",
|
|
return_value=30,
|
|
):
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_api_rate_limit_string,
|
|
)
|
|
|
|
result = _get_api_rate_limit_string()
|
|
assert result == "30 per minute"
|
|
|
|
def test_is_api_rate_limit_exempt_no_user(self, app):
|
|
"""Test that unauthenticated requests are exempt."""
|
|
with app.test_request_context():
|
|
from local_deep_research.security.rate_limiter import (
|
|
_is_api_rate_limit_exempt,
|
|
)
|
|
|
|
assert _is_api_rate_limit_exempt() is True
|
|
|
|
def test_is_api_rate_limit_exempt_zero_limit(self, app):
|
|
"""Test that rate_limit=0 means exempt."""
|
|
from unittest.mock import patch
|
|
|
|
app.config["SECRET_KEY"] = "test-secret"
|
|
with app.test_request_context():
|
|
from flask import g
|
|
|
|
g.current_user = "testuser"
|
|
|
|
with patch(
|
|
"local_deep_research.security.rate_limiter._get_user_api_rate_limit",
|
|
return_value=0,
|
|
):
|
|
from local_deep_research.security.rate_limiter import (
|
|
_is_api_rate_limit_exempt,
|
|
)
|
|
|
|
assert _is_api_rate_limit_exempt() is True
|
|
|
|
def test_get_api_user_key_with_user(self, app):
|
|
"""Test that key function returns user-prefixed key."""
|
|
with app.test_request_context():
|
|
from flask import g
|
|
|
|
g.current_user = "alice"
|
|
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_api_user_key,
|
|
)
|
|
|
|
result = _get_api_user_key()
|
|
assert result == "api_user:alice"
|
|
|
|
|
|
class TestUploadRateLimit:
|
|
"""Tests for upload_rate_limit_user and upload_rate_limit_ip shared limiters."""
|
|
|
|
def test_upload_rate_limit_user_exists_and_callable(self):
|
|
"""Test that upload_rate_limit_user is defined and callable."""
|
|
from local_deep_research.security.rate_limiter import (
|
|
upload_rate_limit_user,
|
|
)
|
|
|
|
assert upload_rate_limit_user is not None
|
|
assert callable(upload_rate_limit_user)
|
|
|
|
def test_upload_rate_limit_ip_exists_and_callable(self):
|
|
"""Test that upload_rate_limit_ip is defined and callable."""
|
|
from local_deep_research.security.rate_limiter import (
|
|
upload_rate_limit_ip,
|
|
)
|
|
|
|
assert upload_rate_limit_ip is not None
|
|
assert callable(upload_rate_limit_ip)
|
|
|
|
def test_get_upload_user_key_with_user(self, app):
|
|
"""Test that upload key function returns user-prefixed key."""
|
|
with app.test_request_context():
|
|
from flask import g
|
|
|
|
g.current_user = "bob"
|
|
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_upload_user_key,
|
|
)
|
|
|
|
result = _get_upload_user_key()
|
|
assert result == "upload_user:bob"
|
|
|
|
def test_get_upload_user_key_no_user(self, app):
|
|
"""Test that upload key function falls back to IP when no user."""
|
|
with app.test_request_context():
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_upload_user_key,
|
|
)
|
|
|
|
result = _get_upload_user_key()
|
|
assert result.startswith("upload_ip:")
|
|
|
|
|
|
class TestUploadRateLimitFunctional:
|
|
"""Functional tests for dual-key upload rate limiting."""
|
|
|
|
def test_upload_rate_limit_enforces_per_user(
|
|
self, restore_rate_limiter_module
|
|
):
|
|
"""Per-user upload limit blocks after threshold."""
|
|
rate_limiter = _reload_rate_limiter_with_limits(
|
|
user_limit="3 per minute", ip_limit="100 per minute"
|
|
)
|
|
test_app = Flask(__name__)
|
|
test_app.config["SECRET_KEY"] = "test"
|
|
test_app.config["TESTING"] = True
|
|
test_app.config["RATELIMIT_ENABLED"] = True
|
|
test_app.config["RATELIMIT_STRATEGY"] = "moving-window"
|
|
|
|
@test_app.route("/upload", methods=["POST"])
|
|
@rate_limiter.upload_rate_limit_user
|
|
@rate_limiter.upload_rate_limit_ip
|
|
def upload():
|
|
return "ok"
|
|
|
|
rate_limiter.limiter.init_app(test_app)
|
|
|
|
with test_app.test_client() as c:
|
|
with c.session_transaction() as sess:
|
|
sess["username"] = "uploader"
|
|
# Per-user limit is "3 per minute" — first 3 pass, 4th is blocked
|
|
for i in range(3):
|
|
resp = c.post("/upload")
|
|
assert resp.status_code == 200, f"Request {i + 1} should pass"
|
|
resp = c.post("/upload")
|
|
assert resp.status_code == 429
|
|
|
|
def test_upload_per_user_limit_is_independent(
|
|
self, restore_rate_limiter_module
|
|
):
|
|
"""Per-user upload bucket is keyed by username, not shared."""
|
|
rate_limiter = _reload_rate_limiter_with_limits(
|
|
user_limit="3 per minute", ip_limit="100 per minute"
|
|
)
|
|
test_app = Flask(__name__)
|
|
test_app.config["SECRET_KEY"] = "test"
|
|
test_app.config["TESTING"] = True
|
|
test_app.config["RATELIMIT_ENABLED"] = True
|
|
test_app.config["RATELIMIT_STRATEGY"] = "moving-window"
|
|
|
|
# Only per-user limit (no per-IP) to isolate user-key behavior
|
|
@test_app.route("/upload", methods=["POST"])
|
|
@rate_limiter.upload_rate_limit_user
|
|
def upload():
|
|
return "ok"
|
|
|
|
rate_limiter.limiter.init_app(test_app)
|
|
|
|
# User A exhausts their per-user limit
|
|
client_a = test_app.test_client()
|
|
with client_a.session_transaction() as sess:
|
|
sess["username"] = "user_a"
|
|
for _ in range(3):
|
|
client_a.post("/upload")
|
|
assert client_a.post("/upload").status_code == 429
|
|
|
|
# User B is unaffected (different user bucket)
|
|
client_b = test_app.test_client()
|
|
with client_b.session_transaction() as sess:
|
|
sess["username"] = "user_b"
|
|
assert client_b.post("/upload").status_code == 200
|
|
|
|
def test_upload_rate_limit_respects_env_var_override(
|
|
self, restore_rate_limiter_module
|
|
):
|
|
"""End-to-end: patched config flows through to decorator binding.
|
|
|
|
Reloading rate_limiter after patching load_server_config simulates
|
|
the env-var-set-at-process-start scenario without relying on a real
|
|
env var (the module captures _config at import time).
|
|
"""
|
|
rate_limiter = _reload_rate_limiter_with_limits(
|
|
user_limit="2 per minute", ip_limit="2 per minute"
|
|
)
|
|
assert rate_limiter._UPLOAD_RATE_LIMIT_USER == "2 per minute"
|
|
assert rate_limiter._UPLOAD_RATE_LIMIT_IP == "2 per minute"
|
|
|
|
test_app = Flask(__name__)
|
|
test_app.config["SECRET_KEY"] = "test"
|
|
test_app.config["TESTING"] = True
|
|
test_app.config["RATELIMIT_ENABLED"] = True
|
|
test_app.config["RATELIMIT_STRATEGY"] = "moving-window"
|
|
|
|
@test_app.route("/upload", methods=["POST"])
|
|
@rate_limiter.upload_rate_limit_user
|
|
@rate_limiter.upload_rate_limit_ip
|
|
def upload():
|
|
return "ok"
|
|
|
|
rate_limiter.limiter.init_app(test_app)
|
|
|
|
with test_app.test_client() as c:
|
|
with c.session_transaction() as sess:
|
|
sess["username"] = "envtest"
|
|
assert c.post("/upload").status_code == 200
|
|
assert c.post("/upload").status_code == 200
|
|
assert c.post("/upload").status_code == 429
|
|
|
|
|
|
class TestApiRateLimitCaching:
|
|
"""Tests for g-caching and DB fallback in _get_user_api_rate_limit."""
|
|
|
|
def test_g_cache_hit_skips_db(self, app):
|
|
"""Second call returns cached value without DB access."""
|
|
with app.test_request_context():
|
|
from flask import g
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_user_api_rate_limit,
|
|
)
|
|
|
|
g._api_rate_limit = 42
|
|
result = _get_user_api_rate_limit()
|
|
assert result == 42
|
|
|
|
def test_db_exception_falls_back_to_default(self, app):
|
|
"""DB failure returns default 60 without raising."""
|
|
app.config["SECRET_KEY"] = "test"
|
|
with app.test_request_context():
|
|
from flask import g
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_user_api_rate_limit,
|
|
)
|
|
|
|
g.current_user = "testuser"
|
|
with patch(
|
|
"local_deep_research.database.session_context.get_user_db_session",
|
|
side_effect=RuntimeError("DB down"),
|
|
):
|
|
result = _get_user_api_rate_limit()
|
|
assert result == 60
|
|
|
|
def test_custom_rate_limit_from_db(self, app):
|
|
"""User-configured rate limit is returned from DB."""
|
|
app.config["SECRET_KEY"] = "test"
|
|
with app.test_request_context():
|
|
from flask import g
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_user_api_rate_limit,
|
|
)
|
|
|
|
g.current_user = "testuser"
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_setting.return_value = 30
|
|
|
|
mock_session = MagicMock()
|
|
with patch(
|
|
"local_deep_research.database.session_context.get_user_db_session"
|
|
) as mock_ctx:
|
|
mock_ctx.return_value.__enter__ = MagicMock(
|
|
return_value=mock_session
|
|
)
|
|
mock_ctx.return_value.__exit__ = MagicMock(return_value=None)
|
|
with patch(
|
|
"local_deep_research.utilities.db_utils.get_settings_manager",
|
|
return_value=mock_sm,
|
|
):
|
|
result = _get_user_api_rate_limit()
|
|
assert result == 30
|
|
assert g._api_rate_limit == 30
|
|
|
|
|
|
class TestSessionFallback:
|
|
"""Tests for session fallback when g.current_user is not set."""
|
|
|
|
def test_api_user_key_uses_session_fallback(self, app):
|
|
"""Key function uses session username when g.current_user absent."""
|
|
app.config["SECRET_KEY"] = "test"
|
|
with app.test_request_context():
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_api_user_key,
|
|
)
|
|
|
|
with app.test_client() as c:
|
|
with c.session_transaction() as sess:
|
|
sess["username"] = "session_user"
|
|
with c.application.test_request_context():
|
|
from flask import session as s
|
|
|
|
s["username"] = "session_user"
|
|
result = _get_api_user_key()
|
|
assert result == "api_user:session_user"
|
|
|
|
def test_upload_user_key_uses_session_fallback(self, app):
|
|
"""Upload key function uses session username when g.current_user absent."""
|
|
app.config["SECRET_KEY"] = "test"
|
|
with app.test_request_context():
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_upload_user_key,
|
|
)
|
|
|
|
with app.test_client() as c:
|
|
with c.application.test_request_context():
|
|
from flask import session as s
|
|
|
|
s["username"] = "session_uploader"
|
|
result = _get_upload_user_key()
|
|
assert result == "upload_user:session_uploader"
|
|
|
|
def test_g_current_user_takes_priority_over_session(self, app):
|
|
"""g.current_user is preferred over session username."""
|
|
app.config["SECRET_KEY"] = "test"
|
|
with app.test_request_context():
|
|
from flask import g, session
|
|
from local_deep_research.security.rate_limiter import (
|
|
_get_api_user_key,
|
|
)
|
|
|
|
g.current_user = "g_user"
|
|
session["username"] = "session_user"
|
|
result = _get_api_user_key()
|
|
assert result == "api_user:g_user"
|
|
|
|
def test_exempt_uses_session_fallback(self, app):
|
|
"""Exempt check uses session username when g.current_user absent."""
|
|
app.config["SECRET_KEY"] = "test"
|
|
with app.test_request_context():
|
|
from flask import session
|
|
from local_deep_research.security.rate_limiter import (
|
|
_is_api_rate_limit_exempt,
|
|
)
|
|
|
|
# No g.current_user, no session username → exempt
|
|
assert _is_api_rate_limit_exempt() is True
|
|
|
|
# Set session username → not exempt (has user, default limit > 0)
|
|
session["username"] = "session_user"
|
|
with patch(
|
|
"local_deep_research.security.rate_limiter._get_user_api_rate_limit",
|
|
return_value=60,
|
|
):
|
|
assert _is_api_rate_limit_exempt() is False
|
|
|
|
|
|
class TestGetCurrentUsername:
|
|
"""Tests for get_current_username helper."""
|
|
|
|
def test_returns_g_current_user(self, app):
|
|
with app.test_request_context():
|
|
from flask import g
|
|
from local_deep_research.security.rate_limiter import (
|
|
get_current_username,
|
|
)
|
|
|
|
g.current_user = "alice"
|
|
assert get_current_username() == "alice"
|
|
|
|
def test_returns_session_when_no_g_current_user(self, app):
|
|
app.config["SECRET_KEY"] = "test"
|
|
with app.test_request_context():
|
|
from flask import session
|
|
from local_deep_research.security.rate_limiter import (
|
|
get_current_username,
|
|
)
|
|
|
|
session["username"] = "bob"
|
|
assert get_current_username() == "bob"
|
|
|
|
def test_returns_none_when_neither_set(self, app):
|
|
with app.test_request_context():
|
|
from local_deep_research.security.rate_limiter import (
|
|
get_current_username,
|
|
)
|
|
|
|
assert get_current_username() is None
|
|
|
|
def test_g_current_user_priority_over_session(self, app):
|
|
app.config["SECRET_KEY"] = "test"
|
|
with app.test_request_context():
|
|
from flask import g, session
|
|
from local_deep_research.security.rate_limiter import (
|
|
get_current_username,
|
|
)
|
|
|
|
g.current_user = "g_user"
|
|
session["username"] = "session_user"
|
|
assert get_current_username() == "g_user"
|
|
|
|
def test_empty_string_g_current_user_falls_to_session(self, app):
|
|
"""Empty string g.current_user is falsy — should fall through to session."""
|
|
app.config["SECRET_KEY"] = "test"
|
|
with app.test_request_context():
|
|
from flask import g, session
|
|
from local_deep_research.security.rate_limiter import (
|
|
get_current_username,
|
|
)
|
|
|
|
g.current_user = ""
|
|
session["username"] = "session_user"
|
|
assert get_current_username() == "session_user"
|