"""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,

hi

") 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