""" Unit tests for AuthSessionService. Tests the per-login authentication session management that ensures email verification is required on EVERY login, never cached. See ADR-010 for the architectural decision behind this design. """ import hashlib import time from datetime import datetime, timedelta from unittest.mock import MagicMock, Mock, patch import pytest from gondulf.services.auth_session import ( MAX_CODE_ATTEMPTS, SESSION_TTL_MINUTES, AuthSessionError, AuthSessionService, CodeVerificationError, MaxAttemptsExceededError, SessionExpiredError, SessionNotFoundError, ) @pytest.fixture def mock_database(): """Create a mock database for testing.""" mock_db = Mock() mock_engine = MagicMock() mock_db.get_engine.return_value = mock_engine return mock_db @pytest.fixture def auth_session_service(mock_database): """Create AuthSessionService with mock database.""" return AuthSessionService(database=mock_database) class TestAuthSessionServiceInit: """Tests for AuthSessionService initialization.""" def test_initialization(self, mock_database): """Test service initializes correctly.""" service = AuthSessionService(database=mock_database) assert service.database == mock_database class TestSessionIdGeneration: """Tests for session ID generation.""" def test_generate_session_id_is_string(self, auth_session_service): """Test session ID is a string.""" session_id = auth_session_service._generate_session_id() assert isinstance(session_id, str) def test_generate_session_id_is_unique(self, auth_session_service): """Test session IDs are unique.""" ids = [auth_session_service._generate_session_id() for _ in range(100)] assert len(set(ids)) == 100 def test_generate_session_id_is_long_enough(self, auth_session_service): """Test session ID has sufficient entropy.""" session_id = auth_session_service._generate_session_id() # URL-safe base64 of 32 bytes = ~43 characters assert len(session_id) >= 40 class TestVerificationCodeGeneration: """Tests for verification code generation.""" def test_generate_code_is_6_digits(self, auth_session_service): """Test verification code is exactly 6 digits.""" code = auth_session_service._generate_verification_code() assert len(code) == 6 assert code.isdigit() def test_generate_code_is_padded(self, auth_session_service): """Test verification code is zero-padded.""" # Generate many codes to test padding for _ in range(100): code = auth_session_service._generate_verification_code() assert len(code) == 6 def test_generate_code_varies(self, auth_session_service): """Test verification codes are not constant.""" codes = [auth_session_service._generate_verification_code() for _ in range(100)] # With 6 digits, 100 codes should have significant variation assert len(set(codes)) > 50 class TestCodeHashing: """Tests for code hashing.""" def test_hash_code_produces_sha256(self, auth_session_service): """Test code hashing produces SHA-256 hash.""" code = "123456" hashed = auth_session_service._hash_code(code) expected = hashlib.sha256(code.encode()).hexdigest() assert hashed == expected def test_hash_code_is_deterministic(self, auth_session_service): """Test same code produces same hash.""" code = "123456" hash1 = auth_session_service._hash_code(code) hash2 = auth_session_service._hash_code(code) assert hash1 == hash2 def test_different_codes_produce_different_hashes(self, auth_session_service): """Test different codes produce different hashes.""" hash1 = auth_session_service._hash_code("123456") hash2 = auth_session_service._hash_code("654321") assert hash1 != hash2 class TestCreateSession: """Tests for session creation.""" def test_create_session_returns_session_id(self, auth_session_service, mock_database): """Test session creation returns session ID.""" # Setup mock to track execute calls mock_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) result = auth_session_service.create_session( me="https://user.example.com", email="user@example.com", client_id="https://app.example.com", redirect_uri="https://app.example.com/callback", state="xyz123", code_challenge="challenge123", code_challenge_method="S256", scope="", response_type="id" ) assert "session_id" in result assert isinstance(result["session_id"], str) assert len(result["session_id"]) >= 40 def test_create_session_returns_verification_code(self, auth_session_service, mock_database): """Test session creation returns verification code.""" mock_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) result = auth_session_service.create_session( me="https://user.example.com", email="user@example.com", client_id="https://app.example.com", redirect_uri="https://app.example.com/callback", state="xyz123", code_challenge="challenge123", code_challenge_method="S256", scope="", response_type="id" ) assert "verification_code" in result assert len(result["verification_code"]) == 6 assert result["verification_code"].isdigit() def test_create_session_returns_expiration(self, auth_session_service, mock_database): """Test session creation returns expiration time.""" mock_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) result = auth_session_service.create_session( me="https://user.example.com", email="user@example.com", client_id="https://app.example.com", redirect_uri="https://app.example.com/callback", state="xyz123", code_challenge="challenge123", code_challenge_method="S256", scope="", response_type="id" ) assert "expires_at" in result assert isinstance(result["expires_at"], datetime) # Expiration should be approximately SESSION_TTL_MINUTES from now expected_expiry = datetime.utcnow() + timedelta(minutes=SESSION_TTL_MINUTES) assert abs((result["expires_at"] - expected_expiry).total_seconds()) < 5 def test_create_session_stores_hashed_code(self, auth_session_service, mock_database): """Test that verification code is stored hashed, not plain.""" mock_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) result = auth_session_service.create_session( me="https://user.example.com", email="user@example.com", client_id="https://app.example.com", redirect_uri="https://app.example.com/callback", state="xyz123", code_challenge="challenge123", code_challenge_method="S256", scope="", response_type="id" ) # Verify execute was called assert mock_conn.execute.called # Check the parameters passed to execute call_args = mock_conn.execute.call_args params = call_args[0][1] # Code hash should be SHA-256 of the verification code expected_hash = hashlib.sha256(result["verification_code"].encode()).hexdigest() assert params["code_hash"] == expected_hash def test_create_session_handles_database_error(self, auth_session_service, mock_database): """Test session creation handles database errors.""" mock_database.get_engine.return_value.begin.side_effect = Exception("Database error") with pytest.raises(AuthSessionError) as exc_info: auth_session_service.create_session( me="https://user.example.com", email="user@example.com", client_id="https://app.example.com", redirect_uri="https://app.example.com/callback", state="xyz123", code_challenge="challenge123", code_challenge_method="S256", scope="", response_type="id" ) assert "Failed to create session" in str(exc_info.value) class TestGetSession: """Tests for session retrieval.""" def test_get_session_not_found(self, auth_session_service, mock_database): """Test getting non-existent session raises error.""" mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = None mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) with pytest.raises(SessionNotFoundError): auth_session_service.get_session("nonexistent_session_id") def test_get_session_expired(self, auth_session_service, mock_database): """Test getting expired session raises error.""" mock_conn = MagicMock() mock_result = MagicMock() # Return a session that expired in the past expired_time = datetime.utcnow() - timedelta(hours=1) mock_result.fetchone.return_value = ( "session123", "https://user.example.com", "user@example.com", False, 0, "https://app.example.com", "https://app.example.com/callback", "xyz", "challenge", "S256", "", "id", datetime.utcnow() - timedelta(hours=2), expired_time ) mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) # Also mock the delete for cleanup mock_del_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_del_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) with pytest.raises(SessionExpiredError): auth_session_service.get_session("session123") def test_get_session_returns_data(self, auth_session_service, mock_database): """Test getting valid session returns all data.""" mock_conn = MagicMock() mock_result = MagicMock() future_time = datetime.utcnow() + timedelta(minutes=5) mock_result.fetchone.return_value = ( "session123", "https://user.example.com", "user@example.com", True, 1, "https://app.example.com", "https://app.example.com/callback", "xyz", "challenge", "S256", "profile", "code", datetime.utcnow(), future_time ) mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) result = auth_session_service.get_session("session123") assert result["session_id"] == "session123" assert result["me"] == "https://user.example.com" assert result["email"] == "user@example.com" assert result["code_verified"] is True assert result["client_id"] == "https://app.example.com" assert result["response_type"] == "code" class TestVerifyCode: """Tests for code verification - the core authentication step.""" def test_verify_code_success(self, auth_session_service, mock_database): """Test successful code verification.""" code = "123456" code_hash = hashlib.sha256(code.encode()).hexdigest() future_time = datetime.utcnow() + timedelta(minutes=5) # Mock for initial fetch mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = ( "session123", "https://user.example.com", "user@example.com", code_hash, False, 0, "https://app.example.com", "https://app.example.com/callback", "xyz", "challenge", "S256", "", "id", future_time ) mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) # Mock for update mock_update_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_update_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) result = auth_session_service.verify_code("session123", code) assert result["code_verified"] is True assert result["me"] == "https://user.example.com" def test_verify_code_wrong_code(self, auth_session_service, mock_database): """Test code verification with wrong code.""" correct_code = "123456" wrong_code = "654321" code_hash = hashlib.sha256(correct_code.encode()).hexdigest() future_time = datetime.utcnow() + timedelta(minutes=5) mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = ( "session123", "https://user.example.com", "user@example.com", code_hash, False, 0, "https://app.example.com", "https://app.example.com/callback", "xyz", "challenge", "S256", "", "id", future_time ) mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) # Mock for attempt increment mock_update_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_update_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) with pytest.raises(CodeVerificationError): auth_session_service.verify_code("session123", wrong_code) def test_verify_code_max_attempts_exceeded(self, auth_session_service, mock_database): """Test code verification fails after max attempts.""" code = "123456" code_hash = hashlib.sha256(code.encode()).hexdigest() future_time = datetime.utcnow() + timedelta(minutes=5) mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = ( "session123", "https://user.example.com", "user@example.com", code_hash, False, MAX_CODE_ATTEMPTS, "https://app.example.com", "https://app.example.com/callback", "xyz", "challenge", "S256", "", "id", future_time ) mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) # Mock for session deletion mock_del_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_del_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) with pytest.raises(MaxAttemptsExceededError): auth_session_service.verify_code("session123", code) def test_verify_code_session_not_found(self, auth_session_service, mock_database): """Test code verification with non-existent session.""" mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = None mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) with pytest.raises(SessionNotFoundError): auth_session_service.verify_code("nonexistent", "123456") def test_verify_code_session_expired(self, auth_session_service, mock_database): """Test code verification with expired session.""" code = "123456" code_hash = hashlib.sha256(code.encode()).hexdigest() expired_time = datetime.utcnow() - timedelta(hours=1) mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = ( "session123", "https://user.example.com", "user@example.com", code_hash, False, 0, "https://app.example.com", "https://app.example.com/callback", "xyz", "challenge", "S256", "", "id", expired_time ) mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) # Mock for session deletion mock_del_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_del_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) with pytest.raises(SessionExpiredError): auth_session_service.verify_code("session123", code) def test_verify_code_already_verified(self, auth_session_service, mock_database): """Test code verification on already verified session returns success.""" code = "123456" code_hash = hashlib.sha256(code.encode()).hexdigest() future_time = datetime.utcnow() + timedelta(minutes=5) mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = ( "session123", "https://user.example.com", "user@example.com", code_hash, True, 1, "https://app.example.com", # Already verified "https://app.example.com/callback", "xyz", "challenge", "S256", "", "id", future_time ) mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) result = auth_session_service.verify_code("session123", code) assert result["code_verified"] is True class TestIsSessionVerified: """Tests for checking session verification status.""" def test_is_session_verified_true(self, auth_session_service, mock_database): """Test is_session_verified returns True for verified session.""" future_time = datetime.utcnow() + timedelta(minutes=5) mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = ( "session123", "https://user.example.com", "user@example.com", True, 1, "https://app.example.com", "https://app.example.com/callback", "xyz", "challenge", "S256", "", "id", datetime.utcnow(), future_time ) mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) assert auth_session_service.is_session_verified("session123") is True def test_is_session_verified_false(self, auth_session_service, mock_database): """Test is_session_verified returns False for unverified session.""" future_time = datetime.utcnow() + timedelta(minutes=5) mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = ( "session123", "https://user.example.com", "user@example.com", False, 0, "https://app.example.com", "https://app.example.com/callback", "xyz", "challenge", "S256", "", "id", datetime.utcnow(), future_time ) mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) assert auth_session_service.is_session_verified("session123") is False def test_is_session_verified_not_found(self, auth_session_service, mock_database): """Test is_session_verified returns False for non-existent session.""" mock_conn = MagicMock() mock_result = MagicMock() mock_result.fetchone.return_value = None mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False) assert auth_session_service.is_session_verified("nonexistent") is False class TestDeleteSession: """Tests for session deletion.""" def test_delete_session(self, auth_session_service, mock_database): """Test session deletion.""" mock_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) # Should not raise auth_session_service.delete_session("session123") # Verify execute was called assert mock_conn.execute.called class TestCleanupExpiredSessions: """Tests for expired session cleanup.""" def test_cleanup_returns_count(self, auth_session_service, mock_database): """Test cleanup returns number of deleted sessions.""" mock_conn = MagicMock() mock_result = MagicMock() mock_result.rowcount = 5 mock_conn.execute.return_value = mock_result mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) count = auth_session_service.cleanup_expired_sessions() assert count == 5 def test_cleanup_handles_error(self, auth_session_service, mock_database): """Test cleanup handles database errors gracefully.""" mock_database.get_engine.return_value.begin.side_effect = Exception("Database error") count = auth_session_service.cleanup_expired_sessions() assert count == 0 class TestSecurityProperties: """ Tests verifying security properties of the authentication flow. These tests ensure the critical security requirements from ADR-010 are met. """ def test_code_is_never_stored_in_plain_text(self, auth_session_service, mock_database): """ CRITICAL: Verify that verification codes are never stored in plain text. The verification code should be hashed before storage to prevent database compromise from exposing valid codes. """ mock_conn = MagicMock() mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn) mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False) result = auth_session_service.create_session( me="https://user.example.com", email="user@example.com", client_id="https://app.example.com", redirect_uri="https://app.example.com/callback", state="xyz123", code_challenge="challenge123", code_challenge_method="S256", scope="", response_type="id" ) plain_code = result["verification_code"] call_args = mock_conn.execute.call_args params = call_args[0][1] # The plain code should NOT appear in storage assert params.get("code_hash") != plain_code # The hash should be a SHA-256 hash (64 hex characters) assert len(params["code_hash"]) == 64 def test_session_id_has_sufficient_entropy(self, auth_session_service): """ CRITICAL: Verify session IDs have sufficient entropy to prevent guessing. Session IDs must be cryptographically random with enough bits to prevent brute-force attacks. """ session_ids = [auth_session_service._generate_session_id() for _ in range(1000)] # All should be unique assert len(set(session_ids)) == 1000 # Should be at least 32 bytes of entropy (256 bits) # URL-safe base64 of 32 bytes is ~43 characters for sid in session_ids: assert len(sid) >= 40 def test_code_verification_uses_constant_time_comparison(self, auth_session_service): """ CRITICAL: Verify code comparison uses constant-time algorithm. This prevents timing attacks that could leak information about the correct code. """ # The implementation uses secrets.compare_digest which is constant-time # We verify the hash comparison pattern is correct code1 = "123456" code2 = "123456" hash1 = auth_session_service._hash_code(code1) hash2 = auth_session_service._hash_code(code2) # Same codes should produce same hashes assert hash1 == hash2 # Different codes should produce different hashes hash3 = auth_session_service._hash_code("654321") assert hash1 != hash3