diff --git a/enterprise/server/services/org_invitation_service.py b/enterprise/server/services/org_invitation_service.py index 29fb1d1e96..f0cf3d3ee8 100644 --- a/enterprise/server/services/org_invitation_service.py +++ b/enterprise/server/services/org_invitation_service.py @@ -313,11 +313,22 @@ class OrgInvitationService: raise InvitationInvalidError('User not found') user_email = user.email - # Fallback: fetch email from Keycloak if not in database (for existing users) + # Fallback: fetch email from Keycloak if not in database (for existing users). + # When found, persist it back to User.email so the members list shows it + # without requiring the user to log out and log back in. if not user_email: token_manager = TokenManager() user_info = await token_manager.get_user_info_from_user_id(str(user_id)) - user_email = user_info.get('email') if user_info else None + if user_info: + user_email = user_info.get('email') + if user_email: + await UserStore.backfill_user_email( + str(user_id), + { + 'email': user_email, + 'email_verified': user_info.get('emailVerified', False), + }, + ) if not user_email: raise EmailMismatchError('Your account does not have an email address') diff --git a/enterprise/tests/unit/test_org_invitation_service.py b/enterprise/tests/unit/test_org_invitation_service.py index f7cd0c885b..c0926a9a13 100644 --- a/enterprise/tests/unit/test_org_invitation_service.py +++ b/enterprise/tests/unit/test_org_invitation_service.py @@ -136,6 +136,10 @@ class TestAcceptInvitationEmailValidation: 'server.services.org_invitation_service.OrgInvitationStore.update_invitation_status', new_callable=AsyncMock, ) as mock_update_status, + patch( + 'server.services.org_invitation_service.UserStore.backfill_user_email', + new_callable=AsyncMock, + ), ): mock_get_invitation.return_value = mock_invitation mock_is_expired.return_value = False @@ -163,6 +167,98 @@ class TestAcceptInvitationEmailValidation: str(user_id) ) + @pytest.mark.asyncio + async def test_accept_invitation_user_no_email_keycloak_fallback_persists_email( + self, mock_invitation + ): + """When User.email is NULL and Keycloak returns an email, the email is + persisted back to the User record (normalized to snake_case) so the + members list shows it without requiring the user to log out and back in. + """ + # Arrange + user_id = UUID('87654321-4321-8765-4321-876543218765') + token = 'inv-test-token-12345' + + mock_user = MagicMock() + mock_user.id = user_id + mock_user.email = None + + # Keycloak admin API returns camelCase `emailVerified`. + mock_keycloak_user_info = { + 'email': 'alice@example.com', + 'emailVerified': True, + } + + mock_org = MagicMock() + mock_org.agent_settings = {'llm': {'model': 'test-model'}} + + with ( + patch( + 'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token', + new_callable=AsyncMock, + ) as mock_get_invitation, + patch( + 'server.services.org_invitation_service.OrgInvitationStore.is_token_expired' + ) as mock_is_expired, + patch( + 'server.services.org_invitation_service.UserStore.get_user_by_id', + new_callable=AsyncMock, + ) as mock_get_user, + patch( + 'server.services.org_invitation_service.TokenManager' + ) as mock_token_manager_class, + patch( + 'server.services.org_invitation_service.OrgMemberStore.get_org_member', + new_callable=AsyncMock, + ) as mock_get_member, + patch( + 'server.services.org_invitation_service.OrgService.create_litellm_integration', + new_callable=AsyncMock, + ) as mock_create_litellm, + patch( + 'server.services.org_invitation_service.OrgStore.get_org_by_id', + new_callable=AsyncMock, + ) as mock_get_org, + patch( + 'server.services.org_invitation_service.OrgMemberStore.add_user_to_org', + new_callable=AsyncMock, + ), + patch( + 'server.services.org_invitation_service.OrgInvitationStore.update_invitation_status', + new_callable=AsyncMock, + ) as mock_update_status, + patch( + 'server.services.org_invitation_service.UserStore.backfill_user_email', + new_callable=AsyncMock, + ) as mock_backfill, + ): + mock_get_invitation.return_value = mock_invitation + mock_is_expired.return_value = False + mock_get_user.return_value = mock_user + + mock_token_manager = MagicMock() + mock_token_manager.get_user_info_from_user_id = AsyncMock( + return_value=mock_keycloak_user_info + ) + mock_token_manager_class.return_value = mock_token_manager + + mock_get_member.return_value = None + mock_settings = MagicMock() + mock_settings.llm_api_key = SecretStr('test-key') + mock_create_litellm.return_value = mock_settings + mock_get_org.return_value = mock_org + mock_update_status.return_value = mock_invitation + + # Act + await OrgInvitationService.accept_invitation(token, user_id) + + # Assert — persisted with snake_case `email_verified` derived from + # Keycloak's camelCase `emailVerified`. + mock_backfill.assert_awaited_once_with( + str(user_id), + {'email': 'alice@example.com', 'email_verified': True}, + ) + @pytest.mark.asyncio async def test_accept_invitation_no_email_anywhere_raises_error( self, mock_invitation