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
735 lines
24 KiB
Python
735 lines
24 KiB
Python
"""Comprehensive tests for auth-related pure logic.
|
|
|
|
Covers:
|
|
- PasswordValidator (strength validation, requirements)
|
|
- AccountLockoutManager (lock/unlock, threshold, expiry, eviction)
|
|
- URLValidator (safe URLs, redirect validation, scheme checks, DOI, sanitize)
|
|
- sanitize_for_log / strip_control_chars
|
|
- SessionManager (create, validate, expire, destroy, cleanup, user queries)
|
|
"""
|
|
|
|
import datetime
|
|
import time
|
|
from datetime import UTC, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.security.account_lockout import AccountLockoutManager
|
|
from local_deep_research.security.log_sanitizer import (
|
|
sanitize_for_log,
|
|
strip_control_chars,
|
|
)
|
|
from local_deep_research.security.password_validator import PasswordValidator
|
|
from local_deep_research.security.url_validator import (
|
|
URLValidationError,
|
|
URLValidator,
|
|
)
|
|
from local_deep_research.web.auth.session_manager import SessionManager
|
|
|
|
|
|
# ======================================================================
|
|
# PasswordValidator
|
|
# ======================================================================
|
|
|
|
|
|
class TestPasswordValidator:
|
|
"""Tests for PasswordValidator.validate_strength and get_requirements."""
|
|
|
|
def test_valid_password(self):
|
|
assert PasswordValidator.validate_strength("securepass1") == []
|
|
|
|
def test_too_short(self):
|
|
errors = PasswordValidator.validate_strength("abc1")
|
|
assert any("at least 8 characters" in e for e in errors)
|
|
|
|
def test_no_lowercase(self):
|
|
errors = PasswordValidator.validate_strength("ABCDEFG1")
|
|
assert any("lowercase" in e for e in errors)
|
|
|
|
def test_no_digit(self):
|
|
errors = PasswordValidator.validate_strength("abcdefgh")
|
|
assert any("digit" in e for e in errors)
|
|
|
|
def test_empty_password(self):
|
|
errors = PasswordValidator.validate_strength("")
|
|
assert (
|
|
len(errors) >= 2
|
|
) # too short + no lowercase + no digit (at least 2)
|
|
|
|
def test_all_failures(self):
|
|
errors = PasswordValidator.validate_strength("AB")
|
|
assert len(errors) == 3
|
|
|
|
def test_exactly_8_chars_valid(self):
|
|
assert PasswordValidator.validate_strength("abcdefg1") == []
|
|
|
|
def test_requirements_returns_list(self):
|
|
reqs = PasswordValidator.get_requirements()
|
|
assert isinstance(reqs, list)
|
|
assert len(reqs) >= 3
|
|
|
|
def test_requirements_mention_length(self):
|
|
reqs = PasswordValidator.get_requirements()
|
|
assert any("8" in r for r in reqs)
|
|
|
|
def test_unicode_letters_do_not_count_as_lowercase(self):
|
|
# Only ASCII lowercase a-z counts
|
|
errors = PasswordValidator.validate_strength("ÉÈÜÖ12345678")
|
|
assert any("lowercase" in e for e in errors)
|
|
|
|
def test_digit_anywhere(self):
|
|
assert PasswordValidator.validate_strength("aaaaaaaa1") == []
|
|
assert PasswordValidator.validate_strength("1aaaaaaaa") == []
|
|
|
|
def test_special_chars_only(self):
|
|
errors = PasswordValidator.validate_strength("!@#$%^&*")
|
|
assert any("lowercase" in e for e in errors)
|
|
assert any("digit" in e for e in errors)
|
|
|
|
|
|
# ======================================================================
|
|
# AccountLockoutManager
|
|
# ======================================================================
|
|
|
|
|
|
class TestAccountLockoutManager:
|
|
"""Tests for AccountLockoutManager lock/unlock/eviction logic."""
|
|
|
|
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_lock_after_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_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_success_clears_failures(self):
|
|
mgr = self._make_manager(threshold=3)
|
|
mgr.record_failure("alice")
|
|
mgr.record_failure("alice")
|
|
mgr.record_success("alice")
|
|
# After clearing, need 3 more to lock
|
|
mgr.record_failure("alice")
|
|
mgr.record_failure("alice")
|
|
assert mgr.is_locked("alice") is False
|
|
|
|
def test_success_unlocks(self):
|
|
mgr = self._make_manager(threshold=2)
|
|
mgr.record_failure("alice")
|
|
mgr.record_failure("alice")
|
|
assert mgr.is_locked("alice") is True
|
|
mgr.record_success("alice")
|
|
assert mgr.is_locked("alice") is False
|
|
|
|
def test_lockout_expires(self):
|
|
mgr = self._make_manager(threshold=1, lockout_minutes=0)
|
|
mgr.record_failure("alice")
|
|
# lockout_minutes=0 means the lock is already in the past
|
|
# Force the lock time to past
|
|
with mgr._lock:
|
|
entry = mgr._state.get("alice")
|
|
if entry and entry.get("locked_until"):
|
|
entry["locked_until"] = datetime.datetime.now(
|
|
timezone.utc
|
|
) - timedelta(seconds=1)
|
|
assert mgr.is_locked("alice") is False
|
|
|
|
def test_separate_users_independent(self):
|
|
mgr = self._make_manager(threshold=2)
|
|
mgr.record_failure("alice")
|
|
mgr.record_failure("alice")
|
|
assert mgr.is_locked("alice") is True
|
|
assert mgr.is_locked("bob") is False
|
|
|
|
def test_record_success_on_unknown_user(self):
|
|
mgr = self._make_manager()
|
|
# Should not raise
|
|
mgr.record_success("nobody")
|
|
assert mgr.is_locked("nobody") is False
|
|
|
|
def test_eviction_clears_expired(self):
|
|
mgr = self._make_manager(threshold=1, lockout_minutes=0)
|
|
# Add many entries, all expired
|
|
for i in range(100):
|
|
mgr.record_failure(f"user_{i}")
|
|
# Force all locked_until to past
|
|
with mgr._lock:
|
|
for entry in mgr._state.values():
|
|
if entry.get("locked_until"):
|
|
entry["locked_until"] = datetime.datetime.now(
|
|
timezone.utc
|
|
) - timedelta(seconds=10)
|
|
# Trigger eviction by exceeding _MAX_STATE_ENTRIES
|
|
mgr._MAX_STATE_ENTRIES = 50 # lower threshold for test
|
|
mgr.record_failure("trigger_evict")
|
|
# After eviction, expired entries should be gone
|
|
with mgr._lock:
|
|
# Only the trigger entry (or fewer) should remain
|
|
assert len(mgr._state) <= 2
|
|
|
|
def test_threshold_exactly_at_boundary(self):
|
|
mgr = self._make_manager(threshold=5)
|
|
for _ in range(4):
|
|
mgr.record_failure("alice")
|
|
assert mgr.is_locked("alice") is False
|
|
mgr.record_failure("alice") # 5th attempt
|
|
assert mgr.is_locked("alice") is True
|
|
|
|
|
|
# ======================================================================
|
|
# URLValidator
|
|
# ======================================================================
|
|
|
|
|
|
class TestURLValidator:
|
|
"""Tests for URLValidator static methods."""
|
|
|
|
# --- is_unsafe_scheme ---
|
|
|
|
def test_unsafe_javascript(self):
|
|
assert URLValidator.is_unsafe_scheme("javascript:alert(1)") is True
|
|
|
|
def test_unsafe_data(self):
|
|
assert (
|
|
URLValidator.is_unsafe_scheme("data:text/html,<h1>hi</h1>") is True
|
|
)
|
|
|
|
def test_unsafe_vbscript(self):
|
|
assert URLValidator.is_unsafe_scheme("vbscript:MsgBox") is True
|
|
|
|
def test_safe_https(self):
|
|
assert URLValidator.is_unsafe_scheme("https://example.com") is False
|
|
|
|
def test_empty_not_unsafe(self):
|
|
assert URLValidator.is_unsafe_scheme("") is False
|
|
|
|
def test_case_insensitive_scheme(self):
|
|
assert URLValidator.is_unsafe_scheme("JAVASCRIPT:void(0)") is True
|
|
|
|
# --- is_safe_url ---
|
|
|
|
def test_safe_https_url(self):
|
|
assert URLValidator.is_safe_url("https://example.com") is True
|
|
|
|
def test_safe_http_url(self):
|
|
assert URLValidator.is_safe_url("http://example.com") is True
|
|
|
|
def test_ftp_is_safe(self):
|
|
assert URLValidator.is_safe_url("ftp://files.example.com") is True
|
|
|
|
def test_empty_not_safe(self):
|
|
assert URLValidator.is_safe_url("") is False
|
|
|
|
def test_none_not_safe(self):
|
|
assert URLValidator.is_safe_url(None) is False
|
|
|
|
def test_fragment_only_allowed(self):
|
|
assert URLValidator.is_safe_url("#section") is True
|
|
|
|
def test_fragment_only_disallowed(self):
|
|
assert (
|
|
URLValidator.is_safe_url("#section", allow_fragments=False) is False
|
|
)
|
|
|
|
def test_mailto_default_disallowed(self):
|
|
assert URLValidator.is_safe_url("mailto:user@example.com") is False
|
|
|
|
def test_mailto_allowed(self):
|
|
assert (
|
|
URLValidator.is_safe_url(
|
|
"mailto:user@example.com", allow_mailto=True
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_no_scheme_requires_scheme(self):
|
|
assert (
|
|
URLValidator.is_safe_url("example.com", require_scheme=True)
|
|
is False
|
|
)
|
|
|
|
def test_no_scheme_not_required(self):
|
|
assert (
|
|
URLValidator.is_safe_url("example.com", require_scheme=False)
|
|
is True
|
|
)
|
|
|
|
def test_trusted_domains(self):
|
|
assert (
|
|
URLValidator.is_safe_url(
|
|
"https://evil.com", trusted_domains=["example.com"]
|
|
)
|
|
is False
|
|
)
|
|
assert (
|
|
URLValidator.is_safe_url(
|
|
"https://example.com", trusted_domains=["example.com"]
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_subdomain_trusted(self):
|
|
assert (
|
|
URLValidator.is_safe_url(
|
|
"https://sub.example.com", trusted_domains=["example.com"]
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_double_encoding_suspicious(self):
|
|
# %2520 = double-encoded space
|
|
assert (
|
|
URLValidator.is_safe_url("https://example.com/%2520path") is False
|
|
)
|
|
|
|
def test_null_byte_suspicious(self):
|
|
assert URLValidator.is_safe_url("https://example.com/%00path") is False
|
|
|
|
# --- sanitize_url ---
|
|
|
|
def test_sanitize_adds_scheme(self):
|
|
result = URLValidator.sanitize_url("example.com")
|
|
assert result == "https://example.com"
|
|
|
|
def test_sanitize_preserves_https(self):
|
|
result = URLValidator.sanitize_url("https://example.com/path")
|
|
assert result == "https://example.com/path"
|
|
|
|
def test_sanitize_rejects_javascript(self):
|
|
assert URLValidator.sanitize_url("javascript:alert(1)") is None
|
|
|
|
def test_sanitize_empty(self):
|
|
assert URLValidator.sanitize_url("") is None
|
|
|
|
def test_sanitize_custom_default_scheme(self):
|
|
result = URLValidator.sanitize_url("example.com", default_scheme="http")
|
|
assert result is not None
|
|
assert result.startswith("http://")
|
|
|
|
# --- is_academic_url ---
|
|
|
|
def test_academic_arxiv(self):
|
|
assert (
|
|
URLValidator.is_academic_url("https://arxiv.org/abs/1234") is True
|
|
)
|
|
|
|
def test_academic_doi(self):
|
|
assert (
|
|
URLValidator.is_academic_url("https://doi.org/10.1234/test") is True
|
|
)
|
|
|
|
def test_academic_subdomain(self):
|
|
assert (
|
|
URLValidator.is_academic_url("https://www.nature.com/articles/123")
|
|
is True
|
|
)
|
|
|
|
def test_not_academic(self):
|
|
assert URLValidator.is_academic_url("https://google.com") is False
|
|
|
|
def test_academic_empty(self):
|
|
assert URLValidator.is_academic_url("") is False
|
|
|
|
# --- extract_doi ---
|
|
|
|
def test_extract_doi_from_doi_url(self):
|
|
doi = URLValidator.extract_doi("https://doi.org/10.1234/test.5678")
|
|
assert doi is not None
|
|
assert doi.startswith("10.1234")
|
|
|
|
def test_extract_doi_direct(self):
|
|
doi = URLValidator.extract_doi("10.1000/xyz123")
|
|
assert doi is not None
|
|
|
|
def test_extract_doi_none(self):
|
|
assert URLValidator.extract_doi("https://example.com") is None
|
|
|
|
# --- validate_http_url ---
|
|
|
|
def test_validate_http_url_valid(self):
|
|
assert (
|
|
URLValidator.validate_http_url("https://example.com/path") is True
|
|
)
|
|
|
|
def test_validate_http_url_no_scheme(self):
|
|
with pytest.raises(URLValidationError):
|
|
URLValidator.validate_http_url("example.com")
|
|
|
|
def test_validate_http_url_ftp_rejected(self):
|
|
with pytest.raises(URLValidationError):
|
|
URLValidator.validate_http_url("ftp://example.com")
|
|
|
|
def test_validate_http_url_empty(self):
|
|
with pytest.raises(URLValidationError):
|
|
URLValidator.validate_http_url("")
|
|
|
|
def test_validate_http_url_none(self):
|
|
with pytest.raises(URLValidationError):
|
|
URLValidator.validate_http_url(None)
|
|
|
|
# --- is_safe_redirect_url ---
|
|
|
|
def test_redirect_relative_safe(self):
|
|
assert (
|
|
URLValidator.is_safe_redirect_url(
|
|
"/dashboard", "http://localhost:5000/"
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_redirect_same_host_safe(self):
|
|
assert (
|
|
URLValidator.is_safe_redirect_url(
|
|
"http://localhost:5000/page", "http://localhost:5000/"
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_redirect_external_unsafe(self):
|
|
assert (
|
|
URLValidator.is_safe_redirect_url(
|
|
"http://evil.com/steal", "http://localhost:5000/"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_redirect_empty(self):
|
|
assert (
|
|
URLValidator.is_safe_redirect_url("", "http://localhost:5000/")
|
|
is False
|
|
)
|
|
|
|
def test_redirect_crlf_injection(self):
|
|
assert (
|
|
URLValidator.is_safe_redirect_url(
|
|
"/page\r\nSet-Cookie: evil=1", "http://localhost:5000/"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_redirect_protocol_relative(self):
|
|
assert (
|
|
URLValidator.is_safe_redirect_url(
|
|
"//evil.com/steal", "http://localhost:5000/"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_redirect_backslash_bypass(self):
|
|
assert (
|
|
URLValidator.is_safe_redirect_url(
|
|
"\\evil.com", "http://localhost:5000/"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_redirect_null_byte(self):
|
|
assert (
|
|
URLValidator.is_safe_redirect_url(
|
|
"/page\x00evil", "http://localhost:5000/"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_redirect_path_traversal(self):
|
|
assert (
|
|
URLValidator.is_safe_redirect_url(
|
|
"/../etc/passwd", "http://localhost:5000/"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_redirect_encoded_crlf(self):
|
|
# %0d%0a encoded
|
|
assert (
|
|
URLValidator.is_safe_redirect_url(
|
|
"/page%0d%0aSet-Cookie: x=1", "http://localhost:5000/"
|
|
)
|
|
is False
|
|
)
|
|
|
|
# --- get_safe_redirect_path ---
|
|
|
|
def test_safe_redirect_path_valid(self):
|
|
path = URLValidator.get_safe_redirect_path(
|
|
"/dashboard", "http://localhost:5000/"
|
|
)
|
|
assert path == "/dashboard"
|
|
|
|
def test_safe_redirect_path_with_query(self):
|
|
path = URLValidator.get_safe_redirect_path(
|
|
"/page?tab=settings", "http://localhost:5000/"
|
|
)
|
|
assert path is not None
|
|
assert "tab=settings" in path
|
|
|
|
def test_safe_redirect_path_unsafe(self):
|
|
path = URLValidator.get_safe_redirect_path(
|
|
"http://evil.com/steal", "http://localhost:5000/"
|
|
)
|
|
assert path is None
|
|
|
|
def test_safe_redirect_path_empty(self):
|
|
assert (
|
|
URLValidator.get_safe_redirect_path("", "http://localhost:5000/")
|
|
is None
|
|
)
|
|
|
|
|
|
# ======================================================================
|
|
# Log Sanitizer
|
|
# ======================================================================
|
|
|
|
|
|
class TestLogSanitizer:
|
|
"""Tests for sanitize_for_log and strip_control_chars."""
|
|
|
|
def test_strip_null_byte(self):
|
|
assert strip_control_chars("hello\x00world") == "helloworld"
|
|
|
|
def test_strip_newlines(self):
|
|
assert strip_control_chars("line1\nline2\r") == "line1line2"
|
|
|
|
def test_strip_tabs(self):
|
|
assert strip_control_chars("col1\tcol2") == "col1col2"
|
|
|
|
def test_preserve_normal_ascii(self):
|
|
s = "Hello World 123 !@#"
|
|
assert strip_control_chars(s) == s
|
|
|
|
def test_preserve_unicode_letters(self):
|
|
s = "Héllo Wörld 日本語"
|
|
assert strip_control_chars(s) == s
|
|
|
|
def test_strip_zero_width_chars(self):
|
|
assert strip_control_chars("a\u200bb\u200fc") == "abc"
|
|
|
|
def test_strip_bom(self):
|
|
assert strip_control_chars("\ufeffHello") == "Hello"
|
|
|
|
def test_strip_rlo(self):
|
|
# Right-to-left override
|
|
assert strip_control_chars("test\u202eevil") == "testevil"
|
|
|
|
def test_sanitize_truncates(self):
|
|
long_input = "a" * 100
|
|
result = sanitize_for_log(long_input, max_length=50)
|
|
assert len(result) == 50
|
|
assert result.endswith("...")
|
|
|
|
def test_sanitize_short_input(self):
|
|
result = sanitize_for_log("hello", max_length=50)
|
|
assert result == "hello"
|
|
|
|
def test_sanitize_exact_length(self):
|
|
s = "a" * 50
|
|
result = sanitize_for_log(s, max_length=50)
|
|
assert result == s
|
|
|
|
def test_sanitize_strips_and_truncates(self):
|
|
# Control chars stripped first, then truncated
|
|
s = "\x00" + "b" * 100
|
|
result = sanitize_for_log(s, max_length=20)
|
|
assert "\x00" not in result
|
|
assert len(result) == 20
|
|
|
|
def test_sanitize_very_small_max_length(self):
|
|
result = sanitize_for_log("abcdef", max_length=3)
|
|
assert len(result) == 3
|
|
|
|
def test_sanitize_default_max_length(self):
|
|
long_input = "a" * 200
|
|
result = sanitize_for_log(long_input)
|
|
assert len(result) == 50 # default is 50
|
|
|
|
|
|
# ======================================================================
|
|
# SessionManager
|
|
# ======================================================================
|
|
|
|
|
|
class TestSessionManager:
|
|
"""Tests for SessionManager create/validate/destroy/cleanup logic."""
|
|
|
|
def _make_manager(self, session_hours=2, remember_days=30):
|
|
"""Create a SessionManager with controlled timeouts."""
|
|
mgr = SessionManager.__new__(SessionManager)
|
|
import threading
|
|
|
|
mgr.sessions = {}
|
|
mgr._lock = threading.Lock()
|
|
mgr.session_timeout = timedelta(hours=session_hours)
|
|
mgr.remember_me_timeout = timedelta(days=remember_days)
|
|
return mgr
|
|
|
|
def test_create_session_returns_string(self):
|
|
mgr = self._make_manager()
|
|
sid = mgr.create_session("alice")
|
|
assert isinstance(sid, str)
|
|
assert len(sid) > 20 # token_urlsafe(32) is ~43 chars
|
|
|
|
def test_create_session_unique(self):
|
|
mgr = self._make_manager()
|
|
s1 = mgr.create_session("alice")
|
|
s2 = mgr.create_session("alice")
|
|
assert s1 != s2
|
|
|
|
def test_validate_returns_username(self):
|
|
mgr = self._make_manager()
|
|
sid = mgr.create_session("alice")
|
|
assert mgr.validate_session(sid) == "alice"
|
|
|
|
def test_validate_unknown_session(self):
|
|
mgr = self._make_manager()
|
|
assert mgr.validate_session("nonexistent") is None
|
|
|
|
def test_validate_updates_last_access(self):
|
|
mgr = self._make_manager()
|
|
sid = mgr.create_session("alice")
|
|
first_access = mgr.sessions[sid]["last_access"]
|
|
time.sleep(0.01)
|
|
mgr.validate_session(sid)
|
|
assert mgr.sessions[sid]["last_access"] > first_access
|
|
|
|
def test_expired_session_returns_none(self):
|
|
mgr = self._make_manager(session_hours=1)
|
|
sid = mgr.create_session("alice")
|
|
# Force session to be expired
|
|
with mgr._lock:
|
|
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
|
|
UTC
|
|
) - timedelta(hours=2)
|
|
assert mgr.validate_session(sid) is None
|
|
# Session should be removed
|
|
assert sid not in mgr.sessions
|
|
|
|
def test_remember_me_uses_extended_timeout(self):
|
|
mgr = self._make_manager(session_hours=1, remember_days=30)
|
|
sid = mgr.create_session("alice", remember_me=True)
|
|
# Set last_access to 2 hours ago (would expire normal session)
|
|
with mgr._lock:
|
|
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
|
|
UTC
|
|
) - timedelta(hours=2)
|
|
# Should still be valid with remember_me
|
|
assert mgr.validate_session(sid) == "alice"
|
|
|
|
def test_remember_me_expires_eventually(self):
|
|
mgr = self._make_manager(session_hours=1, remember_days=30)
|
|
sid = mgr.create_session("alice", remember_me=True)
|
|
with mgr._lock:
|
|
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
|
|
UTC
|
|
) - timedelta(days=31)
|
|
assert mgr.validate_session(sid) is None
|
|
|
|
def test_destroy_session(self):
|
|
mgr = self._make_manager()
|
|
sid = mgr.create_session("alice")
|
|
mgr.destroy_session(sid)
|
|
assert mgr.validate_session(sid) is None
|
|
|
|
def test_destroy_nonexistent(self):
|
|
mgr = self._make_manager()
|
|
# Should not raise
|
|
mgr.destroy_session("nonexistent")
|
|
|
|
def test_cleanup_expired_sessions(self):
|
|
mgr = self._make_manager(session_hours=1)
|
|
sid1 = mgr.create_session("alice")
|
|
sid2 = mgr.create_session("bob")
|
|
# Expire sid1
|
|
with mgr._lock:
|
|
mgr.sessions[sid1]["last_access"] = datetime.datetime.now(
|
|
UTC
|
|
) - timedelta(hours=2)
|
|
mgr.cleanup_expired_sessions()
|
|
assert sid1 not in mgr.sessions
|
|
assert sid2 in mgr.sessions
|
|
|
|
def test_get_active_sessions_count(self):
|
|
mgr = self._make_manager()
|
|
mgr.create_session("alice")
|
|
mgr.create_session("bob")
|
|
assert mgr.get_active_sessions_count() == 2
|
|
|
|
def test_get_active_sessions_count_excludes_expired(self):
|
|
mgr = self._make_manager(session_hours=1)
|
|
sid1 = mgr.create_session("alice")
|
|
mgr.create_session("bob")
|
|
with mgr._lock:
|
|
mgr.sessions[sid1]["last_access"] = datetime.datetime.now(
|
|
UTC
|
|
) - timedelta(hours=2)
|
|
assert mgr.get_active_sessions_count() == 1
|
|
|
|
def test_get_user_sessions(self):
|
|
mgr = self._make_manager()
|
|
mgr.create_session("alice")
|
|
mgr.create_session("alice")
|
|
mgr.create_session("bob")
|
|
alice_sessions = mgr.get_user_sessions("alice")
|
|
assert len(alice_sessions) == 2
|
|
for s in alice_sessions:
|
|
assert "session_id" in s
|
|
assert s["session_id"].endswith("...")
|
|
|
|
def test_get_user_sessions_empty(self):
|
|
mgr = self._make_manager()
|
|
assert mgr.get_user_sessions("nobody") == []
|
|
|
|
def test_get_active_usernames(self):
|
|
mgr = self._make_manager()
|
|
mgr.create_session("alice")
|
|
mgr.create_session("bob")
|
|
mgr.create_session("alice")
|
|
usernames = mgr.get_active_usernames()
|
|
assert usernames == {"alice", "bob"}
|
|
|
|
def test_get_active_usernames_excludes_expired(self):
|
|
mgr = self._make_manager(session_hours=1)
|
|
sid = mgr.create_session("alice")
|
|
mgr.create_session("bob")
|
|
with mgr._lock:
|
|
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
|
|
UTC
|
|
) - timedelta(hours=2)
|
|
assert mgr.get_active_usernames() == {"bob"}
|
|
|
|
def test_has_active_sessions_for(self):
|
|
mgr = self._make_manager()
|
|
mgr.create_session("alice")
|
|
assert mgr.has_active_sessions_for("alice") is True
|
|
assert mgr.has_active_sessions_for("bob") is False
|
|
|
|
def test_has_active_sessions_for_expired(self):
|
|
mgr = self._make_manager(session_hours=1)
|
|
sid = mgr.create_session("alice")
|
|
with mgr._lock:
|
|
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
|
|
UTC
|
|
) - timedelta(hours=2)
|
|
assert mgr.has_active_sessions_for("alice") is False
|
|
|
|
def test_multiple_users_isolation(self):
|
|
mgr = self._make_manager()
|
|
sid_alice = mgr.create_session("alice")
|
|
sid_bob = mgr.create_session("bob")
|
|
mgr.destroy_session(sid_alice)
|
|
assert mgr.validate_session(sid_bob) == "bob"
|
|
assert mgr.has_active_sessions_for("alice") is False
|
|
assert mgr.has_active_sessions_for("bob") is True
|