""" Tests for web/auth/session_manager.py Tests cover: - SessionManager class initialization with mocked security defaults - Session creation, validation, and destruction - Session cleanup and expiration using datetime mocking - User session management and session info formatting """ import datetime from datetime import UTC from unittest.mock import patch import pytest # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- MOCK_SESSION_TIMEOUT_HOURS = 2 MOCK_REMEMBER_ME_DAYS = 30 # Patch target for the security helper used inside session_manager.py _SECURITY_DEFAULT_PATCH = ( "local_deep_research.web.auth.session_manager.get_security_default" ) def _security_default_side_effect(key, default): """Return controlled timeout values for tests.""" mapping = { "security.session_timeout_hours": MOCK_SESSION_TIMEOUT_HOURS, "security.session_remember_me_days": MOCK_REMEMBER_ME_DAYS, } return mapping.get(key, default) @pytest.fixture() def manager(): """Create a SessionManager with mocked security defaults.""" with patch( _SECURITY_DEFAULT_PATCH, side_effect=_security_default_side_effect ): from local_deep_research.web.auth.session_manager import ( SessionManager, ) return SessionManager() # =========================================================================== # Initialization # =========================================================================== class TestSessionManagerInit: """Tests for SessionManager initialization.""" def test_init_creates_empty_sessions_dict(self, manager): """Should initialize with an empty sessions dict.""" assert manager.sessions == {} def test_init_sets_session_timeout_from_security_defaults(self, manager): """Should set session_timeout based on mocked security default.""" assert manager.session_timeout == datetime.timedelta( hours=MOCK_SESSION_TIMEOUT_HOURS ) def test_init_sets_remember_me_timeout_from_security_defaults( self, manager ): """Should set remember_me_timeout based on mocked security default.""" assert manager.remember_me_timeout == datetime.timedelta( days=MOCK_REMEMBER_ME_DAYS ) def test_init_calls_get_security_default(self): """Should call get_security_default for both timeout settings.""" with patch(_SECURITY_DEFAULT_PATCH) as mock_get: mock_get.return_value = 1 from local_deep_research.web.auth.session_manager import ( SessionManager, ) SessionManager() assert mock_get.call_count == 2 call_keys = [c.args[0] for c in mock_get.call_args_list] assert "security.session_timeout_hours" in call_keys assert "security.session_remember_me_days" in call_keys def test_init_uses_custom_timeout_values(self): """Should honour non-default timeout values from security settings.""" custom_hours = 8 custom_days = 7 def custom_side_effect(key, default): if key == "security.session_timeout_hours": return custom_hours if key == "security.session_remember_me_days": return custom_days return default with patch(_SECURITY_DEFAULT_PATCH, side_effect=custom_side_effect): from local_deep_research.web.auth.session_manager import ( SessionManager, ) mgr = SessionManager() assert mgr.session_timeout == datetime.timedelta(hours=custom_hours) assert mgr.remember_me_timeout == datetime.timedelta(days=custom_days) # =========================================================================== # Session creation # =========================================================================== class TestCreateSession: """Tests for create_session method.""" def test_returns_string_token(self, manager): """Should return a session ID that is a non-empty string.""" session_id = manager.create_session("alice") assert isinstance(session_id, str) assert len(session_id) > 0 def test_returns_unique_tokens(self, manager): """Should generate unique session IDs across multiple calls.""" ids = {manager.create_session(f"user{i}") for i in range(50)} assert len(ids) == 50 def test_stores_session_data(self, manager): """Should store session entry in the sessions dict.""" sid = manager.create_session("bob") assert sid in manager.sessions data = manager.sessions[sid] assert data["username"] == "bob" assert data["remember_me"] is False assert isinstance(data["created_at"], datetime.datetime) assert isinstance(data["last_access"], datetime.datetime) def test_default_remember_me_is_false(self, manager): """Should default remember_me to False.""" sid = manager.create_session("carol") assert manager.sessions[sid]["remember_me"] is False def test_remember_me_true(self, manager): """Should store remember_me=True when requested.""" sid = manager.create_session("dave", remember_me=True) assert manager.sessions[sid]["remember_me"] is True def test_timestamps_are_utc_aware(self, manager): """Should set created_at and last_access as UTC-aware datetimes.""" sid = manager.create_session("eve") data = manager.sessions[sid] assert data["created_at"].tzinfo is not None assert data["last_access"].tzinfo is not None def test_multiple_sessions_same_user(self, manager): """Should allow multiple sessions for the same username.""" sid1 = manager.create_session("frank") sid2 = manager.create_session("frank") assert sid1 != sid2 assert sid1 in manager.sessions assert sid2 in manager.sessions assert manager.sessions[sid1]["username"] == "frank" assert manager.sessions[sid2]["username"] == "frank" # =========================================================================== # Session validation # =========================================================================== class TestValidateSession: """Tests for validate_session method.""" def test_returns_username_for_valid_session(self, manager): """Should return the username for a freshly created session.""" sid = manager.create_session("grace") assert manager.validate_session(sid) == "grace" def test_returns_none_for_unknown_session_id(self, manager): """Should return None for a session_id that was never created.""" assert manager.validate_session("totally-bogus-id") is None def test_returns_none_for_expired_regular_session(self, manager): """Should return None when regular session has exceeded session_timeout.""" fixed_now = datetime.datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) expired_time = fixed_now - datetime.timedelta( hours=MOCK_SESSION_TIMEOUT_HOURS, seconds=1 ) with patch( "local_deep_research.web.auth.session_manager.datetime" ) as mock_dt: mock_dt.datetime.now.return_value = fixed_now mock_dt.timedelta = datetime.timedelta mock_dt.UTC = UTC sid = manager.create_session("hank") # Manually set last_access to an expired time manager.sessions[sid]["last_access"] = expired_time result = manager.validate_session(sid) assert result is None def test_destroys_expired_session(self, manager): """Should remove the session from self.sessions when it expires.""" sid = manager.create_session("ivy") manager.sessions[sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) manager.validate_session(sid) assert sid not in manager.sessions def test_returns_none_for_expired_remember_me_session(self, manager): """Should return None for remember_me session past remember_me_timeout.""" sid = manager.create_session("jack", remember_me=True) manager.sessions[sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(days=MOCK_REMEMBER_ME_DAYS + 1) assert manager.validate_session(sid) is None assert sid not in manager.sessions def test_remember_me_survives_regular_timeout(self, manager): """A remember_me session should still be valid after regular timeout elapses.""" sid = manager.create_session("kate", remember_me=True) # 3 hours ago exceeds the 2-hour regular timeout but not 30-day remember_me manager.sessions[sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) result = manager.validate_session(sid) assert result == "kate" def test_updates_last_access_on_valid_session(self, manager): """Should update last_access to current time when session is valid.""" sid = manager.create_session("leo") old_access = manager.sessions[sid]["last_access"] # Nudge time forward slightly with patch( "local_deep_research.web.auth.session_manager.datetime" ) as mock_dt: future = old_access + datetime.timedelta(minutes=10) mock_dt.datetime.now.return_value = future mock_dt.timedelta = datetime.timedelta mock_dt.UTC = UTC result = manager.validate_session(sid) assert result == "leo" assert manager.sessions[sid]["last_access"] == future def test_does_not_update_last_access_for_unknown_session(self, manager): """Calling validate on unknown id should not create any session entry.""" manager.validate_session("nonexistent") assert "nonexistent" not in manager.sessions def test_expired_boundary_exact_timeout(self, manager): """Session at exactly the timeout boundary should still be valid (uses >).""" fixed_now = datetime.datetime(2026, 6, 15, 12, 0, 0, tzinfo=UTC) boundary_time = fixed_now - datetime.timedelta( hours=MOCK_SESSION_TIMEOUT_HOURS ) sid = manager.create_session("mia") manager.sessions[sid]["last_access"] = boundary_time with patch( "local_deep_research.web.auth.session_manager.datetime" ) as mock_dt: mock_dt.datetime.now.return_value = fixed_now mock_dt.timedelta = datetime.timedelta mock_dt.UTC = UTC # Source code checks ``now - last_access > timeout``, # so exactly equal should still be valid. result = manager.validate_session(sid) assert result == "mia" # =========================================================================== # Session destruction # =========================================================================== class TestDestroySession: """Tests for destroy_session method.""" def test_removes_session(self, manager): """Should remove the session from self.sessions.""" sid = manager.create_session("nina") assert sid in manager.sessions manager.destroy_session(sid) assert sid not in manager.sessions def test_noop_for_unknown_session_id(self, manager): """Should not raise when destroying an unknown session_id.""" manager.destroy_session("does-not-exist") # no exception def test_does_not_affect_other_sessions(self, manager): """Destroying one session should leave others intact.""" sid1 = manager.create_session("oscar") sid2 = manager.create_session("oscar") manager.destroy_session(sid1) assert sid1 not in manager.sessions assert sid2 in manager.sessions # =========================================================================== # Cleanup expired sessions # =========================================================================== class TestCleanupExpiredSessions: """Tests for cleanup_expired_sessions method.""" def test_removes_expired_regular_sessions(self, manager): """Should remove regular sessions that have exceeded session_timeout.""" sid = manager.create_session("quinn") manager.sessions[sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) manager.cleanup_expired_sessions() assert sid not in manager.sessions def test_keeps_valid_regular_sessions(self, manager): """Should keep regular sessions that have not expired.""" sid = manager.create_session("rosa") manager.cleanup_expired_sessions() assert sid in manager.sessions def test_removes_only_expired_sessions(self, manager): """Should remove expired sessions while keeping valid ones.""" expired_sid = manager.create_session("stale_user") manager.sessions[expired_sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) valid_sid = manager.create_session("fresh_user") manager.cleanup_expired_sessions() assert expired_sid not in manager.sessions assert valid_sid in manager.sessions def test_removes_multiple_expired_sessions(self, manager): """Should remove all expired sessions in a single cleanup pass.""" expired_ids = [] for i in range(5): sid = manager.create_session(f"expired_{i}") manager.sessions[sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) expired_ids.append(sid) valid_sid = manager.create_session("keeper") manager.cleanup_expired_sessions() for eid in expired_ids: assert eid not in manager.sessions assert valid_sid in manager.sessions def test_respects_remember_me_timeout(self, manager): """Should keep remember_me sessions that exceed regular timeout but not remember_me_timeout.""" sid = manager.create_session("tina", remember_me=True) # 3 hours ago: past regular timeout but within remember_me timeout manager.sessions[sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) manager.cleanup_expired_sessions() assert sid in manager.sessions def test_removes_expired_remember_me_sessions(self, manager): """Should remove remember_me sessions that exceed remember_me_timeout.""" sid = manager.create_session("uma", remember_me=True) manager.sessions[sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(days=MOCK_REMEMBER_ME_DAYS + 1) manager.cleanup_expired_sessions() assert sid not in manager.sessions def test_handles_empty_sessions(self, manager): """Should not raise when there are no sessions.""" manager.cleanup_expired_sessions() # no exception assert manager.sessions == {} def test_mixed_regular_and_remember_me(self, manager): """Should correctly handle a mix of regular and remember_me sessions.""" # Expired regular s1 = manager.create_session("u1") manager.sessions[s1]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) # Valid remember_me (same age as s1 but has longer timeout) s2 = manager.create_session("u2", remember_me=True) manager.sessions[s2]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) # Expired remember_me s3 = manager.create_session("u3", remember_me=True) manager.sessions[s3]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(days=MOCK_REMEMBER_ME_DAYS + 1) # Valid regular s4 = manager.create_session("u4") manager.cleanup_expired_sessions() assert s1 not in manager.sessions # expired regular assert s2 in manager.sessions # still valid remember_me assert s3 not in manager.sessions # expired remember_me assert s4 in manager.sessions # still valid regular # =========================================================================== # Active sessions count # =========================================================================== class TestGetActiveSessionsCount: """Tests for get_active_sessions_count method.""" def test_returns_zero_when_empty(self, manager): """Should return 0 when there are no sessions.""" assert manager.get_active_sessions_count() == 0 def test_returns_correct_count(self, manager): """Should return the number of active sessions.""" for i in range(4): manager.create_session(f"user{i}") assert manager.get_active_sessions_count() == 4 def test_triggers_cleanup_before_counting(self, manager): """Should clean up expired sessions before counting.""" for i in range(3): manager.create_session(f"active_{i}") expired_sid = manager.create_session("stale") manager.sessions[expired_sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) count = manager.get_active_sessions_count() assert count == 3 assert expired_sid not in manager.sessions def test_count_after_all_expired(self, manager): """Should return 0 when all sessions have expired.""" for i in range(3): sid = manager.create_session(f"user{i}") manager.sessions[sid]["last_access"] = datetime.datetime.now( UTC ) - datetime.timedelta(hours=MOCK_SESSION_TIMEOUT_HOURS + 1) assert manager.get_active_sessions_count() == 0 # =========================================================================== # User sessions # =========================================================================== class TestGetUserSessions: """Tests for get_user_sessions method.""" def test_returns_empty_list_when_no_sessions(self, manager): """Should return an empty list when the user has no sessions.""" assert manager.get_user_sessions("nobody") == [] def test_returns_only_matching_user_sessions(self, manager): """Should return sessions only for the specified username.""" manager.create_session("alice") manager.create_session("alice") manager.create_session("bob") alice_sessions = manager.get_user_sessions("alice") assert len(alice_sessions) == 2 bob_sessions = manager.get_user_sessions("bob") assert len(bob_sessions) == 1 def test_does_not_return_other_users_sessions(self, manager): """Should never include sessions belonging to other users.""" manager.create_session("charlie") manager.create_session("diana") result = manager.get_user_sessions("charlie") assert len(result) == 1 def test_truncates_session_id(self, manager): """Should truncate session_id to first 8 characters followed by '...'.""" sid = manager.create_session("eve") result = manager.get_user_sessions("eve") assert len(result) == 1 truncated_id = result[0]["session_id"] assert truncated_id == sid[:8] + "..." assert len(truncated_id) == 11 # 8 chars + 3 dots def test_returns_expected_keys(self, manager): """Should return dicts with session_id, created_at, last_access, remember_me.""" manager.create_session("frank", remember_me=True) result = manager.get_user_sessions("frank") assert len(result) == 1 info = result[0] assert set(info.keys()) == { "session_id", "created_at", "last_access", "remember_me", } assert info["remember_me"] is True assert isinstance(info["created_at"], datetime.datetime) assert isinstance(info["last_access"], datetime.datetime) def test_multiple_sessions_same_user(self, manager): """Should list all sessions when a user has multiple active sessions.""" sid1 = manager.create_session("grace") sid2 = manager.create_session("grace") result = manager.get_user_sessions("grace") assert len(result) == 2 returned_ids = {r["session_id"] for r in result} assert returned_ids == {sid1[:8] + "...", sid2[:8] + "..."} def test_returns_remember_me_flag_per_session(self, manager): """Each session entry should reflect its own remember_me setting.""" manager.create_session("hana", remember_me=False) manager.create_session("hana", remember_me=True) result = manager.get_user_sessions("hana") flags = {r["remember_me"] for r in result} assert flags == {True, False} class TestDestroyAllUserSessions: """Tests for destroy_all_user_sessions.""" def test_destroys_all_sessions_for_user(self, manager): """Should destroy every session belonging to the target user.""" manager.create_session("alice", remember_me=False) manager.create_session("alice", remember_me=False) manager.create_session("bob", remember_me=False) count = manager.destroy_all_user_sessions("alice") assert count == 2 assert manager.get_user_sessions("alice") == [] # Bob's session should be untouched assert len(manager.get_user_sessions("bob")) == 1 def test_returns_zero_for_unknown_user(self, manager): """Should return 0 and not raise when user has no sessions.""" count = manager.destroy_all_user_sessions("nobody") assert count == 0 def test_returns_zero_after_double_call(self, manager): """Calling twice should be idempotent.""" manager.create_session("alice", remember_me=False) manager.destroy_all_user_sessions("alice") count = manager.destroy_all_user_sessions("alice") assert count == 0