# tests/core/test_auth_service.py from datetime import datetime from unittest.mock import patch import pytest from myauth.core.auth import AuthService from myauth.exceptions import UserAlreadyExistsError, InvalidCredentialsError, InvalidTokenError, \ ExpiredTokenError, RevokedTokenError from myauth.models.token import TokenPayload from myauth.models.user import UserCreate class TestAuthServiceRegisterLogin(object): """Tests for the user registration and login processes.""" # The mocks are injected via the auth_service fixture from conftest.py def test_register_success(self, auth_service: AuthService, test_user_data_create: UserCreate): """Success: Registration works and stores the predictable hash.""" user = auth_service.register(test_user_data_create) assert user is not None assert user.hashed_password == "PREDICTABLE_HASHED_PASSWORD_FOR_TESTING" auth_service.password_manager.hash_password.assert_called_once_with(test_user_data_create.password) def test_register_failure_if_email_exists(self, auth_service: AuthService, test_user_data_create: UserCreate): """Failure: Cannot register if the email already exists.""" auth_service.register(test_user_data_create) with pytest.raises(UserAlreadyExistsError): auth_service.register(test_user_data_create) def test_login_success(self, auth_service: AuthService, test_user_data_create: UserCreate): """Success: Logging in with correct credentials generates and saves tokens.""" # Execute register to insert the user auth_service.register(test_user_data_create) # Execute login user, tokens = auth_service.login(test_user_data_create.email, test_user_data_create.password) assert user.email == test_user_data_create.email auth_service.password_manager.verify_password.assert_called_once() auth_service.token_manager.create_access_token.assert_called_once() assert tokens.access_token == "MOCKED_ACCESS_TOKEN" assert tokens.refresh_token == "MOCKED_REFRESH_TOKEN" def test_login_failure_invalid_password(self, auth_service: AuthService, # Removed mock_password_manager injection test_user_data_create: UserCreate): """Failure: Login fails with InvalidCredentialsError if the password is wrong.""" # Access the manager via the service instance pm = auth_service.password_manager # Setup: Mock hash for registration pm.hash_password.return_value = "HASH" auth_service.register(test_user_data_create) # Mock verify for failure pm.verify_password.return_value = False with pytest.raises(InvalidCredentialsError): auth_service.login(test_user_data_create.email, "WrongPassword!") # Restore the default mock behavior defined in conftest for subsequent tests pm.hash_password.return_value = "PREDICTABLE_HASHED_PASSWORD_FOR_TESTING" pm.verify_password.return_value = True def test_login_failure_user_not_found(self, auth_service: AuthService): """Failure: Login fails if the user does not exist.""" with pytest.raises(InvalidCredentialsError): auth_service.login("non.existent@example.com", "AnyPassword") def test_create_admin_if_needed_success_with_custom_credentials( self, auth_service: AuthService ): """Success: Admin is created with custom credentials when no users exist.""" # Arrange custom_email = "custom.admin@example.com" custom_username = "custom_admin" custom_password = "CustomAdminPass123!" # Act result = auth_service.create_admin_if_needed( admin_email=custom_email, admin_username=custom_username, admin_password=custom_password ) # Assert assert result is True # Verify admin user was created admin_user = auth_service.user_repository.get_user_by_email(custom_email) assert admin_user is not None assert admin_user.email == custom_email assert admin_user.username == custom_username assert "admin" in admin_user.roles # Verify password was hashed auth_service.password_manager.hash_password.assert_called() def test_create_admin_if_needed_success_with_default_credentials( self, auth_service: AuthService, monkeypatch ): """Success: Admin is created with default credentials from environment variables.""" # Arrange monkeypatch.setenv("AUTH_ADMIN_EMAIL", "env.admin@example.com") monkeypatch.setenv("AUTH_ADMIN_USERNAME", "env_admin") monkeypatch.setenv("AUTH_ADMIN_PASSWORD", "EnvAdminPass123!") # Act result = auth_service.create_admin_if_needed() # Assert assert result is True # Verify admin user was created with env variables admin_user = auth_service.user_repository.get_user_by_email("env.admin@example.com") assert admin_user is not None assert admin_user.email == "env.admin@example.com" assert admin_user.username == "env_admin" assert "admin" in admin_user.roles def test_create_admin_if_needed_success_with_hardcoded_defaults( self, auth_service: AuthService, monkeypatch ): """Success: Admin is created with hardcoded defaults when no env vars or params provided.""" # Arrange - Clear any existing env variables monkeypatch.delenv("AUTH_ADMIN_EMAIL", raising=False) monkeypatch.delenv("AUTH_ADMIN_USERNAME", raising=False) monkeypatch.delenv("AUTH_ADMIN_PASSWORD", raising=False) # Act result = auth_service.create_admin_if_needed() # Assert assert result is True # Verify admin user was created with hardcoded defaults admin_user = auth_service.user_repository.get_user_by_email("admin@myauth.com") assert admin_user is not None assert admin_user.email == "admin@myauth.com" assert admin_user.username == "admin" assert "admin" in admin_user.roles def test_create_admin_if_needed_no_creation_when_users_exist( self, auth_service: AuthService, test_user_data_create: UserCreate ): """Failure: Admin is not created when users already exist in the system.""" # Arrange - Create a regular user first auth_service.register(test_user_data_create) # Act result = auth_service.create_admin_if_needed( admin_email="should.not.be.created@example.com", admin_username="should_not_exist", admin_password="ShouldNotExist123!" ) # Assert assert result is False # Verify admin user was NOT created admin_user = auth_service.user_repository.get_user_by_email( "should.not.be.created@example.com" ) assert admin_user is None # Verify only the original user exists assert auth_service.count_users() == 1 def test_create_admin_if_needed_parameters_override_env_variables( self, auth_service: AuthService, monkeypatch ): """Success: Parameters take precedence over environment variables.""" # Arrange monkeypatch.setenv("AUTH_ADMIN_EMAIL", "env.admin@example.com") monkeypatch.setenv("AUTH_ADMIN_USERNAME", "env_admin") monkeypatch.setenv("AUTH_ADMIN_PASSWORD", "EnvAdminPass123!") param_email = "param.admin@example.com" param_username = "param_admin" param_password = "ParamAdminPass123!" # Act result = auth_service.create_admin_if_needed( admin_email=param_email, admin_username=param_username, admin_password=param_password ) # Assert assert result is True # Verify parameters were used, not env variables admin_user = auth_service.user_repository.get_user_by_email(param_email) assert admin_user is not None assert admin_user.email == param_email assert admin_user.username == param_username # Verify env admin was NOT created env_admin = auth_service.user_repository.get_user_by_email("env.admin@example.com") assert env_admin is None def test_create_admin_if_needed_mixed_parameters_and_env( self, auth_service: AuthService, monkeypatch ): """Success: Partial parameters combine with environment variables.""" # Arrange monkeypatch.setenv("AUTH_ADMIN_EMAIL", "env.admin@example.com") monkeypatch.setenv("AUTH_ADMIN_USERNAME", "env_admin") monkeypatch.setenv("AUTH_ADMIN_PASSWORD", "EnvAdminPass123!") # Act - Only provide email as parameter result = auth_service.create_admin_if_needed( admin_email="partial.admin@example.com" ) # Assert assert result is True # Verify email from parameter, username and password from env admin_user = auth_service.user_repository.get_user_by_email("partial.admin@example.com") assert admin_user is not None assert admin_user.email == "partial.admin@example.com" assert admin_user.username == "env_admin" class TestAuthServiceTokenManagement(object): """Tests for token-related flows (Refresh, Logout, GetCurrentUser).""" @pytest.fixture(autouse=True) def setup_user_and_token(self, auth_service: AuthService, test_user_data_create: UserCreate): """ Sets up a registered user and an initial set of tokens for management tests. Temporarily overrides manager behavior for setup. """ # Temporarily set up predictable values for the registration/login flow within the fixture setup pm = auth_service.password_manager tm = auth_service.token_manager original_hash = pm.hash_password.return_value original_verify = pm.verify_password.return_value original_access = tm.create_access_token.return_value original_refresh = tm.create_refresh_token.return_value pm.hash_password.return_value = "HASHED_PASS" pm.verify_password.return_value = True tm.create_access_token.return_value = "SETUP_ACCESS_TOKEN" tm.create_refresh_token.return_value = "SETUP_REFRESH_TOKEN" user = auth_service.register(test_user_data_create) _, tokens = auth_service.login(test_user_data_create.email, test_user_data_create.password) self.user = user self.refresh_token = tokens.refresh_token self.access_token = tokens.access_token # Restore mock values to default conftest behavior for the actual tests pm.hash_password.return_value = original_hash pm.verify_password.return_value = original_verify tm.create_access_token.return_value = original_access tm.create_refresh_token.return_value = original_refresh def test_refresh_access_token_success(self, auth_service: AuthService): """Success: Refreshing an access token works with a valid refresh token.""" # Access the manager via the service instance tm = auth_service.token_manager # Use patch.object on the *instance* for granular control within the test with patch.object(tm, 'create_access_token', return_value="NEW_MOCKED_ACCESS_TOKEN") as mock_create_access: tokens = auth_service.refresh_access_token(self.refresh_token) assert tokens.access_token == "NEW_MOCKED_ACCESS_TOKEN" mock_create_access.assert_called_once() def test_refresh_access_token_failure_invalid_token(self, auth_service: AuthService): """Failure: Refreshing fails if the token is invalid (revoked, expired, etc.).""" auth_service.logout(self.refresh_token) with pytest.raises(InvalidTokenError): auth_service.refresh_access_token("invalid_token") def test_logout_success(self, auth_service: AuthService): """Success: Logout revokes the specified refresh token.""" result = auth_service.logout(self.refresh_token) assert result is True with pytest.raises(RevokedTokenError): auth_service.refresh_access_token(self.refresh_token) def test_get_current_user_success(self, auth_service: AuthService): """Success: Getting the current user works by successfully decoding the JWT.""" # Mock the decoder to simulate a decoded payload token_payload = TokenPayload(sub=self.user.id, email=str(self.user.email), exp=int(datetime.now().timestamp() * 1000)) with patch.object(auth_service.token_manager, 'decode_access_token', return_value=token_payload) as mock_decode: user = auth_service.get_current_user("dummy_jwt") assert user.id == self.user.id mock_decode.assert_called_once() def test_get_current_user_failure_invalid_token(self, auth_service: AuthService): """Failure: Getting the current user fails if the access token is invalid/expired.""" with patch.object(auth_service.token_manager, 'decode_access_token', side_effect=InvalidTokenError("Invalid signature")): with pytest.raises(InvalidTokenError): auth_service.get_current_user("invalid_access_jwt") with patch.object(auth_service.token_manager, 'decode_access_token', side_effect=ExpiredTokenError("Token expired")): with pytest.raises(ExpiredTokenError): auth_service.get_current_user("expired_access_jwt") class TestAuthServiceUserUpdate(object): """Tests for user update operations.""" @pytest.fixture(autouse=True) def setup_user(self, auth_service: AuthService, test_user_data_create: UserCreate): """Sets up a registered user for update tests.""" pm = auth_service.password_manager original_hash = pm.hash_password.return_value # Temporarily set hash for setup pm.hash_password.return_value = "HASHED_PASS" user = auth_service.register(test_user_data_create) self.user = user self.original_email = user.email self.original_username = user.username # Restore hash mock pm.hash_password.return_value = original_hash def test_i_can_update_user_email(self, auth_service: AuthService): """Success: Email can be updated and is_verified is automatically set to False.""" from myauth.models.user import UserUpdate new_email = "updated.email@example.com" updates = UserUpdate(email=new_email) updated_user = auth_service.update_user(self.user.id, updates) assert updated_user.email == new_email assert updated_user.is_verified is False def test_i_can_update_user_username(self, auth_service: AuthService): """Success: Username can be updated.""" from myauth.models.user import UserUpdate new_username = "UpdatedUsername" updates = UserUpdate(username=new_username) updated_user = auth_service.update_user(self.user.id, updates) assert updated_user.username == new_username def test_i_can_update_user_password(self, auth_service: AuthService): """Success: Password can be updated and is properly hashed.""" from myauth.models.user import UserUpdate pm = auth_service.password_manager new_password = "NewSecurePass123!" with patch.object(pm, 'hash_password', return_value="NEW_HASHED_PASSWORD") as mock_hash: updates = UserUpdate(password=new_password) updated_user = auth_service.update_user(self.user.id, updates) mock_hash.assert_called_once_with(new_password) assert updated_user.hashed_password == "NEW_HASHED_PASSWORD" def test_i_can_update_user_password_and_all_refresh_tokens_are_revoked( self, auth_service: AuthService ): """Success: Updating password revokes all refresh tokens when no current token provided.""" from myauth.models.user import UserUpdate # Setup: Create some refresh tokens for the user from myauth.models.token import TokenData from datetime import datetime, timedelta token1 = TokenData( token="refresh_token_1", token_type="refresh", user_id=self.user.id, expires_at=datetime.now() + timedelta(days=1), created_at=datetime.now(), is_revoked=False ) token2 = TokenData( token="refresh_token_2", token_type="refresh", user_id=self.user.id, expires_at=datetime.now() + timedelta(days=1), created_at=datetime.now(), is_revoked=False ) auth_service.token_repository.save_token(token1) auth_service.token_repository.save_token(token2) # Execute: Update password without providing current token updates = UserUpdate(password="NewPassword123!") auth_service.update_user(self.user.id, updates) # Verify: Both tokens are revoked token1_after = auth_service.token_repository.get_token("refresh_token_1", "refresh") token2_after = auth_service.token_repository.get_token("refresh_token_2", "refresh") assert token1_after.is_revoked is True assert token2_after.is_revoked is True def test_i_can_update_user_password_and_preserve_current_session( self, auth_service: AuthService ): """Success: Updating password preserves the current refresh token when provided.""" from myauth.models.user import UserUpdate from myauth.models.token import TokenData from datetime import datetime, timedelta # Setup: Create some refresh tokens current_token = TokenData( token="current_refresh_token", token_type="refresh", user_id=self.user.id, expires_at=datetime.now() + timedelta(days=1), created_at=datetime.now(), is_revoked=False ) other_token = TokenData( token="other_refresh_token", token_type="refresh", user_id=self.user.id, expires_at=datetime.now() + timedelta(days=1), created_at=datetime.now(), is_revoked=False ) auth_service.token_repository.save_token(current_token) auth_service.token_repository.save_token(other_token) # Execute: Update password while providing current token updates = UserUpdate(password="NewPassword123!") auth_service.update_user(self.user.id, updates, refresh_token="current_refresh_token") # Verify: Current token is preserved, other is revoked current_after = auth_service.token_repository.get_token("current_refresh_token", "refresh") other_after = auth_service.token_repository.get_token("other_refresh_token", "refresh") assert current_after.is_revoked is False assert other_after.is_revoked is True def test_i_can_update_multiple_fields_at_once(self, auth_service: AuthService): """Success: Multiple fields can be updated simultaneously.""" from myauth.models.user import UserUpdate updates = UserUpdate( username="MultiUpdateUser", roles=["admin", "member"], user_settings={"theme": "light", "language": "en"} ) updated_user = auth_service.update_user(self.user.id, updates) assert updated_user.username == "MultiUpdateUser" assert updated_user.roles == ["admin", "member"] assert updated_user.user_settings == {"theme": "light", "language": "en"} def test_i_can_update_user_roles(self, auth_service: AuthService): """Success: User roles can be updated.""" from myauth.models.user import UserUpdate new_roles = ["admin", "moderator"] updates = UserUpdate(roles=new_roles) updated_user = auth_service.update_user(self.user.id, updates) assert updated_user.roles == new_roles def test_i_can_update_user_settings(self, auth_service: AuthService): """Success: User settings can be updated.""" from myauth.models.user import UserUpdate new_settings = {"theme": "light", "notifications": True, "language": "fr"} updates = UserUpdate(user_settings=new_settings) updated_user = auth_service.update_user(self.user.id, updates) assert updated_user.user_settings == new_settings def test_i_can_update_is_active_status(self, auth_service: AuthService): """Success: User active status can be updated.""" from myauth.models.user import UserUpdate # Deactivate user updates = UserUpdate(is_active=False) updated_user = auth_service.update_user(self.user.id, updates) assert updated_user.is_active is False # Reactivate user updates = UserUpdate(is_active=True) updated_user = auth_service.update_user(self.user.id, updates) assert updated_user.is_active is True def test_i_cannot_update_user_with_invalid_user_id(self, auth_service: AuthService): """Failure: Updating a non-existent user raises UserNotFoundError.""" from myauth.models.user import UserUpdate from myauth.exceptions import UserNotFoundError updates = UserUpdate(username="ShouldFail") with pytest.raises(UserNotFoundError): auth_service.update_user("non_existent_id", updates) def test_i_cannot_update_email_to_existing_email(self, auth_service: AuthService): """Failure: Updating email to an already registered email raises UserAlreadyExistsError.""" from myauth.models.user import UserCreate, UserUpdate # Setup: Create another user with a different email other_user_data = UserCreate( email="other.user@example.com", username="OtherUser", password="OtherPass123!", roles=["member"] ) auth_service.register(other_user_data) # Execute: Try to update original user's email to the other user's email updates = UserUpdate(email="other.user@example.com") with pytest.raises(UserAlreadyExistsError): auth_service.update_user(self.user.id, updates) def test_i_cannot_update_email_to_same_email_without_triggering_verification_reset( self, auth_service: AuthService ): """Success: Updating email to the same email does not reset is_verified.""" from myauth.models.user import UserUpdate # Setup: Ensure user is verified verify_updates = UserUpdate(is_verified=True) auth_service.update_user(self.user.id, verify_updates) # Execute: Update with the same email updates = UserUpdate(email=self.original_email) updated_user = auth_service.update_user(self.user.id, updates) # Verify: is_verified should remain True assert updated_user.is_verified is True assert updated_user.email == self.original_email def test_i_can_update_with_empty_updates(self, auth_service: AuthService): """Success: Updating with empty UserUpdate does not cause errors.""" from myauth.models.user import UserUpdate updates = UserUpdate() updated_user = auth_service.update_user(self.user.id, updates) # Verify: User is returned and fields are unchanged assert updated_user.id == self.user.id assert updated_user.email == self.original_email assert updated_user.username == self.original_username def test_email_verification_reset_only_when_email_actually_changes( self, auth_service: AuthService ): """Success: is_verified is reset to False only when email actually changes.""" from myauth.models.user import UserUpdate # Setup: Set user as verified verify_updates = UserUpdate(is_verified=True) auth_service.update_user(self.user.id, verify_updates) verified_user = auth_service.user_repository.get_user_by_id(self.user.id) assert verified_user.is_verified is True # Test 1: Update with same email - verification should remain same_email_updates = UserUpdate(email=self.original_email, username="SameEmailTest") updated_user = auth_service.update_user(self.user.id, same_email_updates) assert updated_user.is_verified is True # Test 2: Update with different email - verification should reset different_email_updates = UserUpdate(email="completely.new@example.com") updated_user = auth_service.update_user(self.user.id, different_email_updates) assert updated_user.is_verified is False assert updated_user.email == "completely.new@example.com" # class TestAuthServiceResetVerification(object): # """Tests for password reset and email verification flows.""" # # @pytest.fixture(autouse=True) # def setup_user(self, auth_service: AuthService, test_user_data_create: UserCreate): # """Sets up a registered user using a mock hash for speed.""" # # pm = auth_service.password_manager # original_hash = pm.hash_password.return_value # # # Temporarily set hash for setup # pm.hash_password.return_value = "HASHED_PASS" # user = auth_service.register(test_user_data_create) # self.user = user # # # Restore hash mock # pm.hash_password.return_value = original_hash # # @patch('myauth.core.email.send_email') # def test_request_password_reset_success(self, mock_send_email: MagicMock, auth_service: AuthService): # """Success: Requesting a password reset generates a token and sends an email.""" # # tm = auth_service.token_manager # with patch.object(tm, 'create_password_reset_token', # return_value="MOCKED_RESET_TOKEN") as mock_create_token: # token_string = auth_service.request_password_reset(self.user.email) # # assert token_string == "MOCKED_RESET_TOKEN" # mock_create_token.assert_called_once() # mock_send_email.assert_called_once() # # def test_reset_password_success(self, auth_service: AuthService): # """Success: Resetting the password works with a valid token.""" # # # Setup: Manually create a valid reset token # auth_service.token_repository.save_token( # TokenData(token="valid_reset_token", token_type="password_reset", user_id=self.user.id, # expires_at=datetime.now() + timedelta(minutes=10)) # ) # # # Patch the PasswordManager instance to control the hash output # pm = auth_service.password_manager # with patch.object(pm, 'hash_password', # return_value="NEW_HASHED_PASSWORD_FOR_RESET") as mock_hash: # new_password = "NewPassword123!" # result = auth_service.reset_password("valid_reset_token", new_password) # # assert result is True # mock_hash.assert_called_once_with(new_password) # # # Verification: Check that user data was updated # updated_user = auth_service.user_repository.get_user_by_id(self.user.id) # assert updated_user.hashed_password == "NEW_HASHED_PASSWORD_FOR_RESET" # # @patch('myauth.core.email.send_email') # def test_request_email_verification_success(self, mock_send_email: MagicMock, auth_service: AuthService): # """Success: Requesting verification generates a token and sends an email.""" # # tm = auth_service.token_manager # with patch.object(tm, 'create_email_verification_token', # return_value="MOCKED_JWT_VERIFY_TOKEN") as mock_create_token: # token_string = auth_service.request_email_verification(self.user.email) # # assert token_string == "MOCKED_JWT_VERIFY_TOKEN" # mock_create_token.assert_called_once_with(self.user.email) # mock_send_email.assert_called_once() # # def test_verify_email_success(self, auth_service: AuthService): # """Success: Verification updates the user's status.""" # # # The token_manager is mocked in conftest, so we must access its real create method # # or rely on the mock's return value to get a token string to use in the call. # # Since we need a real token for the decode logic to pass, we need to bypass the mock here. # # # We will temporarily use the real TokenManager to create a valid, decodable token. # # This requires an *unmocked* token manager instance, which is tricky in this setup. # # # Alternative: Temporarily inject a real TokenManager for this test (or rely on a non-mocked method) # # # Assuming TokenManager.create_email_verification_token can be mocked to return a static string # # and TokenManager.decode_email_verification_token can be patched to simulate success. # # # Since the method calls decode_email_verification_token internally, we mock the output of the decode step. # # # Setup: Ensure user is unverified # auth_service.user_repository.update_user(self.user.id, UserUpdate(is_verified=False)) # # tm = auth_service.token_manager # # # Mock the decode step to ensure it returns the email used for verification # with patch.object(tm, 'decode_email_verification_token', return_value=self.user.email) as mock_decode: # # Test (we use a dummy token string as the decode step is mocked) # result = auth_service.verify_email("dummy_verification_token") # # assert result is True # mock_decode.assert_called_once() # # # Verification: User is verified # updated_user = auth_service.user_repository.get_user_by_id(self.user.id) # assert updated_user.is_verified is True