mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
fix(backend): persist keycloak email on invitation acceptance (#14059)
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user