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
145 lines
5.0 KiB
Python
145 lines
5.0 KiB
Python
"""Tests for per-user account lockout."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import patch
|
|
|
|
from local_deep_research.security.account_lockout import AccountLockoutManager
|
|
|
|
|
|
class TestAccountLockout:
|
|
"""Unit tests for AccountLockoutManager."""
|
|
|
|
def _make_manager(self, threshold=3, lockout_minutes=15):
|
|
return AccountLockoutManager(
|
|
threshold=threshold, lockout_minutes=lockout_minutes
|
|
)
|
|
|
|
def test_not_locked_initially(self):
|
|
mgr = self._make_manager()
|
|
assert mgr.is_locked("alice") is False
|
|
|
|
def test_not_locked_below_threshold(self):
|
|
mgr = self._make_manager(threshold=3)
|
|
mgr.record_failure("alice")
|
|
mgr.record_failure("alice")
|
|
assert mgr.is_locked("alice") is False
|
|
|
|
def test_locked_at_threshold(self):
|
|
mgr = self._make_manager(threshold=3)
|
|
for _ in range(3):
|
|
mgr.record_failure("alice")
|
|
assert mgr.is_locked("alice") is True
|
|
|
|
def test_success_resets_lockout(self):
|
|
mgr = self._make_manager(threshold=3)
|
|
for _ in range(3):
|
|
mgr.record_failure("alice")
|
|
assert mgr.is_locked("alice") is True
|
|
mgr.record_success("alice")
|
|
assert mgr.is_locked("alice") is False
|
|
|
|
def test_counter_resets_after_success(self):
|
|
mgr = self._make_manager(threshold=10)
|
|
for _ in range(9):
|
|
mgr.record_failure("alice")
|
|
mgr.record_success("alice")
|
|
# After reset, 9 more failures should not lock (threshold is 10)
|
|
for _ in range(9):
|
|
mgr.record_failure("alice")
|
|
assert mgr.is_locked("alice") is False
|
|
|
|
def test_independent_users(self):
|
|
mgr = self._make_manager(threshold=3)
|
|
for _ in range(3):
|
|
mgr.record_failure("alice")
|
|
assert mgr.is_locked("alice") is True
|
|
assert mgr.is_locked("bob") is False
|
|
|
|
def test_success_on_nonexistent_user_is_noop(self):
|
|
mgr = self._make_manager()
|
|
# Should not raise
|
|
mgr.record_success("nonexistent")
|
|
assert mgr.is_locked("nonexistent") is False
|
|
|
|
def test_lockout_expires_after_duration(self):
|
|
mgr = self._make_manager(threshold=3, lockout_minutes=15)
|
|
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
with patch(
|
|
"local_deep_research.security.account_lockout.datetime"
|
|
) as mock_dt:
|
|
mock_dt.now.return_value = now
|
|
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
|
for _ in range(3):
|
|
mgr.record_failure("alice")
|
|
|
|
# Still locked 14 minutes later
|
|
with patch(
|
|
"local_deep_research.security.account_lockout.datetime"
|
|
) as mock_dt:
|
|
mock_dt.now.return_value = now + timedelta(minutes=14)
|
|
assert mgr.is_locked("alice") is True
|
|
|
|
# Unlocked at exactly 15 minutes
|
|
with patch(
|
|
"local_deep_research.security.account_lockout.datetime"
|
|
) as mock_dt:
|
|
mock_dt.now.return_value = now + timedelta(minutes=15)
|
|
assert mgr.is_locked("alice") is False
|
|
|
|
def test_eviction_removes_unlocked_entries(self):
|
|
"""Eviction should remove unlocked/expired entries, not blanket clear."""
|
|
mgr = self._make_manager(threshold=5)
|
|
mgr._MAX_STATE_ENTRIES = 100
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Add unlocked entries (count < threshold, no locked_until)
|
|
for i in range(90):
|
|
mgr._state[f"unlocked_{i}"] = {"count": 1, "locked_until": None}
|
|
|
|
# Add expired locked entries
|
|
for i in range(10):
|
|
mgr._state[f"expired_{i}"] = {
|
|
"count": 5,
|
|
"locked_until": now - timedelta(minutes=1),
|
|
}
|
|
|
|
# Add actively locked entries
|
|
for i in range(5):
|
|
mgr._state[f"locked_{i}"] = {
|
|
"count": 5,
|
|
"locked_until": now + timedelta(minutes=10),
|
|
}
|
|
|
|
assert len(mgr._state) == 105
|
|
|
|
# Trigger eviction via record_failure
|
|
mgr.record_failure("attacker")
|
|
|
|
# Actively locked entries should survive
|
|
for i in range(5):
|
|
assert f"locked_{i}" in mgr._state
|
|
# New entry should exist
|
|
assert "attacker" in mgr._state
|
|
# Unlocked/expired entries should be gone
|
|
assert not any(k.startswith("unlocked_") for k in mgr._state)
|
|
assert not any(k.startswith("expired_") for k in mgr._state)
|
|
|
|
def test_blanket_clear_as_last_resort(self):
|
|
"""If eviction can't reduce below limit, blanket clear."""
|
|
mgr = self._make_manager(threshold=3)
|
|
mgr._MAX_STATE_ENTRIES = 10
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Fill with actively locked entries (can't be evicted)
|
|
for i in range(15):
|
|
mgr._state[f"locked_{i}"] = {
|
|
"count": 5,
|
|
"locked_until": now + timedelta(hours=1),
|
|
}
|
|
|
|
mgr.record_failure("attacker")
|
|
# Should have blanket-cleared then added new entry
|
|
assert len(mgr._state) == 1
|
|
assert "attacker" in mgr._state
|