diff --git a/components/org.wso2.carbon.identity.api.user.governance/src/main/java/org/wso2/carbon/identity/user/endpoint/impl/MeApiServiceImpl.java b/components/org.wso2.carbon.identity.api.user.governance/src/main/java/org/wso2/carbon/identity/user/endpoint/impl/MeApiServiceImpl.java index cfc126baf2..6516a669eb 100644 --- a/components/org.wso2.carbon.identity.api.user.governance/src/main/java/org/wso2/carbon/identity/user/endpoint/impl/MeApiServiceImpl.java +++ b/components/org.wso2.carbon.identity.api.user.governance/src/main/java/org/wso2/carbon/identity/user/endpoint/impl/MeApiServiceImpl.java @@ -280,7 +280,9 @@ private String getRecoveryScenarioFromProperties(List propertyDTOS) RecoveryScenarios.ADMIN_FORCED_PASSWORD_RESET_VIA_EMAIL_LINK.toString().equals(recoveryScenario) || RecoveryScenarios.ADMIN_FORCED_PASSWORD_RESET_VIA_OTP.toString().equals(recoveryScenario) || RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario) || - RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario)) { + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario) || + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario) || + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario)) { return recoveryScenario; } @@ -293,7 +295,8 @@ private NotificationResponseBean doResendConfirmationCode(String recoveryScenari UserRecoveryData userRecoveryData = null; // Currently this me/resend-code API supports resend code during mobile verification scenario only. - if (RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario)) { + if (RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario) || + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario)) { userRecoveryData = Utils.getUserRecoveryData(resendCodeRequestDTO, recoveryScenario); } if (userRecoveryData == null) { @@ -312,6 +315,18 @@ private NotificationResponseBean doResendConfirmationCode(String recoveryScenari resendCodeRequestDTO); } + if (RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario) && + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE + .equals(userRecoveryData.getRecoveryScenario()) && + RecoverySteps.VERIFY_MOBILE_NUMBER.equals(userRecoveryData.getRecoveryStep())) { + + notificationResponseBean = setNotificationResponseBean(resendConfirmationManager, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString(), + RecoverySteps.VERIFY_MOBILE_NUMBER.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_MOBILE_ON_UPDATE, + resendCodeRequestDTO); + } + return notificationResponseBean; } diff --git a/components/org.wso2.carbon.identity.api.user.governance/src/main/java/org/wso2/carbon/identity/user/endpoint/impl/ResendCodeApiServiceImpl.java b/components/org.wso2.carbon.identity.api.user.governance/src/main/java/org/wso2/carbon/identity/user/endpoint/impl/ResendCodeApiServiceImpl.java index f4c1efb666..f74188b799 100644 --- a/components/org.wso2.carbon.identity.api.user.governance/src/main/java/org/wso2/carbon/identity/user/endpoint/impl/ResendCodeApiServiceImpl.java +++ b/components/org.wso2.carbon.identity.api.user.governance/src/main/java/org/wso2/carbon/identity/user/endpoint/impl/ResendCodeApiServiceImpl.java @@ -187,6 +187,14 @@ private NotificationResponseBean doResendConfirmationCode(String recoveryScenari notificationResponseBean = setNotificationResponseBean(resendConfirmationManager, RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.toString(), RecoverySteps.VERIFY_EMAIL.toString(), IdentityRecoveryConstants.NOTIFICATION_TYPE_RESEND_VERIFY_EMAIL_ON_UPDATE, resendCodeRequestDTO); + } else if (RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario) && + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE + .equals(userRecoveryData.getRecoveryScenario()) && + RecoverySteps.VERIFY_EMAIL.equals(userRecoveryData.getRecoveryStep())) { + notificationResponseBean = setNotificationResponseBean(resendConfirmationManager, + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString(), + RecoverySteps.VERIFY_EMAIL.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_RESEND_VERIFY_EMAIL_ON_UPDATE, resendCodeRequestDTO); } else if (RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario) && RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.equals(userRecoveryData.getRecoveryScenario()) && RecoverySteps.VERIFY_MOBILE_NUMBER.equals(userRecoveryData.getRecoveryStep())) { @@ -194,6 +202,14 @@ private NotificationResponseBean doResendConfirmationCode(String recoveryScenari RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString(), RecoverySteps.VERIFY_MOBILE_NUMBER.toString(), IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_MOBILE_ON_UPDATE, resendCodeRequestDTO); + } else if (RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario) && + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE + .equals(userRecoveryData.getRecoveryScenario()) && + RecoverySteps.VERIFY_MOBILE_NUMBER.equals(userRecoveryData.getRecoveryStep())) { + notificationResponseBean = setNotificationResponseBean(resendConfirmationManager, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString(), + RecoverySteps.VERIFY_MOBILE_NUMBER.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_MOBILE_ON_UPDATE, resendCodeRequestDTO); } return notificationResponseBean; diff --git a/components/org.wso2.carbon.identity.api.user.governance/src/test/java/org/wso2/carbon/identity/user/endpoint/impl/MeApiServiceImplTest.java b/components/org.wso2.carbon.identity.api.user.governance/src/test/java/org/wso2/carbon/identity/user/endpoint/impl/MeApiServiceImplTest.java index 6643281440..f040bde5ee 100644 --- a/components/org.wso2.carbon.identity.api.user.governance/src/test/java/org/wso2/carbon/identity/user/endpoint/impl/MeApiServiceImplTest.java +++ b/components/org.wso2.carbon.identity.api.user.governance/src/test/java/org/wso2/carbon/identity/user/endpoint/impl/MeApiServiceImplTest.java @@ -24,6 +24,7 @@ import org.mockito.MockitoAnnotations; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import org.wso2.carbon.base.CarbonBaseConstants; import org.wso2.carbon.context.PrivilegedCarbonContext; @@ -165,23 +166,68 @@ public void testMeResendCodePost() throws IdentityRecoveryException { Mockito.when(userRecoveryData.getRecoveryStep()).thenReturn( RecoverySteps.getRecoveryStep("VERIFY_MOBILE_NUMBER")); - assertEquals(meApiService.meResendCodePost(meResendCodeRequestDTO()).getStatus(), 201); + assertEquals(meApiService.meResendCodePost( + meResendCodeRequestDTO(RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.name())).getStatus(), + 201); + assertEquals(meApiService.meResendCodePost( meResendCodeRequestDTOWithInvalidScenarioProperty()).getStatus(), 400); mockedUtils.when(() -> Utils.getUserRecoveryData(any(ResendCodeRequestDTO.class), anyString())) .thenReturn(null); - assertEquals(meApiService.meResendCodePost(meResendCodeRequestDTO()).getStatus(), 400); + assertEquals(meApiService.meResendCodePost( + meResendCodeRequestDTO(RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.name())).getStatus(), + 400); Mockito.when(userRecoveryData.getRecoveryScenario()).thenReturn(RecoveryScenarios. getRecoveryScenario("ASK_PASSWORD")); - assertEquals(meApiService.meResendCodePost(meResendCodeRequestDTO()).getStatus(), 400); + assertEquals(meApiService.meResendCodePost( + meResendCodeRequestDTO(RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.name())).getStatus(), + 400); + } finally { + PrivilegedCarbonContext.endTenantFlow(); + } + } + + @DataProvider(name = "recoveryScenarioProvider") + public Object[][] recoveryScenarioProvider() { + return new Object[][] { + {RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL, 400}, + {RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_EMAIL, 400}, + {RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, 201}, + {RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER, 201}, + {RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, 201} + }; + } + + @Test(dataProvider = "recoveryScenarioProvider") + public void testMeResendCodePostRecoveryScenarios(RecoveryScenarios recoveryScenario, RecoverySteps recoveryStep, + int expectedStatusCode) throws IdentityRecoveryException { + + try { + String carbonHome = Paths.get(System.getProperty("user.dir"), "src", "test", "resources").toString(); + System.setProperty(CarbonBaseConstants.CARBON_HOME, carbonHome); + PrivilegedCarbonContext.startTenantFlow(); + PrivilegedCarbonContext.getThreadLocalCarbonContext().setUsername(USERNAME); + PrivilegedCarbonContext.getThreadLocalCarbonContext().setTenantId(-1234); + Mockito.when(resendConfirmationManager.resendConfirmationCode(isNull(), anyString(), anyString(), + anyString(), isNull())).thenReturn(notificationResponseBean); + mockedUtils.when(() -> Utils.getUserRecoveryData(any(ResendCodeRequestDTO.class), anyString())) + .thenReturn(userRecoveryData); + mockedUtils.when(Utils::getResendConfirmationManager).thenReturn(resendConfirmationManager); + Mockito.when(userRecoveryData.getRecoveryScenario()).thenReturn(recoveryScenario); + Mockito.when(userRecoveryData.getRecoveryStep()).thenReturn(recoveryStep); + + assertEquals(meApiService.meResendCodePost( + meResendCodeRequestDTO(recoveryScenario.name())).getStatus(), + expectedStatusCode); } finally { PrivilegedCarbonContext.endTenantFlow(); } } - private SelfRegistrationUserDTO buildSelfRegistartion() { + private SelfRegistrationUserDTO buildSelfRegistration() { SelfRegistrationUserDTO selfRegistrationUserDTO = new SelfRegistrationUserDTO(); selfRegistrationUserDTO.setUsername("TestUser"); @@ -212,11 +258,11 @@ private SelfUserRegistrationRequestDTO selfUserRegistrationRequestDTO() { SelfUserRegistrationRequestDTO selfUserRegistrationRequestDTO = new SelfUserRegistrationRequestDTO(); List listClaimDTO = new ArrayList<>(); listClaimDTO.add(buildClaimDTO()); - buildSelfRegistartion().setClaims(listClaimDTO); + buildSelfRegistration().setClaims(listClaimDTO); List listPropertyDTOs = new ArrayList<>(); listPropertyDTOs.add(buildSelfUserRegistrationRequestDTO()); selfUserRegistrationRequestDTO.setProperties(listPropertyDTOs); - selfUserRegistrationRequestDTO.setUser(buildSelfRegistartion()); + selfUserRegistrationRequestDTO.setUser(buildSelfRegistration()); return selfUserRegistrationRequestDTO; } @@ -227,11 +273,11 @@ private MeCodeValidationRequestDTO createMeCodeValidationRequestDTO() { return codeValidationRequestDTO; } - private MeResendCodeRequestDTO meResendCodeRequestDTO() { + private MeResendCodeRequestDTO meResendCodeRequestDTO(String recoveryScenario) { MeResendCodeRequestDTO meResendCodeRequestDTO = new MeResendCodeRequestDTO(); List listProperty = new ArrayList<>(); - listProperty.add(buildPropertyDTO("RecoveryScenario", "MOBILE_VERIFICATION_ON_UPDATE")); + listProperty.add(buildPropertyDTO("RecoveryScenario", recoveryScenario)); meResendCodeRequestDTO.setProperties(listProperty); return meResendCodeRequestDTO; } diff --git a/components/org.wso2.carbon.identity.api.user.governance/src/test/java/org/wso2/carbon/identity/user/endpoint/impl/ResendCodeApiServiceImplTest.java b/components/org.wso2.carbon.identity.api.user.governance/src/test/java/org/wso2/carbon/identity/user/endpoint/impl/ResendCodeApiServiceImplTest.java index 65349ba385..8b949b7624 100644 --- a/components/org.wso2.carbon.identity.api.user.governance/src/test/java/org/wso2/carbon/identity/user/endpoint/impl/ResendCodeApiServiceImplTest.java +++ b/components/org.wso2.carbon.identity.api.user.governance/src/test/java/org/wso2/carbon/identity/user/endpoint/impl/ResendCodeApiServiceImplTest.java @@ -23,13 +23,18 @@ import org.mockito.MockitoAnnotations; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import org.wso2.carbon.identity.application.authentication.framework.internal.FrameworkServiceDataHolder; +import org.wso2.carbon.identity.application.common.model.User; import org.wso2.carbon.identity.core.util.IdentityUtil; import org.wso2.carbon.identity.multi.attribute.login.mgt.MultiAttributeLoginService; import org.wso2.carbon.identity.recovery.IdentityRecoveryClientException; import org.wso2.carbon.identity.recovery.IdentityRecoveryException; +import org.wso2.carbon.identity.recovery.RecoveryScenarios; +import org.wso2.carbon.identity.recovery.RecoverySteps; import org.wso2.carbon.identity.recovery.bean.NotificationResponseBean; +import org.wso2.carbon.identity.recovery.confirmation.ResendConfirmationManager; import org.wso2.carbon.identity.recovery.model.UserRecoveryData; import org.wso2.carbon.identity.recovery.signup.UserSelfRegistrationManager; import org.wso2.carbon.identity.user.endpoint.dto.PropertyDTO; @@ -39,8 +44,13 @@ import java.util.ArrayList; import java.util.List; +import javax.ws.rs.core.Response; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; /** * This class contains unit tests for ResendCodeApiServiceImpl.java @@ -62,9 +72,16 @@ public class ResendCodeApiServiceImplTest { @Mock private MultiAttributeLoginService mockedMultiAttributeLoginService; + @Mock + private ResendConfirmationManager resendConfirmationManager; + @InjectMocks private ResendCodeApiServiceImpl resendCodeApiService; + private final String TEST_USERNAME = "testUser"; + private final String TEST_TENANT_DOMAIN = "testTenantDomain"; + private static final String RECOVERY_SCENARIO_KEY = "RecoveryScenario"; + @BeforeMethod public void setUp() { @@ -86,27 +103,33 @@ public void tearDown() { @Test public void testResendCodePost() throws IdentityRecoveryException { - Mockito.when(userSelfRegistrationManager.resendConfirmationCode( + when(userSelfRegistrationManager.resendConfirmationCode( Utils.getUser(resendCodeRequestDTO().getUser()), Utils.getProperties(resendCodeRequestDTO().getProperties()))).thenReturn(notificationResponseBean); + assertEquals(resendCodeApiService.resendCodePost(resendCodeRequestDTO()).getStatus(), 201); assertEquals(resendCodeApiService.resendCodePost(emptyResendCodeRequestDTO()).getStatus(), 201); - assertEquals(resendCodeApiService.resendCodePost(emptyPropertyResendCodeRequestDTO()).getStatus(), 201); + assertEquals(resendCodeApiService.resendCodePost(emptyPropertyResendCodeRequestDTO()).getStatus(), + 201); assertEquals(resendCodeApiService.resendCodePost(multipleResendCodeRequestDTO()).getStatus(), 201); - mockedUtils.when(() -> Utils.getUserRecoveryData(recoveryScenarioResendCodeRequestDTO())).thenReturn(null); - assertEquals(resendCodeApiService.resendCodePost(recoveryScenarioResendCodeRequestDTO()).getStatus(), 400); + mockedUtils.when(() -> Utils.getUserRecoveryData(recoveryScenarioResendCodeRequestDTO())) + .thenReturn(null); + assertEquals(resendCodeApiService.resendCodePost(recoveryScenarioResendCodeRequestDTO()).getStatus(), + 400); mockedUtils.when(() -> Utils.getUserRecoveryData(recoveryScenarioResendCodeRequestDTO())).thenReturn( userRecoveryData); - assertEquals(resendCodeApiService.resendCodePost(recoveryScenarioResendCodeRequestDTO()).getStatus(), 400); - assertEquals(resendCodeApiService.resendCodePost(duplicateScenarioResendCodeRequestDTO()).getStatus(), 201); + assertEquals(resendCodeApiService.resendCodePost(recoveryScenarioResendCodeRequestDTO()).getStatus(), + 400); + assertEquals(resendCodeApiService.resendCodePost(duplicateScenarioResendCodeRequestDTO()).getStatus(), + 201); } @Test public void testIdentityRecoveryExceptioninResendCodePost() throws IdentityRecoveryException { - Mockito.when(userSelfRegistrationManager.resendConfirmationCode( + when(userSelfRegistrationManager.resendConfirmationCode( Utils.getUser(resendCodeRequestDTO().getUser()), Utils.getProperties(resendCodeRequestDTO().getProperties()))).thenThrow(new IdentityRecoveryException("Recovery Exception")); assertEquals(resendCodeApiService.resendCodePost(resendCodeRequestDTO()).getStatus(), 400); @@ -115,7 +138,7 @@ public void testIdentityRecoveryExceptioninResendCodePost() throws IdentityRecov @Test public void testIdentityRecoveryClientExceptioninResendCodePost() throws IdentityRecoveryException { - Mockito.when(userSelfRegistrationManager.resendConfirmationCode( + when(userSelfRegistrationManager.resendConfirmationCode( Utils.getUser(resendCodeRequestDTO().getUser()), Utils.getProperties(resendCodeRequestDTO().getProperties()))).thenThrow(new IdentityRecoveryClientException("Recovery Exception")); assertEquals(resendCodeApiService.resendCodePost(resendCodeRequestDTO()).getStatus(), 400); @@ -206,4 +229,100 @@ private PropertyDTO recoveryScenarioPropertyDTO() { propertyDTO.setValue("ASK_PASSWORD"); return propertyDTO; } + + @DataProvider(name = "recoveryScenarioProvider") + public Object[][] recoveryScenarioProvider() { + + return new Object[][] { + {RecoveryScenarios.ASK_PASSWORD, RecoverySteps.UPDATE_PASSWORD, RecoveryScenarios.ASK_PASSWORD, + RecoverySteps.UPDATE_PASSWORD, 201}, + {RecoveryScenarios.NOTIFICATION_BASED_PW_RECOVERY, RecoverySteps.UPDATE_PASSWORD, + RecoveryScenarios.NOTIFICATION_BASED_PW_RECOVERY, RecoverySteps.UPDATE_PASSWORD, 201}, + {RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.CONFIRM_SIGN_UP, RecoveryScenarios.SELF_SIGN_UP, + RecoverySteps.CONFIRM_SIGN_UP, 201}, + {RecoveryScenarios.ADMIN_FORCED_PASSWORD_RESET_VIA_EMAIL_LINK, RecoverySteps.UPDATE_PASSWORD, + RecoveryScenarios.ADMIN_FORCED_PASSWORD_RESET_VIA_EMAIL_LINK, RecoverySteps.UPDATE_PASSWORD, 201}, + {RecoveryScenarios.ADMIN_FORCED_PASSWORD_RESET_VIA_OTP, RecoverySteps.UPDATE_PASSWORD, + RecoveryScenarios.ADMIN_FORCED_PASSWORD_RESET_VIA_OTP, RecoverySteps.UPDATE_PASSWORD, 201}, + {RecoveryScenarios.TENANT_ADMIN_ASK_PASSWORD, RecoverySteps.UPDATE_PASSWORD, + RecoveryScenarios.TENANT_ADMIN_ASK_PASSWORD, RecoverySteps.UPDATE_PASSWORD, 201}, + {RecoveryScenarios.LITE_SIGN_UP, RecoverySteps.CONFIRM_LITE_SIGN_UP, RecoveryScenarios.LITE_SIGN_UP, + RecoverySteps.CONFIRM_LITE_SIGN_UP, 201}, + + {RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL, 201}, + {RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL, + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_EMAIL, 400}, + {RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, 400}, + + {RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_EMAIL, + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_EMAIL, 201}, + {RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_EMAIL, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL, 400}, + {RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_EMAIL, + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER, 400}, + + {RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, 201}, + {RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER, 400}, + {RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL, 400}, + + {RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER, 201}, + {RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER, 400}, + {RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + RecoverySteps.VERIFY_EMAIL, 400}, + }; + } + + @Test(dataProvider = "recoveryScenarioProvider") + public void testRecoveryScenarios(RecoveryScenarios scenario, RecoverySteps step, + RecoveryScenarios userRecoveryDataScenario, RecoverySteps userRecoveryDataStep, + int expectedStatusCode) throws Exception { + + ResendCodeRequestDTO requestDTO = createResendCodeRequestDTO(scenario.name()); + User user = new User(); + UserRecoveryData recoveryData = new UserRecoveryData(user, "test-secret", + userRecoveryDataScenario, userRecoveryDataStep); + when(Utils.getUserRecoveryData(any(), anyString())).thenReturn(recoveryData); + when(Utils.getResendConfirmationManager()).thenReturn(resendConfirmationManager); + + NotificationResponseBean expectedResponse = new NotificationResponseBean(user); + expectedResponse.setKey("test-key"); + when(resendConfirmationManager.resendConfirmationCode(any(), anyString(), anyString(), anyString(), any())) + .thenReturn(expectedResponse); + + Response result = resendCodeApiService.resendCodePost(requestDTO); + + assertNotNull(result); + assertEquals(result.getStatus(), expectedStatusCode); + } + + private ResendCodeRequestDTO createResendCodeRequestDTO(String recoveryScenario) { + + ResendCodeRequestDTO requestDTO = new ResendCodeRequestDTO(); + UserDTO userDTO = new UserDTO(); + userDTO.setTenantDomain(TEST_TENANT_DOMAIN); + userDTO.setUsername(TEST_USERNAME); + requestDTO.setUser(userDTO); + + List properties = new ArrayList<>(); + + PropertyDTO propertyDTO = new PropertyDTO(); + propertyDTO.setKey(RECOVERY_SCENARIO_KEY); + propertyDTO.setValue(recoveryScenario); + + properties.add(propertyDTO); + requestDTO.setProperties(properties); + return requestDTO; + } } diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/IdentityRecoveryConstants.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/IdentityRecoveryConstants.java index c613a26c5f..0bbe6e9956 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/IdentityRecoveryConstants.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/IdentityRecoveryConstants.java @@ -1,17 +1,19 @@ /* - * Copyright (c) 2016, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * Copyright (c) 2016-2024, WSO2 LLC. (http://www.wso2.com). * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.wso2.carbon.identity.recovery; @@ -123,6 +125,10 @@ public class IdentityRecoveryConstants { public static final String USER_ROLES_CLAIM = "http://wso2.org/claims/roles"; public static final String EMAIL_ADDRESS_CLAIM = "http://wso2.org/claims/emailaddress"; public static final String MOBILE_NUMBER_CLAIM = "http://wso2.org/claims/mobile"; + public static final String MOBILE_NUMBERS_CLAIM = "http://wso2.org/claims/mobileNumbers"; + public static final String VERIFIED_MOBILE_NUMBERS_CLAIM = "http://wso2.org/claims/verifiedMobileNumbers"; + public static final String EMAIL_ADDRESSES_CLAIM = "http://wso2.org/claims/emailAddresses"; + public static final String VERIFIED_EMAIL_ADDRESSES_CLAIM = "http://wso2.org/claims/verifiedEmailAddresses"; public static final String DEFAULT_CHALLENGE_QUESTION_SEPARATOR = "!"; public static final String ACCOUNT_STATE_CLAIM_URI = "http://wso2.org/claims/identity/accountState"; public static final String PENDING_SELF_REGISTRATION = "PENDING_SR"; @@ -213,6 +219,9 @@ public class IdentityRecoveryConstants { public static final String ACCOUNT_STATUS_DISABLED = "password.recovery.failed.account.disabled"; public static final String IGNORE_IF_TEMPLATE_NOT_FOUND = "ignoreIfTemplateNotFound"; + public static final String TRUE = "true"; + public static final String FALSE = "false"; + private IdentityRecoveryConstants() { } @@ -447,9 +456,34 @@ public enum ErrorMessages { // UEV - User Email Verification. ERROR_CODE_VERIFICATION_EMAIL_NOT_FOUND("UEV-10001", "Email address not found for email verification"), + ERROR_CODE_EMAIL_VERIFICATION_NOT_ENABLED("UEV-10002", "Email verification is not enabled"), + ERROR_CODE_VERIFY_MULTIPLE_EMAILS("UEV-10003", "Unable to verify multiple email addresses " + + "simultaneously"), + ERROR_CODE_SUPPORT_MULTIPLE_EMAILS_NOT_ENABLED("UEV-10004", "Support for multiple email addresses " + + "per user is not enabled"), + ERROR_CODE_PRIMARY_EMAIL_SHOULD_BE_INCLUDED_IN_EMAILS_LIST("UEV-10005", "As multiple " + + "email addresses support is enabled, primary email address should be included in the email " + + "addresses list."), + ERROR_CODE_PRIMARY_EMAIL_SHOULD_BE_INCLUDED_IN_VERIFIED_EMAILS_LIST("UEV-10006", "As multiple " + + "email addresses support and email verification is enabled, primary email address should be included " + + "in the verified email addresses list."), + + // UMV - User Mobile Verification. + ERROR_CODE_MOBILE_VERIFICATION_NOT_ENABLED("UMV-10001", " Verified mobile numbers claim cannot be" + + " updated as mobile number verification on update is disabled."), + ERROR_CODE_VERIFY_MULTIPLE_MOBILE_NUMBERS("UMV-10002", "Unable to verify " + + "multiple mobile numbers simultaneously."), + ERROR_CODE_SUPPORT_MULTIPLE_MOBILE_NUMBERS_NOT_ENABLED("UEV-10003", "Support for multiple mobile " + + "numbers per user is not enabled"), + ERROR_CODE_PRIMARY_MOBILE_NUMBER_SHOULD_BE_INCLUDED_IN_MOBILE_NUMBERS_LIST("UMV-10004", + "As multiple mobile numbers support is enabled, primary mobile number should be included in " + + "the mobile numbers list."), + ERROR_CODE_PRIMARY_MOBILE_NUMBER_SHOULD_BE_INCLUDED_IN_VERIFIED_MOBILES_LIST("UMV-10005", "As " + + "multiple mobile numbers support and mobile verification is enabled, primary mobile number should be " + + "included in the verified mobile numbers list."), + + INVALID_PASSWORD_RECOVERY_REQUEST("APR-10000", "Invalid Password Recovery Request"), - INVALID_PASSWORD_RECOVERY_REQUEST("APR-10000", "Invalid Password Recovery Request") - , // Idle User Account Identification related Error messages. ERROR_RETRIEVING_ASSOCIATED_USER("UMM-65005", "Error retrieving the associated user for the user: %s in the tenant %s."); @@ -667,6 +701,9 @@ public static class ConnectorConfig { public static final String ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER = "UserClaimUpdate.MobileNumber." + "EnableVerificationByPrivilegedUser"; public static final String USE_VERIFY_CLAIM_ON_UPDATE = "UserClaimUpdate.UseVerifyClaim"; + // This config enables the support to store multiple mobile numbers and email addresses per user. + public static final String SUPPORT_MULTI_EMAILS_AND_MOBILE_NUMBERS_PER_USER = + "UserClaimUpdate.EnableMultipleEmailsAndMobileNumbers"; public static final String ASK_PASSWORD_EXPIRY_TIME = "EmailVerification.AskPassword.ExpiryTime"; public static final String ASK_PASSWORD_TEMP_PASSWORD_GENERATOR = "EmailVerification.AskPassword.PasswordGenerator"; public static final String ASK_PASSWORD_DISABLE_RANDOM_VALUE_FOR_CREDENTIALS = "EmailVerification.AskPassword" + @@ -873,7 +910,11 @@ public enum SkipEmailVerificationOnUpdateStates { /* State maintained to skip triggering an email verification, when the email address was updated by user during the Email OTP flow at the first login where the email address is not previously set. At the moment email address was already verified during the email OTP verification. So no need to verify it again. */ - SKIP_ON_EMAIL_OTP_FLOW + SKIP_ON_EMAIL_OTP_FLOW, + + /* State maintained to skip triggering an email verification, when the email address to be updated is included + in the verifiedEmailAddresses claim, which has been already verified. */ + SKIP_ON_ALREADY_VERIFIED_EMAIL_ADDRESSES } /** @@ -980,6 +1021,10 @@ public enum SkipMobileNumberVerificationOnUpdateStates { /* State maintained to skip triggering an SMS OTP verification, when the mobile number was updated by user during the SMS OTP flow at the first login where the mobile number is not previously set. At the moment mobile number was already verified during the SMS OTP verification. So no need to verify it again. */ - SKIP_ON_SMS_OTP_FLOW + SKIP_ON_SMS_OTP_FLOW, + + /* State maintained to skip triggering an SMS OTP verification, when the mobile number to be updated is included + in the verifiedMobileNumbers claim, which has been already verified. */ + SKIP_ON_ALREADY_VERIFIED_MOBILE_NUMBERS } } diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/RecoveryScenarios.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/RecoveryScenarios.java index dc07ac6471..3c0a765917 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/RecoveryScenarios.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/RecoveryScenarios.java @@ -31,7 +31,9 @@ public enum RecoveryScenarios { ADMIN_FORCED_PASSWORD_RESET_VIA_EMAIL_LINK, ADMIN_FORCED_PASSWORD_RESET_VIA_OTP, EMAIL_VERIFICATION_ON_UPDATE, + EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, MOBILE_VERIFICATION_ON_UPDATE, + MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, LITE_SIGN_UP, TENANT_ADMIN_ASK_PASSWORD, PASSWORD_EXPIRY; @@ -49,7 +51,8 @@ public static RecoveryScenarios getRecoveryScenario(String scenarioName) throws NOTIFICATION_BASED_PW_RECOVERY, QUESTION_BASED_PWD_RECOVERY, USERNAME_RECOVERY, SELF_SIGN_UP, ASK_PASSWORD, ADMIN_FORCED_PASSWORD_RESET_VIA_EMAIL_LINK, ADMIN_FORCED_PASSWORD_RESET_VIA_OTP, LITE_SIGN_UP, TENANT_ADMIN_ASK_PASSWORD, EMAIL_VERIFICATION_ON_UPDATE, MOBILE_VERIFICATION_ON_UPDATE, - PASSWORD_EXPIRY + PASSWORD_EXPIRY, EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE }; if (StringUtils.isNotEmpty(scenarioName)) { for (RecoveryScenarios scenario : scenarios) { diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/confirmation/ResendConfirmationManager.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/confirmation/ResendConfirmationManager.java index 71e52de240..c9477e6ce7 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/confirmation/ResendConfirmationManager.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/confirmation/ResendConfirmationManager.java @@ -267,20 +267,11 @@ private void triggerNotification(User user, String notificationChannel, String t notificationChannel = NotificationChannels.EMAIL_CHANNEL.getChannelType(); } properties.put(IdentityEventConstants.EventProperty.NOTIFICATION_CHANNEL, notificationChannel); - if (NotificationChannels.SMS_CHANNEL.getChannelType().equals(notificationChannel)) { - String sendTo = Utils.getUserClaim(user, IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); - if (StringUtils.isEmpty(sendTo)) { - throw Utils.handleClientException( - IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_MOBILE_NOT_FOUND, user.getUserName()); - } - properties.put(IdentityRecoveryConstants.SEND_TO, sendTo); - } if (StringUtils.isNotBlank(code)) { if (NotificationChannels.SMS_CHANNEL.getChannelType().equals(notificationChannel)) { properties.put(IdentityRecoveryConstants.OTP_TOKEN_STRING, code); - } else { - properties.put(IdentityRecoveryConstants.CONFIRMATION_CODE, code); } + properties.put(IdentityRecoveryConstants.CONFIRMATION_CODE, code); } if (metaProperties != null) { for (Property metaProperty : metaProperties) { @@ -289,6 +280,15 @@ private void triggerNotification(User user, String notificationChannel, String t } } } + if (NotificationChannels.SMS_CHANNEL.getChannelType().equals(notificationChannel) && + properties.get(IdentityRecoveryConstants.SEND_TO) == null) { + String sendTo = Utils.getUserClaim(user, IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); + if (StringUtils.isEmpty(sendTo)) { + throw Utils.handleClientException( + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_MOBILE_NOT_FOUND, user.getUserName()); + } + properties.put(IdentityRecoveryConstants.SEND_TO, sendTo); + } if (properties.containsKey(IdentityRecoveryConstants.RESEND_EMAIL_TEMPLATE_NAME)) { properties.put(IdentityRecoveryConstants.TEMPLATE_TYPE, properties.get(IdentityRecoveryConstants.RESEND_EMAIL_TEMPLATE_NAME)); @@ -391,7 +391,8 @@ private void addRecoveryDataObject(String secretKey, String recoveryFlowId, Stri RecoveryScenarios recoveryScenario, RecoverySteps recoveryStep, User user) throws IdentityRecoveryServerException { - UserRecoveryData recoveryDataDO = new UserRecoveryData(user, recoveryFlowId, secretKey, recoveryScenario, recoveryStep); + UserRecoveryData recoveryDataDO = new UserRecoveryData(user, recoveryFlowId, secretKey, recoveryScenario, + recoveryStep); // Store available channels in remaining setIDs. recoveryDataDO.setRemainingSetIds(recoveryData); @@ -474,6 +475,12 @@ private NotificationResponseBean resendAccountRecoveryNotification(User user, St resolveUserAttributes(user); boolean notificationInternallyManage = isNotificationInternallyManage(user, recoveryScenario); + boolean mobileVerificationOnUpdateScenario = RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString() + .equals(recoveryScenario) + || RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario); + boolean emailVerificationOnUpdateScenario = RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.toString() + .equals(recoveryScenario) + || RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario); NotificationResponseBean notificationResponseBean = new NotificationResponseBean(user); UserRecoveryDataStore userRecoveryDataStore = JDBCRecoveryDataStore.getInstance(); @@ -499,10 +506,10 @@ private NotificationResponseBean resendAccountRecoveryNotification(User user, St preferredChannel = NotificationChannels.EXTERNAL_CHANNEL.getChannelType(); } } - if (RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario)) { + if (emailVerificationOnUpdateScenario) { preferredChannel = NotificationChannels.EMAIL_CHANNEL.getChannelType(); } - if (RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario)) { + if (mobileVerificationOnUpdateScenario) { preferredChannel = NotificationChannels.SMS_CHANNEL.getChannelType(); } @@ -524,13 +531,12 @@ private NotificationResponseBean resendAccountRecoveryNotification(User user, St notificationResponseBean.setNotificationChannel(preferredChannel); } - if (RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario) && - RecoverySteps.VERIFY_EMAIL.toString().equals(recoveryStep)) { + if (emailVerificationOnUpdateScenario && RecoverySteps.VERIFY_EMAIL.toString().equals(recoveryStep)) { String verificationPendingEmailClaimValue = userRecoveryData.getRemainingSetIds(); properties = new Property[]{new Property(IdentityRecoveryConstants.SEND_TO, verificationPendingEmailClaimValue)}; recoveryDataDO.setRemainingSetIds(verificationPendingEmailClaimValue); - } else if (RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario) && + } else if (mobileVerificationOnUpdateScenario && RecoverySteps.VERIFY_MOBILE_NUMBER.toString().equals(recoveryStep)) { String verificationPendingMobileNumber = userRecoveryData.getRemainingSetIds(); properties = new Property[]{new Property(IdentityRecoveryConstants.SEND_TO, @@ -564,8 +570,7 @@ private String resolveEventName(String preferredChannel, String userName, String String eventName; if (NotificationChannels.SMS_CHANNEL.getChannelType().equals(preferredChannel)) { - eventName = IdentityRecoveryConstants.NOTIFICATION_EVENTNAME_PREFIX + preferredChannel - + IdentityRecoveryConstants.NOTIFICATION_EVENTNAME_SUFFIX; + eventName = Utils.resolveEventName(preferredChannel); } else { eventName = IdentityEventConstants.Event.TRIGGER_NOTIFICATION; } @@ -653,10 +658,12 @@ private boolean isNotificationInternallyManage(User user, String recoveryScenari return Boolean.parseBoolean(Utils.getSignUpConfigs (IdentityRecoveryConstants.ConnectorConfig.LITE_SIGN_UP_NOTIFICATION_INTERNALLY_MANAGE, user.getTenantDomain())); - } else if (RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario)) { + } else if (RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario) || + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario)) { // We manage the notifications internally for EMAIL_VERIFICATION_ON_UPDATE. return true; - } else if (RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario)) { + } else if (RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString().equals(recoveryScenario) + || RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString().equals(recoveryScenario)) { // We manage the notifications internally for MOBILE_VERIFICATION_ON_UPDATE. return true; } else { @@ -732,7 +739,9 @@ private String getSecretKey(String preferredChannel, String recoveryScenario, St return Utils.generateSecretKey(preferredChannel, recoveryScenario, tenantDomain, "Recovery.Notification.Password"); case EMAIL_VERIFICATION_ON_UPDATE: + case EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE: case MOBILE_VERIFICATION_ON_UPDATE: + case MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE: return Utils.generateSecretKey(preferredChannel, recoveryScenario, tenantDomain, "UserClaimUpdate"); case ASK_PASSWORD: diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/connector/UserClaimUpdateConfigImpl.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/connector/UserClaimUpdateConfigImpl.java index ca899dc8ce..415157d405 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/connector/UserClaimUpdateConfigImpl.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/connector/UserClaimUpdateConfigImpl.java @@ -1,18 +1,21 @@ /* - * Copyright (c) 2020, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * Copyright (c) 2020-2024, WSO2 LLC. (http://www.wso2.com). * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations und + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ + package org.wso2.carbon.identity.recovery.connector; import org.apache.axiom.om.OMElement; @@ -317,7 +320,7 @@ private void loadConfigurations() { (IdentityCoreConstants.IDENTITY_DEFAULT_NAMESPACE, VERIFICATION_CODE_ELEMENT)); if (verificationCode != null) { emailVerificationOnUpdateCodeExpiryProperty = verificationCode.getFirstChildWithName(new - QName(IdentityCoreConstants.IDENTITY_DEFAULT_NAMESPACE, EXPIRY_TIME_ELEMENT)) + QName(IdentityCoreConstants.IDENTITY_DEFAULT_NAMESPACE, EXPIRY_TIME_ELEMENT)) .getText(); } } @@ -343,7 +346,7 @@ private void loadConfigurations() { (IdentityCoreConstants.IDENTITY_DEFAULT_NAMESPACE, VERIFICATION_CODE_ELEMENT)); if (verificationCode != null) { mobileNumVerificationOnUpdateCodeExpiryProperty = verificationCode.getFirstChildWithName( - new QName(IdentityCoreConstants.IDENTITY_DEFAULT_NAMESPACE, EXPIRY_TIME_ELEMENT)) + new QName(IdentityCoreConstants.IDENTITY_DEFAULT_NAMESPACE, EXPIRY_TIME_ELEMENT)) .getText(); } } @@ -401,5 +404,4 @@ public Map getMetaData() { return meta; } - } diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/MobileNumberVerificationHandler.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/MobileNumberVerificationHandler.java index 4e3b7e7790..fc33313e8b 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/MobileNumberVerificationHandler.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/MobileNumberVerificationHandler.java @@ -1,7 +1,7 @@ /* - * Copyright (c) 2020, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * Copyright (c) 2020-2024, WSO2 LLC. (http://www.wso2.com). * - * WSO2 Inc. licenses this file to you under the Apache License, + * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except * in compliance with the License. * You may obtain a copy of the License at @@ -11,22 +11,24 @@ * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the + * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package org.wso2.carbon.identity.recovery.handler; +import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.wso2.carbon.context.PrivilegedCarbonContext; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; import org.wso2.carbon.identity.application.common.model.User; import org.wso2.carbon.identity.base.IdentityRuntimeException; import org.wso2.carbon.identity.core.bean.context.MessageContext; import org.wso2.carbon.identity.core.handler.InitConfig; +import org.wso2.carbon.identity.event.IdentityEventClientException; import org.wso2.carbon.identity.event.IdentityEventConstants; import org.wso2.carbon.identity.event.IdentityEventException; import org.wso2.carbon.identity.event.event.Event; @@ -50,8 +52,14 @@ import org.wso2.carbon.user.core.UserStoreManager; import org.wso2.carbon.user.core.service.RealmService; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; /** * This event handler is used to send a verification SMS when a claim update event to update the mobile number @@ -84,9 +92,21 @@ public void handleEvent(Event event) throws IdentityEventException { getUserStoreProperty(UserCoreConstants.RealmConfig.PROPERTY_DOMAIN_NAME)); Map claims = (Map) eventProperties.get(IdentityEventConstants.EventProperty .USER_CLAIMS); + if (claims == null) { + claims = new HashMap<>(); + } + + boolean supportMultipleMobileNumbers = Utils.isMultiEmailsAndMobileNumbersPerUserEnabled(); boolean enable = isMobileVerificationOnUpdateEnabled(user.getTenantDomain()); + if (!supportMultipleMobileNumbers) { + // Multiple mobile numbers per user support is disabled. + log.debug("Supporting multiple mobile numbers per user is disabled."); + claims.remove(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM); + claims.remove(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM); + } + if (!enable) { // Mobile Number Verification feature is disabled. if (log.isDebugEnabled()) { @@ -95,14 +115,44 @@ public void handleEvent(Event event) throws IdentityEventException { } /* We need to empty 'MOBILE_NUMBER_PENDING_VALUE_CLAIM' because having a value in that claim implies a verification is pending. But verification is not enabled anymore. */ - if (claims.containsKey(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM)) { + if (claims.containsKey(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM) || + claims.containsKey(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM)) { invalidatePendingMobileVerification(user, userStoreManager, claims); } claims.remove(IdentityRecoveryConstants.VERIFY_MOBILE_CLAIM); + claims.remove(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM); + + if (supportMultipleMobileNumbers) { + if (claims.containsKey(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM) && + !claims.get(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM).isEmpty()) { + String mobileNumber = claims.get(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); + List exisitingAllNumbersList = Utils.getMultiValuedClaim(userStoreManager, user, + IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM); + List updatedAllNumbersList = + claims.containsKey(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM) + ? getListOfMobileNumbersFromString( + claims.get(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM)) + : exisitingAllNumbersList; + if (!updatedAllNumbersList.contains(mobileNumber)) { + updatedAllNumbersList.add(mobileNumber); + claims.put(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM, + String.join(FrameworkUtils.getMultiAttributeSeparator(), updatedAllNumbersList)); + } + } + } else { + // Multiple mobile numbers per user support is disabled. + log.debug("Supporting multiple mobile numbers per user is disabled."); + claims.remove(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM); + claims.remove(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM); + } return; } if (IdentityEventConstants.Event.PRE_SET_USER_CLAIMS.equals(eventName)) { + Utils.unsetThreadLocalIsOnlyVerifiedMobileNumbersUpdated(); + if (supportMultipleMobileNumbers && !claims.containsKey(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM)) { + Utils.setThreadLocalIsOnlyVerifiedMobileNumbersUpdated(true); + } preSetUserClaimOnMobileNumberUpdate(claims, userStoreManager, user); claims.remove(IdentityRecoveryConstants.VERIFY_MOBILE_CLAIM); } @@ -132,7 +182,7 @@ public int getPriority(MessageContext messageContext) { /** * Store verification details in the recovery data store and initiate notification. * - * @param user User. + * @param user User. * @param verificationPendingMobileNumber Updated mobile number that is pending verification. * @throws IdentityEventException */ @@ -142,13 +192,25 @@ private void initNotificationForMobileNumberVerificationOnUpdate(User user, Stri UserRecoveryDataStore userRecoveryDataStore = JDBCRecoveryDataStore.getInstance(); try { - userRecoveryDataStore.invalidate(user, RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, - RecoverySteps.VERIFY_MOBILE_NUMBER); String secretKey = Utils.generateSecretKey(NotificationChannels.SMS_CHANNEL.getChannelType(), String.valueOf(RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE), user.getTenantDomain(), "UserClaimUpdate"); - UserRecoveryData recoveryDataDO = new UserRecoveryData(user, secretKey, - RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER); + + UserRecoveryData recoveryDataDO; + if (Utils.getThreadLocalIsOnlyVerifiedMobileNumbersUpdated()) { + userRecoveryDataStore.invalidate(user, RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER); + recoveryDataDO = new UserRecoveryData(user, secretKey, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER); + } else { + userRecoveryDataStore.invalidate(user, RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER); + recoveryDataDO = new UserRecoveryData(user, secretKey, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER); + } + /* Mobile number is persisted in remaining set ids to maintain context information about the mobile number associated with the verification code generated. */ recoveryDataDO.setRemainingSetIds(verificationPendingMobileNumber); @@ -163,9 +225,9 @@ private void initNotificationForMobileNumberVerificationOnUpdate(User user, Stri /** * Trigger the SMS notification. * - * @param user User. - * @param code SMS OTP. - * @param props Other properties. + * @param user User. + * @param code SMS OTP. + * @param props Other properties. * @param verificationPendingMobileNumber Mobile number to which the SMS should be sent. * @throws IdentityRecoveryException */ @@ -178,7 +240,7 @@ private void triggerNotification(User user, String code, Property[] props, Strin log.debug("Sending: " + notificationType + " notification to user: " + user.toFullQualifiedUsername()); } - String eventName = IdentityEventConstants.Event.TRIGGER_SMS_NOTIFICATION; + String eventName = Utils.resolveEventName(NotificationChannels.SMS_CHANNEL.getChannelType()); HashMap properties = new HashMap<>(); properties.put(IdentityEventConstants.EventProperty.USER_NAME, user.getUserName()); properties.put(IdentityEventConstants.EventProperty.TENANT_DOMAIN, user.getTenantDomain()); @@ -196,6 +258,7 @@ private void triggerNotification(User user, String code, Property[] props, Strin } if (StringUtils.isNotBlank(code)) { properties.put(IdentityRecoveryConstants.CONFIRMATION_CODE, code); + properties.put(IdentityRecoveryConstants.OTP_TOKEN_STRING, code); } Event identityMgtEvent = new Event(eventName, properties); @@ -210,9 +273,9 @@ private void triggerNotification(User user, String code, Property[] props, Strin /** * Form User object from username, tenant domain, and user store domain. * - * @param userName UserName. - * @param tenantDomain Tenant Domain. - * @param userStoreDomain User Domain. + * @param userName UserName. + * @param tenantDomain Tenant Domain. + * @param userStoreDomain User Domain. * @return User. */ private User getUser(String userName, String tenantDomain, String userStoreDomain) { @@ -228,26 +291,33 @@ private User getUser(String userName, String tenantDomain, String userStoreDomai * If the mobile claim is updated, set it to the 'MOBILE_NUMBER_PENDING_VALUE_CLAIM' claim. * Set thread local state to skip sending verification notification in inapplicable claim update scenarios. * - * @param claims Map of claims to be updated. - * @param userStoreManager User store manager. - * @param user User. + * @param claims Map of claims to be updated. + * @param userStoreManager User store manager. + * @param user User. * @throws IdentityEventException */ private void preSetUserClaimOnMobileNumberUpdate(Map claims, UserStoreManager userStoreManager, User user) throws IdentityEventException { - if (IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_CONFIRM.toString().equals - (Utils.getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate())) { + if (MapUtils.isEmpty(claims)) { // Not required to handle in this handler. + Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(IdentityRecoveryConstants + .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString()); + return; + } + + if (IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_CONFIRM.toString().equals + (Utils.getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate())) { + invalidatePendingMobileVerification(user, userStoreManager, claims); return; } /* Within the SMS OTP flow, the mobile number is updated in the user profile after successfully verifying the OTP. Therefore, the mobile number is already verified & no need to verify it again. - */ - if (IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_SMS_OTP_FLOW.toString().equals - (Utils.getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate())) { + */ + if (IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_SMS_OTP_FLOW.toString(). + equals(Utils.getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate())) { invalidatePendingMobileVerification(user, userStoreManager, claims); return; } @@ -256,64 +326,168 @@ private void preSetUserClaimOnMobileNumberUpdate(Map claims, Use Utils.unsetThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(); } - if (MapUtils.isEmpty(claims)) { - // Not required to handle in this handler. + boolean supportMultipleMobileNumbers = Utils.isMultiEmailsAndMobileNumbersPerUserEnabled(); + String multiAttributeSeparator = FrameworkUtils.getMultiAttributeSeparator(); + + String mobileNumber = claims.get(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); + + List updatedVerifiedNumbersList = new ArrayList<>(); + List updatedAllNumbersList; + + if (supportMultipleMobileNumbers) { + List exisitingVerifiedNumbersList = Utils.getMultiValuedClaim(userStoreManager, user, + IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM); + updatedVerifiedNumbersList = claims.containsKey(IdentityRecoveryConstants. + VERIFIED_MOBILE_NUMBERS_CLAIM) ? getListOfMobileNumbersFromString(claims.get( + IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM)) : exisitingVerifiedNumbersList; + + List exisitingAllNumbersList = Utils.getMultiValuedClaim(userStoreManager, user, + IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM); + updatedAllNumbersList = claims.containsKey(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM) ? + getListOfMobileNumbersFromString(claims.get(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM)) : + exisitingAllNumbersList; + + /* + Finds the verification pending mobile number and remove it from the verified numbers list in the payload. + */ + if (mobileNumber == null && CollectionUtils.isNotEmpty(updatedVerifiedNumbersList)) { + mobileNumber = getVerificationPendingMobileNumber(exisitingVerifiedNumbersList, + updatedVerifiedNumbersList); + updatedVerifiedNumbersList.remove(mobileNumber); + } + + /* + Finds the removed numbers from the existing mobile numbers list and remove them from the verified numbers + list. As verified numbers list should not contain numbers that are not in the mobile numbers list. + */ + if (updatedAllNumbersList != null) { + updatedVerifiedNumbersList.removeIf(number -> !updatedAllNumbersList.contains(number)); + } + + claims.put(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM, + String.join(multiAttributeSeparator, updatedAllNumbersList)); + claims.put(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM, + String.join(multiAttributeSeparator, updatedVerifiedNumbersList)); + } else { + updatedAllNumbersList = new ArrayList<>(); + claims.remove(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM); + claims.remove(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM); + } + + if (StringUtils.isBlank(mobileNumber)) { Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(IdentityRecoveryConstants .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString()); return; } - String mobileNumber = claims.get(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); + String existingMobileNumber; + String username = user.getUserName(); + try { + existingMobileNumber = userStoreManager.getUserClaimValue(username, IdentityRecoveryConstants. + MOBILE_NUMBER_CLAIM, null); + } catch (UserStoreException e) { + String error = String.format("Error occurred while retrieving existing mobile number for user: %s in " + + "domain: %s and user store: %s", username, user.getTenantDomain(), user.getUserStoreDomain()); + throw new IdentityEventException(error, e); + } - if (StringUtils.isNotBlank(mobileNumber) && - isVerificationPendingMobileClaimConfigAvailable(user.getTenantDomain())) { - String existingMobileNumber; - String username = user.getUserName(); - try { - existingMobileNumber = userStoreManager.getUserClaimValue(username, IdentityRecoveryConstants. - MOBILE_NUMBER_CLAIM, null); - } catch (UserStoreException e) { - String error = String.format("Error occurred while retrieving existing mobile number for user: %s in " + - "domain: %s and user store: %s", username, user.getTenantDomain(), user.getUserStoreDomain()); - throw new IdentityEventException(error, e); + if (supportMultipleMobileNumbers && updatedVerifiedNumbersList.contains(mobileNumber)) { + Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate( + IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates + .SKIP_ON_ALREADY_VERIFIED_MOBILE_NUMBERS.toString()); + invalidatePendingMobileVerification(user, userStoreManager, claims); + return; + } + + if (StringUtils.equals(mobileNumber, existingMobileNumber)) { + if (log.isDebugEnabled()) { + log.debug(String.format("The mobile number to be updated: %s is same as the existing mobile " + + "number for user: %s in domain: %s and user store: %s. Hence an SMS OTP " + + "verification will not be triggered.", mobileNumber, username, + user.getTenantDomain(), user.getUserStoreDomain())); } + Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(IdentityRecoveryConstants + .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_EXISTING_MOBILE_NUM.toString()); + invalidatePendingMobileVerification(user, userStoreManager, claims); - if (StringUtils.equals(mobileNumber, existingMobileNumber)) { - if (log.isDebugEnabled()) { - log.debug(String.format("The mobile number to be updated: %s is same as the existing mobile " + - "number for user: %s in domain: %s and user store: %s. Hence an SMS OTP verification " + - "will not be triggered.", mobileNumber, username, user.getTenantDomain(), - user.getUserStoreDomain())); + if (supportMultipleMobileNumbers) { + if (!updatedVerifiedNumbersList.contains(existingMobileNumber)) { + updatedVerifiedNumbersList.add(existingMobileNumber); + claims.put(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM, + String.join(multiAttributeSeparator, updatedVerifiedNumbersList)); + } + if (!updatedAllNumbersList.contains(existingMobileNumber)) { + updatedAllNumbersList.add(existingMobileNumber); + claims.put(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM, + String.join(multiAttributeSeparator, updatedAllNumbersList)); } - Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(IdentityRecoveryConstants - .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_EXISTING_MOBILE_NUM.toString()); - invalidatePendingMobileVerification(user, userStoreManager, claims); - return; - } - /* - When 'UseVerifyClaim' is enabled, the verification should happen only if the 'verifyMobile' - temporary claim exists as 'true' in the claim list. If 'UseVerifyClaim' is disabled, no need to - check for 'verifyMobile' claim. - */ - if (Utils.isUseVerifyClaimEnabled() && !isVerifyMobileClaimAvailable(claims)) { - Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(IdentityRecoveryConstants - .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString()); - invalidatePendingMobileVerification(user, userStoreManager, claims); - return; } - claims.put(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM, mobileNumber); - claims.remove(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); - } else { + return; + } + /* + When 'UseVerifyClaim' is enabled, the verification should happen only if the 'verifyMobile' + temporary claim exists as 'true' in the claim list. If 'UseVerifyClaim' is disabled, no need to + check for 'verifyMobile' claim. + */ + if (Utils.isUseVerifyClaimEnabled() && !isVerifyMobileClaimAvailable(claims)) { Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(IdentityRecoveryConstants .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString()); + invalidatePendingMobileVerification(user, userStoreManager, claims); + return; + } + claims.remove(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); + + if (isVerificationPendingMobileClaimConfigAvailable(user.getTenantDomain())) { + claims.put(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM, mobileNumber); } } + /** + * Convert comma separated list of mobile numbers to a list. + * + * @param mobileNumbers Comma separated list of mobile numbers. + * @return List of mobile numbers. + */ + private List getListOfMobileNumbersFromString(String mobileNumbers) { + + String multiAttributeSeparator = FrameworkUtils.getMultiAttributeSeparator(); + return StringUtils.isBlank(mobileNumbers) ? new ArrayList<>() : new ArrayList<>(Arrays.asList( + mobileNumbers.split(multiAttributeSeparator))).stream().map(String::trim).collect(Collectors.toList()); + } + + /** + * Get the mobile number that is pending verification. + * + * @param existingVerifiedNumbersList List of existing verified mobile numbers. + * @param updatedVerifiedNumbersList List of updated verified mobile numbers. + * @return Mobile number that is pending verification. + */ + private String getVerificationPendingMobileNumber(List existingVerifiedNumbersList, + List updatedVerifiedNumbersList) throws + IdentityEventException { + + Set existingVerifiedNumbersSet = new HashSet<>(existingVerifiedNumbersList); + String mobileNumber = null; + + for (String verificationPendingNumber : updatedVerifiedNumbersList) { + if (!existingVerifiedNumbersSet.contains(verificationPendingNumber)) { + if (mobileNumber == null) { + mobileNumber = verificationPendingNumber; + } else { + throw new IdentityEventClientException( + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_VERIFY_MULTIPLE_MOBILE_NUMBERS.getCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_VERIFY_MULTIPLE_MOBILE_NUMBERS.getMessage()); + } + } + } + return mobileNumber; + } + /** * Initiate notification sending process if the thread local is not set to skip verification process. * - * @param user User. - * @param userStoreManager User store manager. + * @param user User. + * @param userStoreManager User store manager. * @throws IdentityEventException */ private void postSetUserClaimOnMobileNumberUpdate(User user, UserStoreManager userStoreManager) throws @@ -325,11 +499,13 @@ private void postSetUserClaimOnMobileNumberUpdate(User user, UserStoreManager us if (!IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_CONFIRM.toString().equals (skipMobileNumVerificationOnUpdateState) && !IdentityRecoveryConstants. SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_EXISTING_MOBILE_NUM.toString().equals - (skipMobileNumVerificationOnUpdateState) && !IdentityRecoveryConstants + (skipMobileNumVerificationOnUpdateState) && !IdentityRecoveryConstants .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString().equals (skipMobileNumVerificationOnUpdateState) && !IdentityRecoveryConstants .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_SMS_OTP_FLOW.toString().equals - (skipMobileNumVerificationOnUpdateState)) { + (skipMobileNumVerificationOnUpdateState) && !IdentityRecoveryConstants. + SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_ALREADY_VERIFIED_MOBILE_NUMBERS.toString(). + equals(skipMobileNumVerificationOnUpdateState)) { String verificationPendingMobileNumClaim = getVerificationPendingMobileNumValue(userStoreManager, user); @@ -339,14 +515,15 @@ private void postSetUserClaimOnMobileNumberUpdate(User user, UserStoreManager us } } finally { Utils.unsetThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(); + Utils.unsetThreadLocalIsOnlyVerifiedMobileNumbersUpdated(); } } /** * Get the 'http://wso2.org/claims/identity/mobileNumber.pendingValue' claim value. * - * @param userStoreManager User store manager. - * @param user User. + * @param userStoreManager User store manager. + * @param user User. * @return Claim value. * @throws IdentityEventException */ @@ -379,7 +556,7 @@ private String getVerificationPendingMobileNumValue(UserStoreManager userStoreMa /** * Check whether mobile verification on update feature is enabled via connector configuration. * - * @param userTenantDomain Tenant domain of the user. + * @param userTenantDomain Tenant domain of the user. * @return True if the feature is enabled, false otherwise. * @throws IdentityEventException */ @@ -392,13 +569,13 @@ private boolean isMobileVerificationOnUpdateEnabled(String userTenantDomain) thr /** * Invalidate pending mobile number verification. * - * @param user User. - * @param userStoreManager User store manager. - * @param claims User claims. + * @param user User. + * @param userStoreManager User store manager. + * @param claims User claims. * @throws IdentityEventException */ private void invalidatePendingMobileVerification(User user, UserStoreManager userStoreManager, - Map claims ) throws IdentityEventException { + Map claims) throws IdentityEventException { if (isVerificationPendingMobileClaimConfigAvailable(user.getTenantDomain()) && StringUtils.isNotBlank(getVerificationPendingMobileNumValue(userStoreManager, user))) { @@ -417,7 +594,7 @@ private void invalidatePendingMobileVerification(User user, UserStoreManager use /** * Check if the claims contain the temporary claim 'verifyMobile' and it is set to true. * - * @param claims User claims. + * @param claims User claims. * @return True if 'verifyMobile' claim is available as true, false otherwise. */ private boolean isVerifyMobileClaimAvailable(Map claims) { diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandler.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandler.java index 37a38d4996..8555927cfa 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandler.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandler.java @@ -1,17 +1,19 @@ /* - * Copyright (c) 2016, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * Copyright (c) 2016-2024, WSO2 LLC. (http://www.wso2.com). * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations und + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.wso2.carbon.identity.recovery.handler; @@ -21,6 +23,7 @@ import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; import org.wso2.carbon.identity.application.common.model.User; import org.wso2.carbon.identity.base.IdentityRuntimeException; import org.wso2.carbon.identity.core.bean.context.MessageContext; @@ -50,14 +53,18 @@ import org.wso2.carbon.user.core.UserStoreManager; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Random; import java.util.UUID; +import java.util.stream.Collectors; -import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_VERIFICATION_EMAIL_NOT_FOUND; +import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ErrorMessages + .ERROR_CODE_VERIFICATION_EMAIL_NOT_FOUND; public class UserEmailVerificationHandler extends AbstractEventHandler { @@ -88,16 +95,31 @@ public void handleEvent(Event event) throws IdentityEventException { Map claims = (Map) eventProperties.get(IdentityEventConstants.EventProperty .USER_CLAIMS); + if (claims == null) { + claims = new HashMap<>(); + } + + boolean supportMultipleEmails = Utils.isMultiEmailsAndMobileNumbersPerUserEnabled(); boolean enable = false; + if (IdentityEventConstants.Event.PRE_ADD_USER.equals(eventName) || IdentityEventConstants.Event.POST_ADD_USER.equals(eventName)) { enable = Boolean.parseBoolean(Utils.getConnectorConfig(IdentityRecoveryConstants.ConnectorConfig .ENABLE_EMAIL_VERIFICATION, user.getTenantDomain())); } else if (IdentityEventConstants.Event.PRE_SET_USER_CLAIMS.equals(eventName) || IdentityEventConstants.Event.POST_SET_USER_CLAIMS.equals(eventName)) { - enable = Boolean.parseBoolean(Utils.getConnectorConfig(IdentityRecoveryConstants.ConnectorConfig - .ENABLE_EMAIL_VERIFICATION_ON_UPDATE, user.getTenantDomain())); + + enable = isEmailVerificationOnUpdateEnabled(user.getTenantDomain()); + + if (!supportMultipleEmails) { + if (log.isDebugEnabled()) { + log.debug("Supporting multiple email addresses per user is disabled."); + } + claims.remove(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM); + claims.remove(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM); + } + if (!enable) { /* We need to empty 'EMAIL_ADDRESS_PENDING_VALUE_CLAIM' because having a value in that claim implies a verification is pending. But verification is not enabled anymore. */ @@ -109,6 +131,30 @@ public void handleEvent(Event event) throws IdentityEventException { } invalidatePendingEmailVerification(user, userStoreManager, claims); } + + if (supportMultipleEmails) { + // Drop the verified email addresses claim as verification on update is not enabled. + claims.remove(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM); + + if (claims.containsKey(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM) && + !claims.get(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM).isEmpty()) { + + String email = claims.get(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM); + List existingAllEmailAddresses = Utils.getMultiValuedClaim(userStoreManager, user, + IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM); + + List updatedAllEmailAddresses = claims.containsKey( + IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM) + ? getListOfEmailAddressesFromString( + claims.get(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM)) + : existingAllEmailAddresses; + if (!updatedAllEmailAddresses.contains(email)) { + updatedAllEmailAddresses.add(email); + claims.put(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM, + String.join(FrameworkUtils.getMultiAttributeSeparator(), updatedAllEmailAddresses)); + } + } + } claims.remove(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM); } } @@ -137,7 +183,8 @@ public void handleEvent(Event event) throws IdentityEventException { if (claims == null || claims.isEmpty()) { // Not required to handle in this handler. return; - } else if (claims.containsKey(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM) && Boolean.parseBoolean(claims.get(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM))) { + } else if (claims.containsKey(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM) && + Boolean.parseBoolean(claims.get(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM))) { if (!claims.containsKey(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM) || StringUtils.isBlank(claims.get(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM))) { throw new IdentityEventClientException(ERROR_CODE_VERIFICATION_EMAIL_NOT_FOUND.getCode(), @@ -148,8 +195,10 @@ public void handleEvent(Event event) throws IdentityEventException { claim.setValue(claims.get(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM)); Utils.setEmailVerifyTemporaryClaim(claim); claims.remove(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM); - Utils.publishRecoveryEvent(eventProperties, IdentityEventConstants.Event.PRE_VERIFY_EMAIL_CLAIM, null); - } else if (claims.containsKey(IdentityRecoveryConstants.ASK_PASSWORD_CLAIM) && Boolean.parseBoolean(claims.get(IdentityRecoveryConstants.ASK_PASSWORD_CLAIM))) { + Utils.publishRecoveryEvent(eventProperties, IdentityEventConstants.Event.PRE_VERIFY_EMAIL_CLAIM, + null); + } else if (claims.containsKey(IdentityRecoveryConstants.ASK_PASSWORD_CLAIM) && + Boolean.parseBoolean(claims.get(IdentityRecoveryConstants.ASK_PASSWORD_CLAIM))) { Claim claim = new Claim(); claim.setClaimUri(IdentityRecoveryConstants.ASK_PASSWORD_CLAIM); claim.setValue(claims.get(IdentityRecoveryConstants.ASK_PASSWORD_CLAIM)); @@ -237,6 +286,10 @@ public void handleEvent(Event event) throws IdentityEventException { } if (IdentityEventConstants.Event.PRE_SET_USER_CLAIMS.equals(eventName)) { + Utils.unsetThreadLocalIsOnlyVerifiedEmailAddressesUpdated(); + if (supportMultipleEmails && !claims.containsKey(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM)) { + Utils.setThreadLocalIsOnlyVerifiedEmailAddressesUpdated(true); + } preSetUserClaimsOnEmailUpdate(claims, userStoreManager, user); claims.remove(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM); } @@ -247,6 +300,12 @@ public void handleEvent(Event event) throws IdentityEventException { } } + private boolean isEmailVerificationOnUpdateEnabled(String tenantDomain) throws IdentityEventException { + + return Boolean.parseBoolean(Utils.getConnectorConfig(IdentityRecoveryConstants.ConnectorConfig + .ENABLE_EMAIL_VERIFICATION_ON_UPDATE, tenantDomain)); + } + @Override public void init(InitConfig configuration) throws IdentityRuntimeException { @@ -297,7 +356,8 @@ protected void initNotification(User user, Enum recoveryScenario, Enum recoveryS /** * This method sets a random value for the credentials, if the ask password flow is enabled. - * @param credentials Credentials object + * + * @param credentials Credentials object */ private void setRandomValueForCredentials(Object credentials) { @@ -331,10 +391,18 @@ private void initNotificationForEmailVerificationOnUpdate(User user, String secr UserRecoveryDataStore userRecoveryDataStore = JDBCRecoveryDataStore.getInstance(); try { - userRecoveryDataStore.invalidate(user, RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, - RecoverySteps.VERIFY_EMAIL); - UserRecoveryData recoveryDataDO = new UserRecoveryData(user, secretKey, - RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL); + UserRecoveryData recoveryDataDO; + if (Utils.getThreadLocalIsOnlyVerifiedEmailAddressesUpdated()) { + userRecoveryDataStore.invalidate(user, RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + RecoverySteps.VERIFY_EMAIL); + recoveryDataDO = new UserRecoveryData(user, secretKey, + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_EMAIL); + } else { + userRecoveryDataStore.invalidate(user, RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, + RecoverySteps.VERIFY_EMAIL); + recoveryDataDO = new UserRecoveryData(user, secretKey, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL); + } /* Email address persisted in remaining set ids to maintain context information about the email address associated with the verification code generated. */ recoveryDataDO.setRemainingSetIds(verificationPendingEmailAddress); @@ -474,6 +542,12 @@ protected User getUser(Map eventProperties, UserStoreManager userStoreManager) { private void preSetUserClaimsOnEmailUpdate(Map claims, UserStoreManager userStoreManager, User user) throws IdentityEventException { + if (MapUtils.isEmpty(claims)) { + Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(IdentityRecoveryConstants. + SkipEmailVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString()); + return; + } + if (IdentityRecoveryConstants.SkipEmailVerificationOnUpdateStates.SKIP_ON_CONFIRM.toString().equals (Utils.getThreadLocalToSkipSendingEmailVerificationOnUpdate())) { // Not required to handle in this handler. @@ -494,56 +568,161 @@ private void preSetUserClaimsOnEmailUpdate(Map claims, UserStore Utils.unsetThreadLocalToSkipSendingEmailVerificationOnUpdate(); } - if (MapUtils.isEmpty(claims)) { - // Not required to handle in this handler. - Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(IdentityRecoveryConstants. - SkipEmailVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString()); + boolean supportMultipleEmails = Utils.isMultiEmailsAndMobileNumbersPerUserEnabled(); + String multiAttributeSeparator = FrameworkUtils.getMultiAttributeSeparator(); + + String emailAddress = claims.get(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM); + List updatedVerifiedEmailAddresses = new ArrayList<>(); + List updatedAllEmailAddresses; + + // Handle email addresses and verified email addresses claims. + if (supportMultipleEmails) { + List existingVerifiedEmailAddresses = Utils.getMultiValuedClaim(userStoreManager, user, + IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM); + List existingAllEmailAddresses = Utils.getMultiValuedClaim(userStoreManager, user, + IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM); + + updatedVerifiedEmailAddresses = claims.containsKey(IdentityRecoveryConstants. + VERIFIED_EMAIL_ADDRESSES_CLAIM) ? getListOfEmailAddressesFromString(claims.get( + IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM)) : existingVerifiedEmailAddresses; + updatedAllEmailAddresses = claims.containsKey(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM) ? + getListOfEmailAddressesFromString(claims.get(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM)) : + existingAllEmailAddresses; + + if (updatedAllEmailAddresses == null) { + updatedAllEmailAddresses = new ArrayList<>(); + } + if (updatedVerifiedEmailAddresses == null) { + updatedVerifiedEmailAddresses = new ArrayList<>(); + } + + // Find the verification pending email address and remove it from verified email addresses in the payload. + if (emailAddress == null && CollectionUtils.isNotEmpty(updatedVerifiedEmailAddresses)) { + emailAddress = getVerificationPendingEmailAddress(existingVerifiedEmailAddresses, + updatedVerifiedEmailAddresses); + updatedVerifiedEmailAddresses.remove(emailAddress); + } + + /* + Find the removed numbers from the existing email addresses list and remove them from the verified email + addresses list, as verified email addresses list should not contain email addresses that are not in the + email addresses list. + */ + final List tempUpdatedAllEmailAddresses = new ArrayList<>(updatedAllEmailAddresses); + updatedVerifiedEmailAddresses.removeIf(number -> !tempUpdatedAllEmailAddresses.contains(number)); + + claims.put(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM, + StringUtils.join(updatedVerifiedEmailAddresses, multiAttributeSeparator)); + claims.put(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM, + StringUtils.join(updatedAllEmailAddresses, multiAttributeSeparator)); + } else { + /* + email addresses and verified email addresses should not be updated when support for multiple email + addresses is disabled. + */ + claims.remove(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM); + claims.remove(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM); + updatedAllEmailAddresses = new ArrayList<>(); + } + + if (StringUtils.isBlank(emailAddress)) { + Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(IdentityRecoveryConstants + .SkipEmailVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString()); return; } - String emailAddress = claims.get(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM); + String existingEmail = getEmailClaimValue(user, userStoreManager); - if (StringUtils.isNotBlank(emailAddress)) { + if (supportMultipleEmails && updatedVerifiedEmailAddresses.contains(emailAddress)) { + if (log.isDebugEnabled()) { + log.debug(String.format("The email address to be updated: %s is same as the existing email " + + "address for user: %s in domain %s. Hence an email verification will not be " + + "triggered.", emailAddress, user.getUserName(), user.getTenantDomain())); + } + Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(IdentityRecoveryConstants + .SkipEmailVerificationOnUpdateStates.SKIP_ON_ALREADY_VERIFIED_EMAIL_ADDRESSES.toString()); + invalidatePendingEmailVerification(user, userStoreManager, claims); + return; + } - String existingEmail; - String username = user.getUserName(); - try { - existingEmail = userStoreManager.getUserClaimValue(username, - IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM, null); - } catch (UserStoreException e) { - String error = String.format("Error occurred while retrieving existing email address for user: %s in " + - "domain : %s", username, user.getTenantDomain()); - throw new IdentityEventException(error, e); + if (emailAddress.equals(existingEmail)) { + if (log.isDebugEnabled()) { + log.debug(String.format("The email address to be updated: %s is already verified and contains" + + " in the verified email addresses list. Hence an email verification will not be " + + "triggered.", emailAddress)); } + Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(IdentityRecoveryConstants + .SkipEmailVerificationOnUpdateStates.SKIP_ON_EXISTING_EMAIL.toString()); + invalidatePendingEmailVerification(user, userStoreManager, claims); - if (emailAddress.equals(existingEmail)) { - if (log.isDebugEnabled()) { - log.debug(String.format("The email address to be updated: %s is same as the existing email " + - "address for user: %s in domain %s. Hence an email verification will not be " + - "triggered.", emailAddress, username, user.getTenantDomain())); + if (supportMultipleEmails) { + if (!updatedVerifiedEmailAddresses.contains(existingEmail)) { + updatedVerifiedEmailAddresses.add(existingEmail); + claims.put(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM, + StringUtils.join(updatedVerifiedEmailAddresses, multiAttributeSeparator)); + } + if (!updatedAllEmailAddresses.contains(existingEmail)) { + updatedAllEmailAddresses.add(existingEmail); + claims.put(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM, + StringUtils.join(updatedAllEmailAddresses, multiAttributeSeparator)); } - Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(IdentityRecoveryConstants - .SkipEmailVerificationOnUpdateStates.SKIP_ON_EXISTING_EMAIL.toString()); - invalidatePendingEmailVerification(user, userStoreManager, claims); - return; - } - /* - When 'UseVerifyClaim' is enabled, the verification should happen only if the 'verifyEmail' temporary - claim exists as 'true' in the claim list. If 'UseVerifyClaim' is disabled, no need to check for - 'verifyEmail' claim. - */ - if (Utils.isUseVerifyClaimEnabled() && !isVerifyEmailClaimAvailable(claims)) { - Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(IdentityRecoveryConstants - .SkipEmailVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString()); - invalidatePendingEmailVerification(user, userStoreManager, claims); - return; } - claims.put(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM, emailAddress); - claims.remove(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM); - } else { + return; + } + + /* + When 'UseVerifyClaim' is enabled, the verification should happen only if the 'verifyEmail' temporary + claim exists as 'true' in the claim list. If 'UseVerifyClaim' is disabled, no need to check for + 'verifyEmail' claim. + */ + if (Utils.isUseVerifyClaimEnabled() && !isVerifyEmailClaimAvailable(claims)) { Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(IdentityRecoveryConstants .SkipEmailVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString()); + invalidatePendingEmailVerification(user, userStoreManager, claims); + return; + } + claims.put(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM, emailAddress); + claims.remove(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM); + } + + /** + * Get the email address that is pending verification. + * + * @param existingVerifiedEmailAddresses List of existing verified email addresses. + * @param updatedVerifiedEmailAddresses List of updated verified email addresses. + * @return email address that is pending verification. + */ + private String getVerificationPendingEmailAddress(List existingVerifiedEmailAddresses, + List updatedVerifiedEmailAddresses) throws + IdentityEventException { + + String emailAddress = null; + for (String verificationPendingEmailAddress : updatedVerifiedEmailAddresses) { + if (existingVerifiedEmailAddresses.stream().noneMatch(email -> + email.trim().equalsIgnoreCase(verificationPendingEmailAddress.trim()))) { + if (emailAddress == null) { + emailAddress = verificationPendingEmailAddress; + } else { + throw new IdentityEventClientException(IdentityRecoveryConstants.ErrorMessages. + ERROR_CODE_VERIFY_MULTIPLE_EMAILS.getCode(), IdentityRecoveryConstants. + ErrorMessages.ERROR_CODE_VERIFY_MULTIPLE_EMAILS.getMessage()); + } + } } + return emailAddress; + } + + /** + * Convert comma separated list of email addresses to a list. + * + * @param emails Comma separated list of mobile numbers. + * @return List of email addresses. + */ + private List getListOfEmailAddressesFromString(String emails) { + + String multiAttributeSeparator = FrameworkUtils.getMultiAttributeSeparator(); + return emails != null ? new LinkedList<>(Arrays.asList(emails.split(multiAttributeSeparator))).stream() + .map(String::trim).collect(Collectors.toList()) : new ArrayList<>(); } private void postSetUserClaimsOnEmailUpdate(User user, UserStoreManager userStoreManager) throws @@ -554,11 +733,13 @@ private void postSetUserClaimsOnEmailUpdate(User user, UserStoreManager userStor if (!IdentityRecoveryConstants.SkipEmailVerificationOnUpdateStates.SKIP_ON_CONFIRM.toString().equals (skipEmailVerificationOnUpdateState) && !IdentityRecoveryConstants. SkipEmailVerificationOnUpdateStates.SKIP_ON_EXISTING_EMAIL.toString().equals - (skipEmailVerificationOnUpdateState) && !IdentityRecoveryConstants + (skipEmailVerificationOnUpdateState) && !IdentityRecoveryConstants .SkipEmailVerificationOnUpdateStates.SKIP_ON_INAPPLICABLE_CLAIMS.toString().equals (skipEmailVerificationOnUpdateState) && !IdentityRecoveryConstants .SkipEmailVerificationOnUpdateStates.SKIP_ON_EMAIL_OTP_FLOW.toString().equals - (skipEmailVerificationOnUpdateState)) { + (skipEmailVerificationOnUpdateState) && !IdentityRecoveryConstants. + SkipEmailVerificationOnUpdateStates.SKIP_ON_ALREADY_VERIFIED_EMAIL_ADDRESSES.toString().equals( + skipEmailVerificationOnUpdateState)) { String pendingVerificationEmailClaimValue = getPendingVerificationEmailValue(userStoreManager, user); @@ -572,6 +753,7 @@ private void postSetUserClaimsOnEmailUpdate(User user, UserStoreManager userStor } } finally { Utils.unsetThreadLocalToSkipSendingEmailVerificationOnUpdate(); + Utils.unsetThreadLocalIsOnlyVerifiedEmailAddressesUpdated(); } } @@ -604,13 +786,13 @@ private String getPendingVerificationEmailValue(UserStoreManager userStoreManage /** * Invalidate pending email verification. * - * @param user User. - * @param userStoreManager User store manager. - * @param claims User claims. + * @param user User. + * @param userStoreManager User store manager. + * @param claims User claims. * @throws IdentityEventException */ private void invalidatePendingEmailVerification(User user, UserStoreManager userStoreManager, - Map claims ) throws IdentityEventException { + Map claims) throws IdentityEventException { if (StringUtils.isNotBlank(getPendingVerificationEmailValue(userStoreManager, user))) { claims.put(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM, StringUtils.EMPTY); @@ -628,7 +810,7 @@ private void invalidatePendingEmailVerification(User user, UserStoreManager user /** * Check if the claims contain the temporary claim 'verifyEmail' and it is set to true. * - * @param claims User claims. + * @param claims User claims. * @return True if 'verifyEmail' claim is available as true, false otherwise. */ private boolean isVerifyEmailClaimAvailable(Map claims) { @@ -650,7 +832,7 @@ private boolean isVerifyEmailClaimAvailable(Map claims) { * @throws IdentityEventException IdentityEventException. */ private void sendNotificationToExistingEmailOnEmailUpdate(User user, UserStoreManager userStoreManager, - String newEmailAddress, String templateType) throws IdentityEventException { + String newEmailAddress, String templateType) throws IdentityEventException { boolean enable = Boolean.parseBoolean(Utils.getConnectorConfig(IdentityRecoveryConstants.ConnectorConfig .ENABLE_NOTIFICATION_ON_EMAIL_UPDATE, user.getTenantDomain())); if (!enable) { diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/signup/UserSelfRegistrationManager.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/signup/UserSelfRegistrationManager.java index cc8a6b5e15..ca4e14e0db 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/signup/UserSelfRegistrationManager.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/signup/UserSelfRegistrationManager.java @@ -1,12 +1,12 @@ /* - * Copyright (c) 2016, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * Copyright (c) 2016-2024, WSO2 LLC. (http://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except * in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an @@ -34,6 +34,7 @@ import org.wso2.carbon.consent.mgt.core.model.ReceiptInput; import org.wso2.carbon.consent.mgt.core.model.ReceiptServiceInput; import org.wso2.carbon.context.PrivilegedCarbonContext; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; import org.wso2.carbon.identity.application.common.model.IdentityProvider; import org.wso2.carbon.identity.application.common.model.User; import org.wso2.carbon.identity.auth.attribute.handler.AuthAttributeHandlerManager; @@ -98,16 +99,15 @@ import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; -import java.net.URISyntaxException; import java.text.SimpleDateFormat; import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -719,7 +719,8 @@ public UserRecoveryData introspectUserSelfRegistration(boolean skipExpiredCodeVa } private UserRecoveryData validateSelfRegistrationCode(String code, String verifiedChannelType, - String verifiedChannelClaim, Map properties, boolean skipExpiredCodeValidation) + String verifiedChannelClaim, Map properties, + boolean skipExpiredCodeValidation) throws IdentityRecoveryException { Utils.unsetThreadLocalToSkipSendingEmailVerificationOnUpdate(); @@ -767,12 +768,39 @@ private UserRecoveryData validateSelfRegistrationCode(String code, String verifi HashMap userClaims = getClaimsListToUpdate(user, verifiedChannelType, externallyVerifiedClaim, recoveryData.getRecoveryScenario().toString()); + boolean supportMultipleEmailsAndMobileNumbers = Utils.isMultiEmailsAndMobileNumbersPerUserEnabled(); + String multiAttributeSeparator = FrameworkUtils.getMultiAttributeSeparator(); + if (RecoverySteps.VERIFY_EMAIL.equals(recoveryData.getRecoveryStep())) { String pendingEmailClaimValue = recoveryData.getRemainingSetIds(); if (StringUtils.isNotBlank(pendingEmailClaimValue)) { eventProperties.put(IdentityEventConstants.EventProperty.VERIFIED_EMAIL, pendingEmailClaimValue); userClaims.put(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM, StringUtils.EMPTY); - userClaims.put(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM, pendingEmailClaimValue); //todo?? + if (supportMultipleEmailsAndMobileNumbers) { + try { + List verifiedEmails = Utils.getMultiValuedClaim(userStoreManager, user, + IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM); + verifiedEmails.add(pendingEmailClaimValue); + userClaims.put(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM, StringUtils.join( + verifiedEmails, multiAttributeSeparator)); + + List allEmails = Utils.getMultiValuedClaim(userStoreManager, user, + IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM); + if (!allEmails.contains(pendingEmailClaimValue)) { + allEmails.add(pendingEmailClaimValue); + userClaims.put(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM, StringUtils.join( + allEmails, multiAttributeSeparator)) ; + } + } catch (IdentityEventException e) { + log.error("Error occurred while obtaining claim for the user : " + user.getUserName()); + throw new IdentityRecoveryServerException("Error occurred while obtaining existing claim " + + "value for the user : " + user.getUserName(), e); + } + } + if (!RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE + .equals(recoveryData.getRecoveryScenario())) { + userClaims.put(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM, pendingEmailClaimValue); + } // Todo passes when email address is properly set here. Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(IdentityRecoveryConstants .SkipEmailVerificationOnUpdateStates.SKIP_ON_CONFIRM.toString()); @@ -781,8 +809,38 @@ private UserRecoveryData validateSelfRegistrationCode(String code, String verifi if (RecoverySteps.VERIFY_MOBILE_NUMBER.equals(recoveryData.getRecoveryStep())) { String pendingMobileClaimValue = recoveryData.getRemainingSetIds(); if (StringUtils.isNotBlank(pendingMobileClaimValue)) { + if (supportMultipleEmailsAndMobileNumbers) { + try { + List existingVerifiedMobileNumbersList = Utils.getMultiValuedClaim(userStoreManager, + user, IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM); + if (!existingVerifiedMobileNumbersList.contains(pendingMobileClaimValue)) { + existingVerifiedMobileNumbersList.add(pendingMobileClaimValue); + userClaims.put(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM, + String.join(multiAttributeSeparator, existingVerifiedMobileNumbersList)); + } + + /* + VerifiedMobileNumbers is a subset of mobileNumbers. Hence, adding the verified number to + mobileNumbers claim as well. + */ + List allMobileNumbersList = Utils.getMultiValuedClaim(userStoreManager, + user, IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM); + if (!allMobileNumbersList.contains(pendingMobileClaimValue)) { + allMobileNumbersList.add(pendingMobileClaimValue); + userClaims.put(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM, + String.join(multiAttributeSeparator, allMobileNumbersList)); + } + } catch (IdentityEventException e) { + log.error("Error occurred while obtaining claim for the user : " + user.getUserName()); + throw new IdentityRecoveryServerException("Error occurred while obtaining existing claim " + + "value for the user : " + user.getUserName(), e); + } + } + if (!RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE + .equals(recoveryData.getRecoveryScenario())) { + userClaims.put(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM, pendingMobileClaimValue); + } userClaims.put(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM, StringUtils.EMPTY); - userClaims.put(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM, pendingMobileClaimValue); // Todo passes when mobile number is properly set here. Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(IdentityRecoveryConstants .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_CONFIRM.toString()); @@ -840,7 +898,7 @@ private boolean isMobileVerificationEnabledForPrivilegedUsers(String tenantDomai /** * Introspects self registration confirmation code details without invalidating it. - * Does not triggering notification events or update user claims. + * Does not trigger notification events or update user claims. * * @param skipExpiredCodeValidation Skip confirmation code validation against expiration. * @param code Confirmation code. @@ -852,7 +910,7 @@ private UserRecoveryData introspectSelfRegistrationCode(String code, boolean ski UserRecoveryDataStore userRecoveryDataStore = JDBCRecoveryDataStore.getInstance(); - // If the code is validated, the load method will return data. Otherwise method will throw exceptions. + // If the code is validated, the load method will return data. Otherwise, method will throw exceptions. UserRecoveryData recoveryData; if (!skipExpiredCodeValidation) { recoveryData = userRecoveryDataStore.load(code); @@ -932,15 +990,50 @@ public void confirmVerificationCodeMe(String code, Map propertie UserStoreManager userStoreManager = getUserStoreManager(user); HashMap userClaims = new HashMap<>(); - if (RecoverySteps.VERIFY_MOBILE_NUMBER.equals(recoveryData.getRecoveryStep())) { - String pendingMobileNumberClaimValue = recoveryData.getRemainingSetIds(); - if (StringUtils.isNotBlank(pendingMobileNumberClaimValue)) { - userClaims.put(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM, StringUtils.EMPTY); + boolean supportMultipleEmailsAndMobileNumbers = Utils.isMultiEmailsAndMobileNumbersPerUserEnabled(); + + String pendingMobileNumberClaimValue = recoveryData.getRemainingSetIds(); + if (StringUtils.isNotBlank(pendingMobileNumberClaimValue)) { + /* + Verifying whether user is trying to add a mobile number to http://wso2.org/claims/verifedMobileNumbers + claim. + */ + if (supportMultipleEmailsAndMobileNumbers) { + try { + String multiAttributeSeparator = FrameworkUtils.getMultiAttributeSeparator(); + List existingVerifiedMobileNumbersList = Utils.getMultiValuedClaim(userStoreManager, + user, IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM); + if (!existingVerifiedMobileNumbersList.contains(pendingMobileNumberClaimValue)) { + existingVerifiedMobileNumbersList.add(pendingMobileNumberClaimValue); + userClaims.put(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM, + String.join(multiAttributeSeparator, existingVerifiedMobileNumbersList)); + } + + /* + VerifiedMobileNumbers is a subset of mobileNumbers. Hence, adding the verified number to + mobileNumbers claim as well. + */ + List allMobileNumbersList = Utils.getMultiValuedClaim(userStoreManager, + user, IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM); + if (!allMobileNumbersList.contains(pendingMobileNumberClaimValue)) { + allMobileNumbersList.add(pendingMobileNumberClaimValue); + userClaims.put(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM, + String.join(multiAttributeSeparator, allMobileNumbersList)); + } + } catch (IdentityEventException e) { + log.error("Error occurred while obtaining claim for the user : " + user.getUserName()); + throw new IdentityRecoveryServerException("Error occurred while obtaining existing claim " + + "value for the user : " + user.getUserName(), e); + } + } + if (!RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE + .equals(recoveryData.getRecoveryScenario())) { userClaims.put(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM, pendingMobileNumberClaimValue); - userClaims.put(NotificationChannels.SMS_CHANNEL.getVerifiedClaimUrl(), Boolean.TRUE.toString()); - Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(IdentityRecoveryConstants - .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_CONFIRM.toString()); } + userClaims.put(NotificationChannels.SMS_CHANNEL.getVerifiedClaimUrl(), Boolean.TRUE.toString()); + userClaims.put(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM, StringUtils.EMPTY); + Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(IdentityRecoveryConstants + .SkipMobileNumberVerificationOnUpdateStates.SKIP_ON_CONFIRM.toString()); } // Update the user claims. updateUserClaims(userStoreManager, user, userClaims); diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java index 568ee5b299..82070936d2 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStore.java @@ -983,7 +983,8 @@ private boolean isCodeExpired(String tenantDomain, Enum recoveryScenario, Enum r notificationExpiryTimeInMinutes = Integer.parseInt( Utils.getRecoveryConfigs(IdentityRecoveryConstants.ConnectorConfig.EXPIRY_TIME, tenantDomain)); } - } else if (RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.equals(recoveryScenario)) { + } else if (RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.equals(recoveryScenario) || + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE.equals(recoveryScenario)) { notificationExpiryTimeInMinutes = Integer.parseInt(Utils.getRecoveryConfigs(IdentityRecoveryConstants .ConnectorConfig.EMAIL_VERIFICATION_ON_UPDATE_EXPIRY_TIME, tenantDomain)); } else if (RecoveryScenarios.TENANT_ADMIN_ASK_PASSWORD.equals(recoveryScenario)) { @@ -1024,7 +1025,8 @@ private boolean isCodeExpired(String tenantDomain, Enum recoveryScenario, Enum r IdentityRecoveryConstants.ConnectorConfig.LITE_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME, tenantDomain)); } - } else if (RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.equals(recoveryScenario)) { + } else if (RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.equals(recoveryScenario) || + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.equals(recoveryScenario)) { notificationExpiryTimeInMinutes = Integer.parseInt(Utils.getRecoveryConfigs(IdentityRecoveryConstants .ConnectorConfig.MOBILE_NUM_VERIFICATION_ON_UPDATE_EXPIRY_TIME, tenantDomain)); } else if (RecoveryScenarios.ADMIN_FORCED_PASSWORD_RESET_VIA_EMAIL_LINK.equals(recoveryScenario) || diff --git a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/util/Utils.java b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/util/Utils.java index 4d22e2c6ea..07cd7ae89c 100644 --- a/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/util/Utils.java +++ b/components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/util/Utils.java @@ -27,6 +27,7 @@ import org.json.JSONObject; import org.wso2.carbon.CarbonConstants; import org.wso2.carbon.context.PrivilegedCarbonContext; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; import org.wso2.carbon.identity.application.common.model.Property; import org.wso2.carbon.identity.application.common.model.User; import org.wso2.carbon.identity.auth.attribute.handler.exception.AuthAttributeHandlerClientException; @@ -83,6 +84,8 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -127,6 +130,18 @@ public class Utils { */ private static ThreadLocal skipSendingSmsOtpVerificationOnUpdate = new ThreadLocal<>(); + /** + * This thread local variable is used to stop verification pending mobile number from being set as the primary + * mobile number when only updating the verifiedMobileNumbers claim. + */ + private static ThreadLocal isOnlyVerifiedMobileNumbersUpdated = new ThreadLocal<>(); + + /** + * This thread local variable is used to stop verification pending email address from being set as the primary + * email address when only updating the verifiedEmailAddress claim. + */ + private static ThreadLocal isOnlyVerifiedEmailAddressesUpdated = new ThreadLocal<>(); + //Error messages that are caused by password pattern violations private static final String[] pwdPatternViolations = new String[]{UserCoreErrorConstants.ErrorMessages .ERROR_CODE_ERROR_DURING_PRE_UPDATE_CREDENTIAL_BY_ADMIN.getCode(), UserCoreErrorConstants.ErrorMessages @@ -251,6 +266,38 @@ public static void setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(String skipSendingSmsOtpVerificationOnUpdate.set(value); } + public static void unsetThreadLocalIsOnlyVerifiedMobileNumbersUpdated() { + + isOnlyVerifiedMobileNumbersUpdated.remove(); + } + + public static void setThreadLocalIsOnlyVerifiedMobileNumbersUpdated(Boolean value) { + + isOnlyVerifiedMobileNumbersUpdated.set(value); + } + + public static boolean getThreadLocalIsOnlyVerifiedMobileNumbersUpdated() { + + Boolean value = isOnlyVerifiedMobileNumbersUpdated.get(); + return value != null && value; + } + + public static void unsetThreadLocalIsOnlyVerifiedEmailAddressesUpdated() { + + isOnlyVerifiedEmailAddressesUpdated.remove(); + } + + public static void setThreadLocalIsOnlyVerifiedEmailAddressesUpdated(Boolean value) { + + isOnlyVerifiedEmailAddressesUpdated.set(value); + } + + public static boolean getThreadLocalIsOnlyVerifiedEmailAddressesUpdated() { + + Boolean value = isOnlyVerifiedEmailAddressesUpdated.get(); + return value != null && value; + } + public static String getClaimFromUserStoreManager(User user, String claim) throws UserStoreException { @@ -1345,6 +1392,17 @@ public static boolean isUseVerifyClaimEnabled() { (IdentityRecoveryConstants.ConnectorConfig.USE_VERIFY_CLAIM_ON_UPDATE)); } + /** + * Check whether the supporting multiple email addresses and mobile numbers per user is enabled. + * + * @return True if the config is set to true, false otherwise. + */ + public static boolean isMultiEmailsAndMobileNumbersPerUserEnabled() { + + return Boolean.parseBoolean(IdentityUtil.getProperty( + IdentityRecoveryConstants.ConnectorConfig.SUPPORT_MULTI_EMAILS_AND_MOBILE_NUMBERS_PER_USER)); + } + /** * Trigger recovery event. * @@ -1734,4 +1792,30 @@ public static String getUserClaim(User user, String userClaim) throws IdentityRe throw new IdentityRecoveryServerException(error, e); } } + + /** + * Retrieves the existing multi-valued claims for a given claim URI. + * + * @param userStoreManager User store manager. + * @param user User object. + * @param claimURI Claim URI to retrieve. + * @return List of existing claim values. + * @throws IdentityEventException If an error occurs while retrieving the claim value. + */ + public static List getMultiValuedClaim(UserStoreManager userStoreManager, User user, String claimURI) + throws IdentityEventException { + + try { + String claimValue = userStoreManager.getUserClaimValue(user.getUserName(), claimURI, null); + if (StringUtils.isBlank(claimValue)) { + return new ArrayList<>(); + } + + String separator = FrameworkUtils.getMultiAttributeSeparator(); + return new ArrayList<>(Arrays.asList(claimValue.split(separator))); + } catch (UserStoreException e) { + throw new IdentityEventException("Error retrieving claim " + claimURI + + " for user: " + user.toFullQualifiedUsername(), e); + } + } } diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/confirmation/ResendConfirmationManagerTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/confirmation/ResendConfirmationManagerTest.java new file mode 100644 index 0000000000..193a6ff5e6 --- /dev/null +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/confirmation/ResendConfirmationManagerTest.java @@ -0,0 +1,517 @@ +/* + * Copyright (c) 2018, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.recovery.confirmation; + +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.context.PrivilegedCarbonContext; +import org.wso2.carbon.identity.application.common.model.User; +import org.wso2.carbon.identity.common.testng.WithCarbonHome; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.event.IdentityEventException; +import org.wso2.carbon.identity.event.services.IdentityEventService; +import org.wso2.carbon.identity.governance.IdentityGovernanceService; +import org.wso2.carbon.identity.governance.service.notification.NotificationChannels; +import org.wso2.carbon.identity.recovery.IdentityRecoveryClientException; +import org.wso2.carbon.identity.recovery.IdentityRecoveryConstants; +import org.wso2.carbon.identity.recovery.IdentityRecoveryException; +import org.wso2.carbon.identity.recovery.RecoveryScenarios; +import org.wso2.carbon.identity.recovery.RecoverySteps; +import org.wso2.carbon.identity.recovery.bean.NotificationResponseBean; +import org.wso2.carbon.identity.recovery.dto.ResendConfirmationDTO; +import org.wso2.carbon.identity.recovery.internal.IdentityRecoveryServiceDataHolder; +import org.wso2.carbon.identity.recovery.internal.service.impl.UserAccountRecoveryManager; +import org.wso2.carbon.identity.recovery.model.Property; +import org.wso2.carbon.identity.recovery.model.UserRecoveryData; +import org.wso2.carbon.identity.recovery.model.UserRecoveryFlowData; +import org.wso2.carbon.identity.recovery.store.JDBCRecoveryDataStore; +import org.wso2.carbon.identity.recovery.store.UserRecoveryDataStore; +import org.wso2.carbon.identity.recovery.util.Utils; +import org.wso2.carbon.user.core.UserCoreConstants; +import org.wso2.carbon.user.core.service.RealmService; +import org.wso2.carbon.identity.event.event.Event; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +@WithCarbonHome +public class ResendConfirmationManagerTest { + + @InjectMocks + private ResendConfirmationManager resendConfirmationManager; + + @Mock + private UserRecoveryDataStore userRecoveryDataStore; + + @Mock + private IdentityEventService identityEventService; + + @Mock + private IdentityGovernanceService identityGovernanceService; + + @Mock + private RealmService realmService; + + @Mock + private IdentityRecoveryServiceDataHolder identityRecoveryServiceDataHolder; + + @Mock + private PrivilegedCarbonContext threadLocalCarbonContext; + + @Mock + private UserAccountRecoveryManager userAccountRecoveryManager; + + private MockedStatic mockedServiceDataHolder; + private MockedStatic mockedIdentityUtil; + private MockedStatic mockedIdentityTenantUtil; + private MockedStatic mockedUtils; + private MockedStatic mockedPrivilegedCarbonContext; + private MockedStatic mockedJDBCRecoveryDataStore; + private MockedStatic mockedUserAccountRecoveryManager; + + private static final String TEST_USERNAME = "test-user"; + private static final String TEST_TENANT_DOMAIN = "test.com"; + private static final String TEST_USER_STORE_DOMAIN = "TESTING"; + + @BeforeMethod + public void setUp() throws Exception { + + MockitoAnnotations.openMocks(this); + resendConfirmationManager = ResendConfirmationManager.getInstance(); + + mockedServiceDataHolder = mockStatic(IdentityRecoveryServiceDataHolder.class); + mockedIdentityUtil = mockStatic(IdentityUtil.class); + mockedIdentityTenantUtil = mockStatic(IdentityTenantUtil.class); + mockedUtils = mockStatic(Utils.class); + mockedPrivilegedCarbonContext = mockStatic(PrivilegedCarbonContext.class); + mockedJDBCRecoveryDataStore = mockStatic(JDBCRecoveryDataStore.class); + mockedUserAccountRecoveryManager = mockStatic(UserAccountRecoveryManager.class); + + when(IdentityRecoveryServiceDataHolder.getInstance()).thenReturn(identityRecoveryServiceDataHolder); + mockedPrivilegedCarbonContext.when(PrivilegedCarbonContext::getThreadLocalCarbonContext) + .thenReturn(threadLocalCarbonContext); + mockedIdentityUtil.when(IdentityUtil::getPrimaryDomainName).thenReturn( + UserCoreConstants.PRIMARY_DEFAULT_DOMAIN_NAME); + mockedJDBCRecoveryDataStore.when(JDBCRecoveryDataStore::getInstance).thenReturn(userRecoveryDataStore); + mockedUserAccountRecoveryManager.when(UserAccountRecoveryManager::getInstance) + .thenReturn(userAccountRecoveryManager); + + when(identityRecoveryServiceDataHolder.getIdentityEventService()).thenReturn(identityEventService); + when(identityRecoveryServiceDataHolder.getIdentityGovernanceService()).thenReturn(identityGovernanceService); + when(identityRecoveryServiceDataHolder.getRealmService()).thenReturn(realmService); + + when(threadLocalCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN); + } + + @AfterMethod + public void tearDown() { + + mockedServiceDataHolder.close(); + mockedIdentityUtil.close(); + mockedIdentityTenantUtil.close(); + mockedUtils.close(); + mockedPrivilegedCarbonContext.close(); + mockedJDBCRecoveryDataStore.close(); + mockedUserAccountRecoveryManager.close(); + } + + @Test + public void testResendConfirmationCodeMobileVerificationOnUpdate() throws Exception { + + String verificationPendingMobile = "0777897621"; + String oldCode = "dummy-code"; + String newCode = "new-code"; + User user = getUser(); + Property[] properties = new Property[]{new Property("testKey", "testValue")}; + + UserRecoveryData userRecoveryData = new UserRecoveryData(user, oldCode, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER); + userRecoveryData.setRemainingSetIds(verificationPendingMobile); + when(userRecoveryDataStore.loadWithoutCodeExpiryValidation(user, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE)).thenReturn(userRecoveryData); + + mockedUtils.when(() -> Utils.reIssueExistingConfirmationCode(userRecoveryData, + NotificationChannels.SMS_CHANNEL.getChannelType())).thenReturn(false); + mockedUtils.when(() -> Utils.generateSecretKey(anyString(), anyString(), anyString(), anyString())) + .thenReturn(newCode); + + NotificationResponseBean responseBean = resendConfirmationManager.resendConfirmationCode( + user, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString(), + RecoverySteps.VERIFY_MOBILE_NUMBER.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_MOBILE_ON_UPDATE, properties); + assertNotNull(responseBean); + + ArgumentCaptor recoveryDataCaptor = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor.capture()); + UserRecoveryData capturedRecoveryData = recoveryDataCaptor.getValue(); + Assert.assertEquals(RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, + capturedRecoveryData.getRecoveryScenario()); + Assert.assertEquals(verificationPendingMobile, + capturedRecoveryData.getRemainingSetIds()); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class); + verify(identityEventService).handleEvent(eventCaptor.capture()); + Event capturedEvent = eventCaptor.getValue(); + Map eventProperties = capturedEvent.getEventProperties(); + Assert.assertEquals(verificationPendingMobile, eventProperties.get(IdentityRecoveryConstants.SEND_TO)); + Assert.assertEquals(newCode, eventProperties.get(IdentityRecoveryConstants.CONFIRMATION_CODE)); + + // Reset data. + reset(userRecoveryDataStore); + reset(identityEventService); + + // Case 2: MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE recovery scenario. + UserRecoveryData userRecoveryData2 = new UserRecoveryData(user, oldCode, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER); + userRecoveryData2.setRemainingSetIds(verificationPendingMobile); + when(userRecoveryDataStore.loadWithoutCodeExpiryValidation(user, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE)).thenReturn(userRecoveryData2); + + NotificationResponseBean responseBean2 = resendConfirmationManager.resendConfirmationCode( + user, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString(), + RecoverySteps.VERIFY_MOBILE_NUMBER.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_MOBILE_ON_UPDATE, properties); + assertNotNull(responseBean2); + + ArgumentCaptor recoveryDataCaptor2 = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor2.capture()); + UserRecoveryData capturedRecoveryData2 = recoveryDataCaptor2.getValue(); + Assert.assertEquals(RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + capturedRecoveryData2.getRecoveryScenario()); + Assert.assertEquals(verificationPendingMobile, + capturedRecoveryData2.getRemainingSetIds()); + } + + @Test + public void testResendConfirmationCodeEmailVerificationOnUpdate() throws Exception { + + String verificationPendingEmail = "testuser@gmail.com"; + String oldCode = "dummy-code"; + String newCode = "new-code"; + User user = getUser(); + Property[] properties = new Property[]{new Property("testKey", "testValue")}; + + UserRecoveryData userRecoveryData = new UserRecoveryData(user, oldCode, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL); + userRecoveryData.setRemainingSetIds(verificationPendingEmail); + when(userRecoveryDataStore.loadWithoutCodeExpiryValidation(user, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE)).thenReturn(userRecoveryData); + + mockedUtils.when(() -> Utils.reIssueExistingConfirmationCode(userRecoveryData, + NotificationChannels.EMAIL_CHANNEL.getChannelType())).thenReturn(false); + mockedUtils.when(() -> Utils.generateSecretKey(anyString(), anyString(), anyString(), anyString())) + .thenReturn(newCode); + mockUtilsErrors(); + + NotificationResponseBean responseBean = resendConfirmationManager.resendConfirmationCode( + user, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.toString(), + RecoverySteps.VERIFY_EMAIL.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_EMAIL_ON_UPDATE, properties); + + assertNotNull(responseBean); + + ArgumentCaptor recoveryDataCaptor = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor.capture()); + UserRecoveryData capturedRecoveryData = recoveryDataCaptor.getValue(); + Assert.assertEquals(RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, + capturedRecoveryData.getRecoveryScenario()); + Assert.assertEquals(verificationPendingEmail, + capturedRecoveryData.getRemainingSetIds()); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class); + verify(identityEventService).handleEvent(eventCaptor.capture()); + Event capturedEvent = eventCaptor.getValue(); + Map eventProperties = capturedEvent.getEventProperties(); + Assert.assertEquals(verificationPendingEmail, eventProperties.get(IdentityRecoveryConstants.SEND_TO)); + Assert.assertEquals(newCode, eventProperties.get(IdentityRecoveryConstants.CONFIRMATION_CODE)); + + // Reset. + reset(userRecoveryDataStore); + reset(identityEventService); + + // Case 2: EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE recovery scenario. + UserRecoveryData userRecoveryData2 = new UserRecoveryData(user, oldCode, + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_EMAIL); + userRecoveryData2.setRemainingSetIds(verificationPendingEmail); + when(userRecoveryDataStore.loadWithoutCodeExpiryValidation(user, + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE)).thenReturn(userRecoveryData2); + + NotificationResponseBean responseBean2 = resendConfirmationManager.resendConfirmationCode( + user, + RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString(), + RecoverySteps.VERIFY_EMAIL.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_EMAIL_ON_UPDATE, properties); + assertNotNull(responseBean2); + + ArgumentCaptor recoveryDataCaptor2 = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor2.capture()); + UserRecoveryData capturedRecoveryData2 = recoveryDataCaptor2.getValue(); + Assert.assertEquals(RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + capturedRecoveryData2.getRecoveryScenario()); + Assert.assertEquals(verificationPendingEmail, + capturedRecoveryData2.getRemainingSetIds()); + } + + @Test + public void testResendConfirmationCodeErrorScenarios() throws Exception { + + String verificationPendingMobile = "0777897621"; + String oldCode = "dummy-code"; + String newCode = "new-code"; + User user = getUser(); + Property[] properties = new Property[]{new Property("testKey", "testValue")}; + + UserRecoveryData userRecoveryData = new UserRecoveryData(user, oldCode, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER); + userRecoveryData.setRemainingSetIds(verificationPendingMobile); + when(userRecoveryDataStore.loadWithoutCodeExpiryValidation(user, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE)).thenReturn(userRecoveryData); + + mockedUtils.when(() -> Utils.reIssueExistingConfirmationCode(userRecoveryData, + NotificationChannels.SMS_CHANNEL.getChannelType())).thenReturn(false); + mockedUtils.when(() -> Utils.generateSecretKey(anyString(), anyString(), anyString(), anyString())) + .thenReturn(newCode); + mockUtilsErrors(); + + // Case 1: Null user. + try { + resendConfirmationManager.resendConfirmationCode(null, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString(), + RecoverySteps.VERIFY_MOBILE_NUMBER.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_MOBILE_ON_UPDATE, properties); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + + // Case 2: Empty Recovery scenario. + try { + resendConfirmationManager.resendConfirmationCode(user, + "", + RecoverySteps.VERIFY_MOBILE_NUMBER.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_MOBILE_ON_UPDATE, properties); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + + // Case 3: Empty Recovery step. + try { + resendConfirmationManager.resendConfirmationCode(user, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString(), + "", + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_MOBILE_ON_UPDATE, properties); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + + // Case 4: Empty Notification type. + try { + resendConfirmationManager.resendConfirmationCode(user, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString(), + RecoverySteps.VERIFY_MOBILE_NUMBER.toString(), + "", properties); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + } + + @Test + public void testResendConfirmationCodeWithCode() throws IdentityRecoveryException, IdentityEventException { + + String verificationPendingEmail = "testuser@gmail.com"; + String oldCode = "dummy-code"; + String newCode = "new-code"; + User user = getUser(); + Property[] properties = new Property[]{new Property("testKey", "testValue")}; + + UserRecoveryData userRecoveryData = new UserRecoveryData(user, oldCode, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL); + userRecoveryData.setRemainingSetIds(verificationPendingEmail); + when(userRecoveryDataStore.loadWithoutCodeExpiryValidation(user, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE)).thenReturn(userRecoveryData); + + mockedUtils.when(() -> Utils.reIssueExistingConfirmationCode(userRecoveryData, + NotificationChannels.EMAIL_CHANNEL.getChannelType())).thenReturn(false); + mockedUtils.when(() -> Utils.generateSecretKey(anyString(), anyString(), anyString(), anyString())) + .thenReturn(newCode); + mockUtilsErrors(); + + NotificationResponseBean responseBean = resendConfirmationManager.resendConfirmationCode( + user, + oldCode, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE.toString(), + RecoverySteps.VERIFY_EMAIL.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_EMAIL_ON_UPDATE, properties); + + assertNotNull(responseBean); + + ArgumentCaptor recoveryDataCaptor = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor.capture()); + UserRecoveryData capturedRecoveryData = recoveryDataCaptor.getValue(); + Assert.assertEquals(RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, + capturedRecoveryData.getRecoveryScenario()); + Assert.assertEquals(verificationPendingEmail, + capturedRecoveryData.getRemainingSetIds()); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class); + verify(identityEventService).handleEvent(eventCaptor.capture()); + Event capturedEvent = eventCaptor.getValue(); + Map eventProperties = capturedEvent.getEventProperties(); + Assert.assertEquals(verificationPendingEmail, eventProperties.get(IdentityRecoveryConstants.SEND_TO)); + Assert.assertEquals(newCode, eventProperties.get(IdentityRecoveryConstants.CONFIRMATION_CODE)); + + // Case 2: Code not given. + try { + resendConfirmationManager.resendConfirmationCode( + user, + null, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString(), + RecoverySteps.VERIFY_MOBILE_NUMBER.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_VERIFY_MOBILE_ON_UPDATE, properties); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + } + + @Test + public void testResendConfirmation() throws IdentityRecoveryException { + + String resendCode = "dummy-code"; + String recoveryFlowId = "dummy-flow-id"; + int resendCount = 2; + User user = getUser(); + Property[] properties = new Property[]{new Property("testKey", "testValue")}; + + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants.ConnectorConfig. + RECOVERY_NOTIFICATION_PASSWORD_MAX_RESEND_ATTEMPTS, TEST_TENANT_DOMAIN)).thenReturn("5"); + mockUtilsErrors(); + + UserRecoveryData userRecoveryData = new UserRecoveryData(user, resendCode, + RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.VERIFY_EMAIL); + userRecoveryData.setRemainingSetIds(NotificationChannels.EMAIL_CHANNEL.getChannelType()); + userRecoveryData.setRecoveryFlowId(recoveryFlowId); + + when(userAccountRecoveryManager.getUserRecoveryData(anyString(), any())).thenReturn(userRecoveryData); + + UserRecoveryFlowData userRecoveryFlowData = mock(UserRecoveryFlowData.class); + when(userAccountRecoveryManager.loadUserRecoveryFlowData(userRecoveryData)) + .thenReturn(userRecoveryFlowData); + when(userRecoveryFlowData.getResendCount()).thenReturn(resendCount); + + ResendConfirmationDTO resendConfirmationDTO = resendConfirmationManager.resendConfirmation( + TEST_TENANT_DOMAIN, resendCode, RecoveryScenarios.SELF_SIGN_UP.toString(), + RecoverySteps.VERIFY_EMAIL.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_EMAIL_CONFIRM, + properties); + assertEquals(resendConfirmationDTO.getSuccessCode(), + IdentityRecoveryConstants.SuccessEvents.SUCCESS_STATUS_CODE_RESEND_CONFIRMATION_CODE.getCode()); + verify(userAccountRecoveryManager).updateRecoveryDataResendCount(recoveryFlowId, resendCount + 1); + + // Case 2: Resend count exceeds the maximum limit. + when(userRecoveryFlowData.getResendCount()).thenReturn(5); + try { + resendConfirmationManager.resendConfirmation( + TEST_TENANT_DOMAIN, resendCode, RecoveryScenarios.SELF_SIGN_UP.toString(), + RecoverySteps.VERIFY_EMAIL.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_EMAIL_CONFIRM, + properties); + verify(userAccountRecoveryManager).invalidateRecoveryData(recoveryFlowId); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + + // Case 2: When recovery scenarios doesn't match. + when(userRecoveryFlowData.getResendCount()).thenReturn(1); + try { + resendConfirmationManager.resendConfirmation( + TEST_TENANT_DOMAIN, resendCode, RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString(), + RecoverySteps.VERIFY_EMAIL.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_EMAIL_CONFIRM, + properties); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + + // Case 3: When tenant domain doesn't match. + try { + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants.ConnectorConfig. + RECOVERY_NOTIFICATION_PASSWORD_MAX_RESEND_ATTEMPTS, "OTHER_DOMAIN")) + .thenReturn("5"); + resendConfirmationManager.resendConfirmation( + "OTHER_DOMAIN", resendCode, RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE.toString(), + RecoverySteps.VERIFY_EMAIL.toString(), + IdentityRecoveryConstants.NOTIFICATION_TYPE_EMAIL_CONFIRM, + properties); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + + } + + private static User getUser() { + + User user = new User(); + user.setUserName(TEST_USERNAME); + user.setTenantDomain(TEST_TENANT_DOMAIN); + user.setUserStoreDomain(TEST_USER_STORE_DOMAIN); + return user; + } + + private void mockUtilsErrors() { + + mockedUtils.when(() -> Utils.handleClientException(any(IdentityRecoveryConstants.ErrorMessages.class), + any())).thenReturn(new IdentityRecoveryClientException("test-code", "test-dec")); + + mockedUtils.when(() -> Utils.handleClientException(any(IdentityRecoveryConstants.ErrorMessages.class), + isNull())).thenReturn(new IdentityRecoveryClientException("test-code", "test-dec")); + + mockedUtils.when(() -> Utils.handleClientException(anyString(), anyString(), any())) + .thenReturn(new IdentityRecoveryClientException("test-code", "test-dec")); + + mockedUtils.when(() -> Utils.handleClientException(any(IdentityRecoveryConstants.ErrorMessages.class), + anyString())).thenReturn(new IdentityRecoveryClientException("test-code", "test-dec")); + + } +} diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/connector/UserClaimUpdateConfigImplTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/connector/UserClaimUpdateConfigImplTest.java index 4383641b4d..9520096812 100644 --- a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/connector/UserClaimUpdateConfigImplTest.java +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/connector/UserClaimUpdateConfigImplTest.java @@ -1,25 +1,27 @@ /* - * Copyright (c) 2020, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * Copyright (c) 2020-2024, WSO2 LLC. (http://www.wso2.com). * - * WSO2 Inc. licenses this file to you under the Apache License, + * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except * in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the + * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ + package org.wso2.carbon.identity.recovery.connector; import org.apache.axiom.om.OMElement; import org.mockito.MockedStatic; import org.mockito.Mockito; +import org.testng.Assert; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.BeforeTest; @@ -28,6 +30,7 @@ import org.wso2.carbon.identity.core.util.IdentityCoreConstants; import org.wso2.carbon.identity.governance.IdentityGovernanceException; import org.wso2.carbon.identity.recovery.IdentityRecoveryConstants; +import org.wso2.carbon.identity.application.common.model.Property; import java.util.ArrayList; import java.util.HashMap; @@ -61,6 +64,8 @@ public class UserClaimUpdateConfigImplTest { private static final String VERIFICATION_CODE_ELEMENT = "VerificationCode"; private static final String EXPIRY_TIME_ELEMENT = "ExpiryTime"; private static final String VERIFICATION_ON_UPDATE_ELEMENT = "VerificationOnUpdate"; + private static final String ENABLE_MULTIPLE_EMAILS_AND_MOBILE_NUMBERS_ELEMENT = + "EnableMultipleEmailsAndMobileNumbers"; private MockedStatic mockedIdentityConfigParser; @BeforeTest @@ -189,6 +194,7 @@ public void testGetPropertyNames() { propertiesExpected.add(IdentityRecoveryConstants.ConnectorConfig.ENABLE_NOTIFICATION_ON_EMAIL_UPDATE); propertiesExpected.add(IdentityRecoveryConstants.ConnectorConfig.ENABLE_MOBILE_NUM_VERIFICATION_ON_UPDATE); propertiesExpected.add(IdentityRecoveryConstants.ConnectorConfig.MOBILE_NUM_VERIFICATION_ON_UPDATE_EXPIRY_TIME); + propertiesExpected.add(IdentityRecoveryConstants.ConnectorConfig.ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER); String[] propertiesArrayExpected = propertiesExpected.toArray(new String[0]); String[] properties = userClaimUpdateConfig.getPropertyNames(); @@ -220,6 +226,8 @@ public void testGetDefaultPropertyValues() throws IdentityGovernanceException { VERIFICATION_CODE_ELEMENT))).thenReturn(mockOMElement); when(mockOMElement.getFirstChildWithName(new QName(IdentityCoreConstants.IDENTITY_DEFAULT_NAMESPACE, EXPIRY_TIME_ELEMENT))).thenReturn(mockOMElement); + when(mockOMElement.getFirstChildWithName(new QName(IdentityCoreConstants.IDENTITY_DEFAULT_NAMESPACE, + USER_CLAIM_UPDATE_ELEMENT))).thenReturn(mockOMElement); Properties defaultPropertyValues = userClaimUpdateConfig.getDefaultPropertyValues(TENANT_DOMAIN); assertNotNull(defaultPropertyValues.getProperty(IdentityRecoveryConstants.ConnectorConfig @@ -244,7 +252,7 @@ public void testGetDefaultProperties() throws IdentityGovernanceException { EMAIL_VERIFICATION_ON_UPDATE_OTP_LENGTH, IdentityRecoveryConstants.ConnectorConfig .EMAIL_VERIFICATION_ON_UPDATE_EXPIRY_TIME, IdentityRecoveryConstants.ConnectorConfig .ENABLE_MOBILE_NUM_VERIFICATION_ON_UPDATE, IdentityRecoveryConstants.ConnectorConfig - .MOBILE_NUM_VERIFICATION_ON_UPDATE_EXPIRY_TIME,"testproperty"}; + .MOBILE_NUM_VERIFICATION_ON_UPDATE_EXPIRY_TIME, "testproperty"}; IdentityConfigParser mockConfigParser = mock(IdentityConfigParser.class); mockedIdentityConfigParser.when(IdentityConfigParser::getInstance).thenReturn(mockConfigParser); @@ -253,4 +261,11 @@ public void testGetDefaultProperties() throws IdentityGovernanceException { assertEquals(defaultPropertyValues.size(), propertyNames.length - 1, "Maps are not equal as" + " their size differs."); } + + @Test + public void testGetMetaData() { + + Map metaData = userClaimUpdateConfig.getMetaData(); + Assert.assertEquals(metaData.size(), 10); + } } diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/MobileNumberVerificationHandlerTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/MobileNumberVerificationHandlerTest.java new file mode 100644 index 0000000000..3d407f715b --- /dev/null +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/MobileNumberVerificationHandlerTest.java @@ -0,0 +1,626 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.recovery.handler; + +import org.apache.commons.lang.StringUtils; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; +import org.wso2.carbon.identity.event.IdentityEventClientException; +import org.wso2.carbon.identity.event.IdentityEventException; +import org.wso2.carbon.identity.event.event.Event; +import org.testng.annotations.Test; +import org.testng.Assert; +import org.wso2.carbon.identity.event.IdentityEventConstants; +import org.wso2.carbon.identity.event.services.IdentityEventService; +import org.wso2.carbon.identity.governance.service.notification.NotificationChannels; +import org.wso2.carbon.identity.recovery.IdentityRecoveryConstants; +import org.wso2.carbon.identity.recovery.IdentityRecoveryException; +import org.wso2.carbon.identity.recovery.RecoveryScenarios; +import org.wso2.carbon.identity.recovery.RecoverySteps; +import org.wso2.carbon.identity.recovery.internal.IdentityRecoveryServiceDataHolder; +import org.wso2.carbon.identity.recovery.model.UserRecoveryData; +import org.wso2.carbon.identity.recovery.store.JDBCRecoveryDataStore; +import org.wso2.carbon.identity.recovery.store.UserRecoveryDataStore; +import org.wso2.carbon.identity.recovery.util.Utils; +import org.wso2.carbon.user.api.Claim; +import org.wso2.carbon.user.api.ClaimManager; +import org.wso2.carbon.user.core.tenant.TenantManager; +import org.wso2.carbon.user.api.UserRealm; +import org.wso2.carbon.user.core.UserCoreConstants; +import org.wso2.carbon.user.core.UserStoreManager; +import org.wso2.carbon.user.core.config.RealmConfiguration; +import org.wso2.carbon.user.core.service.RealmService; +import org.wso2.carbon.user.api.UserStoreException; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit test cases for MobileNumberVerificationHandler. + */ +public class MobileNumberVerificationHandlerTest { + + @InjectMocks + private MobileNumberVerificationHandler mobileNumberVerificationHandler; + + @Mock + private UserStoreManager userStoreManager; + + @Mock + private RealmConfiguration realmConfiguration; + + @Mock + private ClaimManager claimManager; + + @Mock + private RealmService realmService; + + @Mock + private UserRealm userRealm; + + @Mock + private TenantManager tenantManager; + + @Mock + private UserRecoveryDataStore userRecoveryDataStore; + + @Mock + private IdentityEventService identityEventService; + + @Mock + private IdentityRecoveryServiceDataHolder serviceDataHolder; + + private MockedStatic mockedJDBCRecoveryDataStore; + private MockedStatic mockedUtils; + private MockedStatic mockedIdentityRecoveryServiceDataHolder; + private MockedStatic mockedFrameworkUtils; + + private static final String TEST_USERNAME = "testuser"; + private static final String TEST_TENANT_DOMAIN = "test.com"; + private static final int TEST_TENANT_ID = 5; + private static final String TEST_USER_STORE_DOMAIN = "TESTING"; + private static final String EXISTING_NUMBER_1 = "0777777777"; + private static final String EXISTING_NUMBER_2 = "0711111111"; + private static final String NEW_MOBILE_NUMBER = "0722222222"; + + @BeforeMethod + public void setUpMethod() throws UserStoreException { + + MockitoAnnotations.openMocks(this); + mobileNumberVerificationHandler = new MobileNumberVerificationHandler(); + mockedJDBCRecoveryDataStore = mockStatic(JDBCRecoveryDataStore.class); + mockedUtils = mockStatic(Utils.class); + mockedIdentityRecoveryServiceDataHolder = mockStatic(IdentityRecoveryServiceDataHolder.class); + mockedFrameworkUtils = mockStatic(FrameworkUtils.class); + + mockedJDBCRecoveryDataStore.when(JDBCRecoveryDataStore::getInstance).thenReturn(userRecoveryDataStore); + mockedIdentityRecoveryServiceDataHolder.when(IdentityRecoveryServiceDataHolder::getInstance) + .thenReturn(serviceDataHolder); + mockedFrameworkUtils.when(FrameworkUtils::getMultiAttributeSeparator).thenReturn(","); + mockedUtils.when(() -> Utils.resolveEventName(NotificationChannels.SMS_CHANNEL.getChannelType())).thenReturn( + "TRIGGER_SMS_NOTIFICATION_LOCAL"); + + when(serviceDataHolder.getRealmService()).thenReturn(realmService); + when(serviceDataHolder.getIdentityEventService()).thenReturn(identityEventService); + when(realmService.getTenantManager()).thenReturn(tenantManager); + when(tenantManager.getTenantId(TEST_TENANT_DOMAIN)).thenReturn(TEST_TENANT_ID); + when(realmService.getTenantUserRealm(anyInt())).thenReturn(userRealm); + when(userRealm.getUserStoreManager()).thenReturn(userStoreManager); + when(userRealm.getClaimManager()).thenReturn(claimManager); + when(userStoreManager.getRealmConfiguration()).thenReturn(realmConfiguration); + when(realmConfiguration.getUserStoreProperty(UserCoreConstants.RealmConfig.PROPERTY_DOMAIN_NAME)) + .thenReturn(TEST_USER_STORE_DOMAIN); + } + + @AfterMethod + public void tearDown() { + + mockedJDBCRecoveryDataStore.close(); + mockedUtils.close(); + mockedIdentityRecoveryServiceDataHolder.close(); + mockedFrameworkUtils.close(); + Utils.unsetThreadLocalIsOnlyVerifiedEmailAddressesUpdated(); + Utils.unsetThreadLocalIsOnlyVerifiedMobileNumbersUpdated(); + Utils.unsetThreadLocalToSkipSendingEmailVerificationOnUpdate(); + Utils.unsetThreadLocalIsOnlyVerifiedMobileNumbersUpdated(); + } + + @Test + public void testGetNames() { + + Assert.assertEquals(mobileNumberVerificationHandler.getName(), "userMobileVerification"); + Assert.assertEquals(mobileNumberVerificationHandler.getFriendlyName(), "User Mobile Number Verification"); + } + + @Test(description = "Verification disabled, Multi-attribute disabled, Change primary mobile") + public void testHandleEventVerificationDisabledMultiAttributeDisabled() + throws UserStoreException, IdentityEventException, IdentityRecoveryException { + + Event event = + createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIM, null, + null, null, NEW_MOBILE_NUMBER); + mockVerificationPendingMobileNumber(); + mockUtilMethods(false, false, false); + + mobileNumberVerificationHandler.handleEvent(event); + + // Expectation: Any pending mobile verification should be invalidated. + verify(userRecoveryDataStore).invalidate(any(), eq(RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE), + eq(RecoverySteps.VERIFY_MOBILE_NUMBER)); + Map userClaims = getUserClaimsFromEvent(event); + Assert.assertEquals(userClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM), + StringUtils.EMPTY); + + reset(userRecoveryDataStore); + // Case 2: User claims null. + Event event2 = + createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIM, null, + null, null, null); + mobileNumberVerificationHandler.handleEvent(event2); + verify(userRecoveryDataStore, never()).invalidate(any(), eq(RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE), any()); + } + + @Test(description = "Verification disabled, Multi-attribute enabled, Change primary mobile") + public void testHandleEventVerificationDisabledMultiAttributeEnabled() + throws UserStoreException, IdentityEventException, IdentityRecoveryException { + + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIM, null, + null, null, NEW_MOBILE_NUMBER); + mockVerificationPendingMobileNumber(); + mockUtilMethods(false, true, false); + + // New primary mobile number is not included in existing all mobile numbers list. + List allMobileNumbers = new ArrayList<>(Arrays.asList(EXISTING_NUMBER_1, EXISTING_NUMBER_2)); + mockExistingNumbersList(allMobileNumbers); + + // Expectation: New mobile number should be added to the mobile numbers claim. + mobileNumberVerificationHandler.handleEvent(event); + Map userClaims = getUserClaimsFromEvent(event); + Assert.assertTrue(StringUtils.contains( + userClaims.get(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM), NEW_MOBILE_NUMBER)); + + // Case 2: Send mobile numbers claim with mobile number claim. + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIM, null, + null, EXISTING_NUMBER_1, NEW_MOBILE_NUMBER); + mockVerificationPendingMobileNumber(); + mockUtilMethods(false, true, false); + + // New primary mobile number is not included in existing all mobile numbers list. + mockExistingNumbersList(null); + + // Expectation: New mobile number should be added to the mobile numbers claim. + mobileNumberVerificationHandler.handleEvent(event2); + Map userClaims2 = getUserClaimsFromEvent(event2); + Assert.assertTrue(StringUtils.contains( + userClaims2.get(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM), NEW_MOBILE_NUMBER)); + Assert.assertTrue(StringUtils.contains( + userClaims2.get(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM), EXISTING_NUMBER_1)); + + // Case 3: Updated verified mobile numbers list. + Event event3 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIM, null, + NEW_MOBILE_NUMBER, null, null); + mockVerificationPendingMobileNumber(); + + // Expectation: Verified mobile number claim should be removed from user claims. + mobileNumberVerificationHandler.handleEvent(event3); + verify(userRecoveryDataStore, times(3)).invalidate(any(), + eq(RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE), + eq(RecoverySteps.VERIFY_MOBILE_NUMBER)); + Map userClaims3 = getUserClaimsFromEvent(event3); + Assert.assertFalse(userClaims3.containsKey(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM)); + } + + @Test(description = "PRE_SET_USER_CLAIMS: Verification enabled, Multi-attribute disabled, Claims null") + public void testHandleEventVerificationEnabledMultiAttributeDisabledThreadLocal() throws Exception { + + /* + Case 1: Claims null, skipSendingSmsOtpVerificationOnUpdate set to skip. + */ + mockVerificationPendingMobileNumber(); + mockUtilMethods(true, false, false); + Event event1 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, null, + null, null, null); + + mobileNumberVerificationHandler.handleEvent(event1); + mockedUtils.verify(() -> Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate( + eq(IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates + .SKIP_ON_INAPPLICABLE_CLAIMS.toString()))); + + /* + Case 2: skipSendingSmsOtpVerificationOnUpdate set to skip. + */ + // Case 2.1: skipSendingSmsOtpVerificationOnUpdate set to SKIP_ON_CONFIRM. + mockVerificationPendingMobileNumber(); + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, null, + null, null, NEW_MOBILE_NUMBER); + mockedUtils.when(Utils::getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate) + .thenReturn(IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates + .SKIP_ON_CONFIRM.toString()); + + mobileNumberVerificationHandler.handleEvent(event2); + Map userClaims2 = getUserClaimsFromEvent(event2); + Assert.assertEquals(userClaims2.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM), + StringUtils.EMPTY); + + // Case 2.2: skipSendingSmsOtpVerificationOnUpdate set to SKIP_ON_SMS_OTP_FLOW. + mockVerificationPendingMobileNumber(); + Event event2_1 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, null, + null, null, NEW_MOBILE_NUMBER); + mockedUtils.when(Utils::getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate) + .thenReturn(IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates + .SKIP_ON_SMS_OTP_FLOW.toString()); + + mobileNumberVerificationHandler.handleEvent(event2_1); + Map userClaims2_1 = getUserClaimsFromEvent(event2_1); + Assert.assertEquals(userClaims2_1.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM), + StringUtils.EMPTY); + + /* + Case 3: skipSendingSmsOtpVerificationOnUpdate set to some value. + */ + Event event3 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, null, + null, null, NEW_MOBILE_NUMBER); + mockedUtils.when(Utils::getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate) + .thenReturn("test"); + + mobileNumberVerificationHandler.handleEvent(event3); + mockedUtils.verify(Utils::unsetThreadLocalToSkipSendingSmsOtpVerificationOnUpdate); + } + + @Test(description = "PRE_SET_USER_CLAIMS: Verification enabled, Multi-attribute disabled, Change primary mobile") + public void testHandleEventVerificationEnabledMultiAttributeDisabled() throws Exception { + + mockVerificationPendingMobileNumber(); + mockUtilMethods(true, false, false); + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, null, + null, null, NEW_MOBILE_NUMBER); + + mobileNumberVerificationHandler.handleEvent(event); + + Map userClaims = getUserClaimsFromEvent(event); + Assert.assertEquals(userClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM), + NEW_MOBILE_NUMBER); + + /* + Case 2: New mobile number is same as existing primary mobile number. + */ + mockExistingPrimaryMobileNumber(NEW_MOBILE_NUMBER); + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, null, + null, null, NEW_MOBILE_NUMBER); + + mobileNumberVerificationHandler.handleEvent(event2); + + Map userClaims2 = getUserClaimsFromEvent(event2); + Assert.assertEquals(userClaims2.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM), + StringUtils.EMPTY); + + /* + Case 3: Enable userVerify and send verifyMobileClaim as false. + */ + mockExistingPrimaryMobileNumber(EXISTING_NUMBER_1); + mockedUtils.when(Utils::isUseVerifyClaimEnabled).thenReturn(true); + Event event3 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_MOBILE_NUMBER); + + mobileNumberVerificationHandler.handleEvent(event3); + mockedUtils.verify(() -> Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate( + IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates + .SKIP_ON_INAPPLICABLE_CLAIMS.toString()), atLeastOnce()); + + /* + Case 4: Mobile number claim is null. + */ + Event event4 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, null); + + mobileNumberVerificationHandler.handleEvent(event4); + mockedUtils.verify(() -> Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate( + IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates + .SKIP_ON_INAPPLICABLE_CLAIMS.toString()), atLeastOnce()); + + /* + Case 5: Throw error when retrieving existing mobile number. + */ + when(userStoreManager.getUserClaimValue(anyString(), + eq(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM), isNull())) + .thenThrow(new org.wso2.carbon.user.core.UserStoreException()); + try { + mobileNumberVerificationHandler.handleEvent(event); + } catch (Exception e) { + Assert.assertTrue(e instanceof IdentityEventException); + } + } + + @Test(description = "Verification enabled, Multi-attribute enabled, Update primary mobile not in verified list") + public void testUpdatePrimaryMobileNotInVerifiedList() throws Exception { + + mockUtilMethods(true, true, false); + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, null, + null, null, NEW_MOBILE_NUMBER); + mockExistingVerifiedNumbersList(new ArrayList<>(Arrays.asList(EXISTING_NUMBER_1))); + mockExistingNumbersList(new ArrayList<>(Arrays.asList(EXISTING_NUMBER_1))); + mockVerificationPendingMobileNumber(); + + mobileNumberVerificationHandler.handleEvent(event); + Map userClaims = getUserClaimsFromEvent(event); + Assert.assertEquals(userClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM), + NEW_MOBILE_NUMBER); + } + + @Test(description = "Verification enabled, Multi-attribute enabled, Update primary mobile in verified list") + public void testUpdatePrimaryMobileInVerifiedList() throws Exception { + + mockVerificationPendingMobileNumber(); + mockUtilMethods(true, true, false); + String newVerifiedMobileNumbers = EXISTING_NUMBER_1 + "," + NEW_MOBILE_NUMBER; + String newMobileNumbers = EXISTING_NUMBER_1 + "," + NEW_MOBILE_NUMBER; + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, + IdentityRecoveryConstants.FALSE, + newVerifiedMobileNumbers, newMobileNumbers, NEW_MOBILE_NUMBER); + mockExistingVerifiedNumbersList(Arrays.asList(EXISTING_NUMBER_1, NEW_MOBILE_NUMBER)); + + + mobileNumberVerificationHandler.handleEvent(event); + + mockedUtils.verify(() -> Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate( + eq(IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates + .SKIP_ON_ALREADY_VERIFIED_MOBILE_NUMBERS.toString()))); + } + + @Test(description = "Verification enabled, Multi-attribute enabled, Add new mobile to verified list") + public void testAddNewMobileToVerifiedList() throws Exception { + + mockVerificationPendingMobileNumber(); + mockUtilMethods(true, true, false); + String newVerifiedMobileNumbers = EXISTING_NUMBER_1 + "," + NEW_MOBILE_NUMBER; + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, + IdentityRecoveryConstants.FALSE, + newVerifiedMobileNumbers, null, null); + mockExistingVerifiedNumbersList(new ArrayList<>(Arrays.asList(EXISTING_NUMBER_1))); + mockExistingNumbersList(new ArrayList<>(Arrays.asList(EXISTING_NUMBER_1))); + + mobileNumberVerificationHandler.handleEvent(event); + + Map userClaims = getUserClaimsFromEvent(event); + Assert.assertEquals(userClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM), + NEW_MOBILE_NUMBER); + + // Case 2: Add multiple numbers to new verified list at once. + String newVerifiedMobileNumbers2 = EXISTING_NUMBER_1 + "," + NEW_MOBILE_NUMBER; + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, + IdentityRecoveryConstants.FALSE, + newVerifiedMobileNumbers2, null, null); + mockExistingVerifiedNumbersList(new ArrayList<>(Arrays.asList())); + mockExistingNumbersList(new ArrayList<>(Arrays.asList(EXISTING_NUMBER_1))); + + try { + mobileNumberVerificationHandler.handleEvent(event2); + } catch (Exception e) { + Assert.assertTrue(e instanceof IdentityEventClientException); + } + + // Case 3: Added new number is existing primary mobile number. + String newVerifiedMobileNumbers3 = EXISTING_NUMBER_1 + "," + NEW_MOBILE_NUMBER; + Event event3 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, + IdentityRecoveryConstants.FALSE, + newVerifiedMobileNumbers3, null, null); + + mockExistingVerifiedNumbersList(new ArrayList<>(Arrays.asList(EXISTING_NUMBER_1))); + mockExistingNumbersList(new ArrayList<>(Arrays.asList(EXISTING_NUMBER_1))); + mockExistingPrimaryMobileNumber(NEW_MOBILE_NUMBER); + + mobileNumberVerificationHandler.handleEvent(event3); + + Map userClaims3 = getUserClaimsFromEvent(event3); + Assert.assertTrue( + StringUtils.contains(userClaims3.get(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM), NEW_MOBILE_NUMBER)); + Assert.assertTrue(StringUtils.contains(userClaims3.get(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM), + NEW_MOBILE_NUMBER)); + } + + @Test(description = "POST_SET_USER_CLAIMS: Verification enabled, Multi-attribute enabled") + public void testHandleEventPostSet() throws IdentityEventException, IdentityRecoveryException, UserStoreException { + + Event event = createEvent(IdentityEventConstants.Event.POST_SET_USER_CLAIMS, null, + null, null, null); + mockUtilMethods(true, true, false); + + MockedStatic mockedStaticRecoveryScenarios = mockStatic(RecoveryScenarios.class); + mockedStaticRecoveryScenarios.when(() -> + RecoveryScenarios.getRecoveryScenario( + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString())) + .thenReturn(RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE); + + /* + Case 1: skipSendingSmsOtpVerificationOnUpdate set to skip. + Expected: Invalidation should not be triggered. + */ + mockedUtils.when(Utils::getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate) + .thenReturn(IdentityRecoveryConstants.SkipMobileNumberVerificationOnUpdateStates + .SKIP_ON_EXISTING_MOBILE_NUM.toString()); + + mobileNumberVerificationHandler.handleEvent(event); + verify(identityEventService, never()).handleEvent(any()); + + /* + Case 2: skipSendingSmsOtpVerificationOnUpdate set to null. + Expected: Notification event should be triggered. + */ + mockedUtils.when(Utils::getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate) + .thenReturn(null); + mockVerificationPendingMobileNumber(); + mobileNumberVerificationHandler.handleEvent(event); + verify(identityEventService).handleEvent(any()); + + // Case 3: Handle exception thrown from userStoreManager.getUserClaimValues. + when(userStoreManager.getUserClaimValues(eq(TEST_USERNAME), + eq(new String[]{IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM}), isNull())) + .thenThrow(new org.wso2.carbon.user.core.UserStoreException()); + try { + mobileNumberVerificationHandler.handleEvent(event); + } catch (Exception e) { + Assert.assertTrue(e instanceof IdentityEventException); + } finally { + mockedStaticRecoveryScenarios.close(); + } + } + + @Test + public void testHandleEventPostSetRecoveryScenarios() + throws IdentityEventException, IdentityRecoveryException, UserStoreException { + + Event event = createEvent(IdentityEventConstants.Event.POST_SET_USER_CLAIMS, null, + null, null, null); + mockUtilMethods(true, true, false); + + MockedStatic mockedStaticRecoveryScenarios = mockStatic(RecoveryScenarios.class); + mockedStaticRecoveryScenarios.when(() -> + RecoveryScenarios.getRecoveryScenario( + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE.toString())) + .thenReturn(RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE); + mockVerificationPendingMobileNumber(); + + // Case 1: Update the primary mobile number. + mockedUtils.when(Utils::getThreadLocalIsOnlyVerifiedMobileNumbersUpdated).thenReturn(false); + mobileNumberVerificationHandler.handleEvent(event); + + ArgumentCaptor recoveryDataCaptor = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor.capture()); + UserRecoveryData capturedRecoveryData = recoveryDataCaptor.getValue(); + Assert.assertEquals(RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, + capturedRecoveryData.getRecoveryScenario()); + Assert.assertEquals(RecoverySteps.VERIFY_MOBILE_NUMBER, capturedRecoveryData.getRecoveryStep()); + + reset(userRecoveryDataStore); + // Case 2: Update the verified list. + Event event2 = createEvent(IdentityEventConstants.Event.POST_SET_USER_CLAIMS, null, + null, null, null); + mockedUtils.when(Utils::getThreadLocalIsOnlyVerifiedMobileNumbersUpdated).thenReturn(true); + mobileNumberVerificationHandler.handleEvent(event2); + + ArgumentCaptor recoveryDataCaptor2 = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor2.capture()); + UserRecoveryData capturedRecoveryData2 = recoveryDataCaptor2.getValue(); + Assert.assertEquals(RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + capturedRecoveryData2.getRecoveryScenario()); + Assert.assertEquals(RecoverySteps.VERIFY_MOBILE_NUMBER, capturedRecoveryData2.getRecoveryStep()); + } + + private void mockExistingPrimaryMobileNumber(String mobileNumber) throws UserStoreException { + + when(userStoreManager.getUserClaimValue(anyString(), + eq(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM), isNull())).thenReturn(mobileNumber); + } + + private void mockExistingNumbersList(List existingAllMobileNumbers) { + + mockedUtils.when(() -> Utils.getMultiValuedClaim(any(), any(), + eq(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM))) + .thenReturn(existingAllMobileNumbers); + } + + private void mockExistingVerifiedNumbersList(List exisitingVerifiedNumbersList) { + + mockedUtils.when(() -> Utils.getMultiValuedClaim(any(), any(), + eq(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM))) + .thenReturn(exisitingVerifiedNumbersList); + } + + + @SuppressWarnings("unchecked") + private static Map getUserClaimsFromEvent(Event event2) { + + Map eventProperties = event2.getEventProperties(); + return (Map) eventProperties.get(IdentityEventConstants.EventProperty.USER_CLAIMS); + } + + private void mockUtilMethods(boolean mobileVerificationEnabled, boolean multiAttributeEnabled, + boolean useVerifyClaimEnabled) { + + mockedUtils.when( + Utils::isMultiEmailsAndMobileNumbersPerUserEnabled).thenReturn(multiAttributeEnabled); + mockedUtils.when(Utils::isUseVerifyClaimEnabled).thenReturn(useVerifyClaimEnabled); + mockedUtils.when(() -> Utils.getConnectorConfig( + eq(IdentityRecoveryConstants.ConnectorConfig.ENABLE_MOBILE_NUM_VERIFICATION_ON_UPDATE), + anyString())) + .thenReturn(String.valueOf(mobileVerificationEnabled)); + } + + private void mockVerificationPendingMobileNumber() throws UserStoreException { + + // Verification pending mobile number claim config. + Claim pendingMobileNumberClaim = new Claim(); + pendingMobileNumberClaim.setClaimUri(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM); + when(claimManager.getClaim(eq(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM))) + .thenReturn(pendingMobileNumberClaim); + + Map pendingMobileNumberClaimMap = new HashMap<>(); + pendingMobileNumberClaimMap.put(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM, EXISTING_NUMBER_2); + when(userStoreManager.getUserClaimValues(eq(TEST_USERNAME), + eq(new String[]{IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM}), isNull())) + .thenReturn(pendingMobileNumberClaimMap); + } + + private Event createEvent(String eventType, String verifyMobileClaim, String verifiedMobileNumbersClaim, + String mobileNumbersClaim, String mobileNumber) { + + Map eventProperties = new HashMap<>(); + eventProperties.put(IdentityEventConstants.EventProperty.USER_NAME, TEST_USERNAME); + eventProperties.put(IdentityEventConstants.EventProperty.TENANT_DOMAIN, TEST_TENANT_DOMAIN); + eventProperties.put(IdentityEventConstants.EventProperty.USER_STORE_MANAGER, userStoreManager); + + Map claims = new HashMap<>(); + if (mobileNumber != null && !mobileNumber.isEmpty()) { + claims.put(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM, mobileNumber); + } + if (verifyMobileClaim != null) { + claims.put(IdentityRecoveryConstants.VERIFY_MOBILE_CLAIM, verifyMobileClaim); + } + if (mobileNumbersClaim != null) { + claims.put(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM, mobileNumbersClaim); + } + if (verifiedMobileNumbersClaim != null) { + claims.put(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM, verifiedMobileNumbersClaim); + } + eventProperties.put(IdentityEventConstants.EventProperty.USER_CLAIMS, claims); + return new Event(eventType, eventProperties); + } +} diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandlerTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandlerTest.java new file mode 100644 index 0000000000..aaf196c914 --- /dev/null +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/handler/UserEmailVerificationHandlerTest.java @@ -0,0 +1,827 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.recovery.handler; + +import org.apache.commons.lang.StringUtils; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; +import org.wso2.carbon.identity.core.bean.context.MessageContext; +import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.event.IdentityEventConstants; +import org.wso2.carbon.identity.event.services.IdentityEventService; +import org.wso2.carbon.identity.event.IdentityEventClientException; +import org.wso2.carbon.identity.event.IdentityEventException; +import org.wso2.carbon.identity.event.event.Event; +import org.wso2.carbon.identity.governance.service.notification.NotificationChannels; +import org.wso2.carbon.identity.recovery.IdentityRecoveryConstants; +import org.wso2.carbon.identity.recovery.IdentityRecoveryException; +import org.wso2.carbon.identity.recovery.IdentityRecoveryServerException; +import org.wso2.carbon.identity.recovery.RecoveryScenarios; +import org.wso2.carbon.identity.recovery.RecoverySteps; +import org.wso2.carbon.identity.recovery.internal.IdentityRecoveryServiceDataHolder; +import org.wso2.carbon.identity.recovery.model.UserRecoveryData; +import org.wso2.carbon.identity.recovery.store.JDBCRecoveryDataStore; +import org.wso2.carbon.identity.recovery.store.UserRecoveryDataStore; +import org.wso2.carbon.identity.recovery.util.Utils; +import org.wso2.carbon.user.api.Claim; +import org.wso2.carbon.user.core.UserCoreConstants; +import org.wso2.carbon.user.core.UserStoreException; +import org.wso2.carbon.user.core.UserStoreManager; +import org. wso2.carbon. identity. application. common. model.User; +import org.wso2.carbon.user.core.config.RealmConfiguration; + +import java.util.Arrays; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class UserEmailVerificationHandlerTest { + + @InjectMocks + private UserEmailVerificationHandler userEmailVerificationHandler; + + @Mock + private UserStoreManager userStoreManager; + + @Mock + private RealmConfiguration realmConfiguration; + + @Mock + private UserRecoveryDataStore userRecoveryDataStore; + + @Mock + private IdentityEventService identityEventService; + + @Mock + private IdentityRecoveryServiceDataHolder serviceDataHolder; + + @Mock + private MessageContext messageContext; + + private MockedStatic mockedJDBCRecoveryDataStore; + private MockedStatic mockedUtils; + private MockedStatic mockedIdentityRecoveryServiceDataHolder; + private MockedStatic mockedFrameworkUtils; + private MockedStatic mockedIdentityUtils; + + private static final String TEST_TENANT_DOMAIN = "test.com"; + private static final String TEST_USER_STORE_DOMAIN = "TESTING"; + private static final String TEST_USERNAME = "testuser"; + private static final String EXISTING_EMAIL_1 = "old1@abc.com"; + private static final String EXISTING_EMAIL_2 = "old2@abc.com"; + private static final String NEW_EMAIL = "new@abc.com"; + + @AfterMethod + public void close() { + + mockedJDBCRecoveryDataStore.close(); + mockedUtils.close(); + mockedIdentityRecoveryServiceDataHolder.close(); + mockedFrameworkUtils.close(); + mockedIdentityUtils.close(); + } + + @BeforeMethod + public void setUp() throws NoSuchFieldException, IllegalAccessException { + + MockitoAnnotations.openMocks(this); + mockedJDBCRecoveryDataStore = mockStatic(JDBCRecoveryDataStore.class); + mockedUtils = mockStatic(Utils.class); + mockedIdentityRecoveryServiceDataHolder = mockStatic(IdentityRecoveryServiceDataHolder.class); + mockedFrameworkUtils = mockStatic(FrameworkUtils.class); + mockedIdentityUtils = mockStatic(IdentityUtil.class); + + userEmailVerificationHandler = new UserEmailVerificationHandler(); + + mockedIdentityRecoveryServiceDataHolder.when(IdentityRecoveryServiceDataHolder::getInstance) + .thenReturn(serviceDataHolder); + mockedJDBCRecoveryDataStore.when(JDBCRecoveryDataStore::getInstance).thenReturn(userRecoveryDataStore); + mockedFrameworkUtils.when(FrameworkUtils::getMultiAttributeSeparator).thenReturn(","); + mockedIdentityUtils.when(() -> IdentityUtil.addDomainToName(eq(TEST_USERNAME), anyString())) + .thenReturn(String.format("%s/%s", TEST_USERNAME, TEST_USER_STORE_DOMAIN)); + + when(serviceDataHolder.getIdentityEventService()).thenReturn(identityEventService); + when(userStoreManager.getRealmConfiguration()).thenReturn(realmConfiguration); + when(realmConfiguration.getUserStoreProperty(eq( + UserCoreConstants.RealmConfig.PROPERTY_DOMAIN_NAME))).thenReturn(TEST_USER_STORE_DOMAIN); + } + + @Test + public void getNames() { + + Assert.assertEquals(userEmailVerificationHandler.getName(), "userEmailVerification"); + Assert.assertEquals(userEmailVerificationHandler.getFriendlyName(), "User Email Verification"); + } + + @Test(description = "Verification - Disabled, Multi attribute - Disabled") + public void testHandleEventPreSetUserClaimsVerificationDisabledMultiDisabled() + throws IdentityEventException, UserStoreException { + + /* + Notification on email update is enabled. + Expected: Notification event should be triggered, pending email claim should be set to empty string. + */ + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + + mockUtilMethods(false, false, false, + true); + mockPrimaryEmail(EXISTING_EMAIL_1); + mockPendingVerificationEmail(EXISTING_EMAIL_2); + + userEmailVerificationHandler.handleEvent(event); + verify(identityEventService).handleEvent(any()); + Map userClaims = getUserClaimsFromEvent(event); + Assert.assertEquals(userClaims.get(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM), + StringUtils.EMPTY); + + // Case 2: Throw error when triggering event. + doThrow(new IdentityEventException("error")).when(identityEventService).handleEvent(any()); + try { + userEmailVerificationHandler.handleEvent(event); + } catch (Exception e) { + Assert.assertTrue(e instanceof IdentityEventException); + } + + // Reset. + doNothing().when(identityEventService).handleEvent(any()); + + // Case 2: Throw UserStoreException when getting the primary email. + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + mockUtilMethods(false, false, false, + true); + mockPendingVerificationEmail(EXISTING_EMAIL_2); + when(userStoreManager.getUserClaimValue(anyString(), eq(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM), + any())).thenThrow(new UserStoreException("error")); + + try { + userEmailVerificationHandler.handleEvent(event2); + } catch (Exception e) { + Assert.assertTrue(e instanceof IdentityEventException); + } + } + + @Test(description = "Verification - Disabled, Multi attribute - Enabled") + public void testHandleEventPreSetUserClaimsVerificationDisabledMultiEnabled() + throws IdentityEventException, IdentityRecoveryException, UserStoreException { + + /* + New Email is not in the existing email address list. + Expected: New email should be added to the new email addresses claim. + */ + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + + mockUtilMethods(false, true, false, + false); + List existingEmails = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1, EXISTING_EMAIL_2)); + mockExistingEmailAddressesList(existingEmails); + + userEmailVerificationHandler.handleEvent(event); + Map userClaims = getUserClaimsFromEvent(event); + Assert.assertTrue(StringUtils.contains( + userClaims.get(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM), NEW_EMAIL)); + + // Case 2 : Send email addresses claim with event. + String emailsClaim = String.format("%s,%s", EXISTING_EMAIL_1, EXISTING_EMAIL_2); + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, emailsClaim, NEW_EMAIL); + + mockUtilMethods(false, true, false, + false); + + userEmailVerificationHandler.handleEvent(event2); + Map userClaims2 = getUserClaimsFromEvent(event2); + Assert.assertTrue(StringUtils.contains( + userClaims2.get(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM), NEW_EMAIL)); + } + + @Test(description = "Verification - Enabled, Multi attribute - Disabled") + public void testHandleEventPreSetUserClaimsVerificationEnabledMultiDisabled() + throws IdentityEventException, IdentityRecoveryException, UserStoreException { + + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + mockUtilMethods(true, false, false, + false); + mockPendingVerificationEmail(EXISTING_EMAIL_1); + + userEmailVerificationHandler.handleEvent(event); + Map userClaimsC1 = getUserClaimsFromEvent(event); + Assert.assertEquals(userClaimsC1.get(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM), NEW_EMAIL); + + // Case 2: Send SELF_SIGNUP_ROLE with the event. + mockPendingVerificationEmail(EXISTING_EMAIL_1); + String[] roleList = new String[]{IdentityRecoveryConstants.SELF_SIGNUP_ROLE}; + Map additionalEventProperties = new HashMap<>(); + additionalEventProperties.put(IdentityEventConstants.EventProperty.ROLE_LIST, roleList); + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL, additionalEventProperties, null); + + userEmailVerificationHandler.handleEvent(event2); + Map userClaimsC2 = getUserClaimsFromEvent(event2); + Assert.assertFalse(userClaimsC2.containsKey(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM)); + + // Case 3: Try to change the primary email value with existing primary email value. + Event event3 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + mockPendingVerificationEmail(EXISTING_EMAIL_1); + mockPrimaryEmail(NEW_EMAIL); + userEmailVerificationHandler.handleEvent(event3); + Map userClaimsC3 = getUserClaimsFromEvent(event3); + Assert.assertEquals(userClaimsC3.get(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM), + StringUtils.EMPTY); + } + + @Test(description = "Verification - Enabled, Multi attribute - Disabled, User verify - Enabled") + public void testHandleEventPreSetUserClaimsVerificationEnabledMultiDisabledUserVerifyEnabled() + throws IdentityEventException, IdentityRecoveryException, UserStoreException { + + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.TRUE, + null, null, NEW_EMAIL); + mockUtilMethods(true, false, true, + false); + mockPendingVerificationEmail(EXISTING_EMAIL_1); + + userEmailVerificationHandler.handleEvent(event); + Map userClaimsC1 = getUserClaimsFromEvent(event); + Assert.assertEquals(userClaimsC1.get(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM), NEW_EMAIL); + + // Case 2: verifyEmail claim is false. + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + mockUtilMethods(true, false, true, + false); + mockPendingVerificationEmail(EXISTING_EMAIL_1); + + userEmailVerificationHandler.handleEvent(event2); + Map userClaimsC2 = getUserClaimsFromEvent(event2); + Assert.assertEquals(userClaimsC2.get(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM), + StringUtils.EMPTY); + } + + @Test(description = "Verification - Enabled, Multi attribute - Enabled, Change primary email which is not in the " + + "verified email list") + public void testHandleEventPreSetUserClaimsVerificationEnabledMultiEnabledC1() + throws IdentityEventException, IdentityRecoveryException, UserStoreException { + + /* + Try to change the primary email, new Email is not in the existing verified email address list. + Expected: IdentityEventClientException should be thrown. + */ + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + + mockUtilMethods(true, true, false, + false); + List existingEmails = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1, EXISTING_EMAIL_2)); + mockExistingEmailAddressesList(existingEmails); + + List existingVerifiedEmails = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1)); + mockExistingVerifiedEmailAddressesList(existingVerifiedEmails); + + userEmailVerificationHandler.handleEvent(event); + Map userClaims = getUserClaimsFromEvent(event); + Assert.assertEquals(userClaims.get(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM), NEW_EMAIL); + } + + @Test(description = "Verification - Enabled, Multi attribute - Enabled, Change primary email which is already " + + "in the verified email list") + public void testHandleEventPreSetUserClaimsVerificationEnabledMultiEnabledC2() throws IdentityEventException { + + /* + Try to change the primary email, new Email is in the existing verified email address list. + Expected: Thread local should be set to skip sending email verification on update. + */ + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + + mockUtilMethods(true, true, false, + false); + List existingEmails = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1, NEW_EMAIL)); + mockExistingEmailAddressesList(existingEmails); + + List existingVerifiedEmails = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1, NEW_EMAIL)); + mockExistingVerifiedEmailAddressesList(existingVerifiedEmails); + + userEmailVerificationHandler.handleEvent(event); + mockedUtils.verify(() -> Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate( + eq(IdentityRecoveryConstants.SkipEmailVerificationOnUpdateStates + .SKIP_ON_ALREADY_VERIFIED_EMAIL_ADDRESSES.toString()))); + } + + @Test(description = "Verification - Enabled, Multi attribute - Enabled, Update verified list with new email") + public void testHandleEventPreSetUserClaimsVerificationEnabledMultiEnabledC3() + throws IdentityEventException, UserStoreException { + + /* + Case 1:Try to update the verified email list with a new email. + Expected: IdentityEventClientException should be thrown. + */ + String newVerifiedEmails = String.format("%s,%s", EXISTING_EMAIL_1, NEW_EMAIL); + Event event1 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + newVerifiedEmails, null, null); + + mockUtilMethods(true, true, false, + false); + List existingEmails = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1)); + mockExistingEmailAddressesList(existingEmails); + + List existingVerifiedEmails = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1)); + mockExistingVerifiedEmailAddressesList(existingVerifiedEmails); + + userEmailVerificationHandler.handleEvent(event1); + Map userClaims1 = getUserClaimsFromEvent(event1); + Assert.assertEquals(userClaims1.get(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM), NEW_EMAIL); + + /* + Case 2: Update verified email list with the existing primary email which is not in the verified email list. + Expected: Email should be added to the updated verified email list. + */ + String newVerifiedEmails2 = String.format("%s,%s", EXISTING_EMAIL_1, NEW_EMAIL); + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + newVerifiedEmails2, null, null); + + mockUtilMethods(true, true, false, + false); + List existingEmails2 = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1)); + mockExistingEmailAddressesList(existingEmails2); + + List existingVerifiedEmails2 = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1)); + mockExistingVerifiedEmailAddressesList(existingVerifiedEmails2); + + mockPrimaryEmail(NEW_EMAIL); + + userEmailVerificationHandler.handleEvent(event2); + Map userClaims2 = getUserClaimsFromEvent(event2); + String updatedVerifiedEmails = userClaims2.get(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM); + Assert.assertTrue(StringUtils.contains(updatedVerifiedEmails, NEW_EMAIL)); + + /* + Case 3: Add multiple new emails to verified emails list. + Expected: Error should be thrown. + */ + String newVerifiedEmails3 = String.format("%s,%s", EXISTING_EMAIL_1, NEW_EMAIL); + Event event3 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + newVerifiedEmails3, null, null); + + mockUtilMethods(true, true, false, + false); + mockExistingEmailAddressesList(new ArrayList<>()); + mockExistingVerifiedEmailAddressesList(new ArrayList<>()); + + try { + userEmailVerificationHandler.handleEvent(event3); + } catch(IdentityEventClientException e) { + Assert.assertEquals(e.getErrorCode(), IdentityRecoveryConstants.ErrorMessages. + ERROR_CODE_VERIFY_MULTIPLE_EMAILS.getCode()); + } + } + + @Test(description = "Verification - Enabled, Multi attribute - Enabled, Remove email from email list") + public void testHandleEventPreSetUserClaimsVerificationEnabledMultiEnabledC4() throws IdentityEventException { + + /* + Remove an email from the existing emails list. + Expected: Removed email should be removed from the verified email list as well. + */ + Event event = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, EXISTING_EMAIL_1, null); + + mockUtilMethods(true, true, false, + false); + List existingEmails = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1, EXISTING_EMAIL_2)); + mockExistingEmailAddressesList(existingEmails); + + List existingVerifiedEmails = new ArrayList<>(Arrays.asList(EXISTING_EMAIL_1, EXISTING_EMAIL_2)); + mockExistingVerifiedEmailAddressesList(existingVerifiedEmails); + + userEmailVerificationHandler.handleEvent(event); + Map userClaims = getUserClaimsFromEvent(event); + Assert.assertEquals(userClaims.get(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM), EXISTING_EMAIL_1); + Assert.assertEquals(userClaims.get(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM), EXISTING_EMAIL_1); + } + + @Test + public void testHandleEventThreadLocalValues() throws IdentityEventException, UserStoreException { + + mockUtilMethods(true, false, false, + false); + mockPendingVerificationEmail(EXISTING_EMAIL_1); + + // Case 1: Thread local value = SKIP_ON_CONFIRM. + Event event1 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + + mockedUtils.when(Utils::getThreadLocalToSkipSendingEmailVerificationOnUpdate).thenReturn( + IdentityRecoveryConstants.SkipEmailVerificationOnUpdateStates.SKIP_ON_CONFIRM + .toString()); + + userEmailVerificationHandler.handleEvent(event1); + Map userClaims1 = getUserClaimsFromEvent(event1); + Assert.assertFalse(userClaims1.containsKey(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM)); + + // Case 2: Thread local value = SKIP_ON_CONFIRM. + Event event2 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + + mockedUtils.when(Utils::getThreadLocalToSkipSendingEmailVerificationOnUpdate).thenReturn( + IdentityRecoveryConstants.SkipEmailVerificationOnUpdateStates.SKIP_ON_EMAIL_OTP_FLOW + .toString()); + + userEmailVerificationHandler.handleEvent(event2); + Map userClaims2 = getUserClaimsFromEvent(event2); + Assert.assertFalse(userClaims2.containsKey(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM)); + + // Case 2: Thread local value = random value. + Event event3 = createEvent(IdentityEventConstants.Event.PRE_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL); + + mockedUtils.when(Utils::getThreadLocalToSkipSendingEmailVerificationOnUpdate).thenReturn("test"); + + userEmailVerificationHandler.handleEvent(event3); + Map userClaims3 = getUserClaimsFromEvent(event3); + Assert.assertFalse(userClaims3.containsKey(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM)); + + } + + @Test + public void testHandleEventPostSetUserClaims() + throws IdentityEventException, IdentityRecoveryException, UserStoreException { + + Event event = createEvent(IdentityEventConstants.Event.POST_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, null); + mockUtilMethods(true, true, false, + true); + mockPendingVerificationEmail(EXISTING_EMAIL_1); + + userEmailVerificationHandler.handleEvent(event); + verify(identityEventService).handleEvent(any()); + + // Case 2: Error is thrown when retrieving verification pending email. + Event event1 = createEvent(IdentityEventConstants.Event.POST_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, null); + mockUtilMethods(true, true, false, + true); + when(userStoreManager.getUserClaimValues(TEST_USERNAME, new String[]{ + IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM}, null)) + .thenThrow(new UserStoreException()); + try { + userEmailVerificationHandler.handleEvent(event1); + } catch (Exception e) { + Assert.assertTrue(e instanceof IdentityEventException); + } + } + + @Test + public void testHandleEventPostSetUserClaimsRecoveryScenarios() + throws IdentityEventException, IdentityRecoveryException, UserStoreException { + + Event event = createEvent(IdentityEventConstants.Event.POST_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, null); + mockUtilMethods(true, true, false, + true); + mockPendingVerificationEmail(EXISTING_EMAIL_1); + + // Case 1: Change primary email address. + mockedUtils.when(Utils::getThreadLocalIsOnlyVerifiedEmailAddressesUpdated).thenReturn(false); + userEmailVerificationHandler.handleEvent(event); + + ArgumentCaptor recoveryDataCaptor = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor.capture()); + UserRecoveryData capturedRecoveryData = recoveryDataCaptor.getValue(); + Assert.assertEquals(RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, + capturedRecoveryData.getRecoveryScenario()); + Assert.assertEquals(RecoverySteps.VERIFY_EMAIL, capturedRecoveryData.getRecoveryStep()); + + reset(userRecoveryDataStore); + + // Case 2: Change verified list. + Event event2 = createEvent(IdentityEventConstants.Event.POST_SET_USER_CLAIMS, IdentityRecoveryConstants.FALSE, + null, null, null); + mockedUtils.when(Utils::getThreadLocalIsOnlyVerifiedEmailAddressesUpdated).thenReturn(true); + userEmailVerificationHandler.handleEvent(event2); + + ArgumentCaptor recoveryDataCaptor2 = ArgumentCaptor.forClass(UserRecoveryData.class); + verify(userRecoveryDataStore).store(recoveryDataCaptor2.capture()); + UserRecoveryData capturedRecoveryData2 = recoveryDataCaptor2.getValue(); + Assert.assertEquals(RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, + capturedRecoveryData2.getRecoveryScenario()); + Assert.assertEquals(RecoverySteps.VERIFY_EMAIL, capturedRecoveryData2.getRecoveryStep()); + } + + @Test + public void testHandleEventPreAddUserVerifyEmailClaim() throws IdentityEventException { + + /* + Case 1: Enable verifyEmail claim and not provide the email address claim value. + */ + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.ENABLE_EMAIL_VERIFICATION, true); + Event event1 = createEvent(IdentityEventConstants.Event.PRE_ADD_USER, IdentityRecoveryConstants.TRUE, + null, null, null); + + try { + userEmailVerificationHandler.handleEvent(event1); + } catch (Exception e) { + Assert.assertTrue(e instanceof IdentityEventClientException); + Assert.assertEquals(((IdentityEventClientException) e).getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_VERIFICATION_EMAIL_NOT_FOUND.getCode()); + } + + /* + Case 2: Provide the email address claim value. + */ + Event event2 = createEvent(IdentityEventConstants.Event.PRE_ADD_USER, IdentityRecoveryConstants.TRUE, + null, null, NEW_EMAIL); + + userEmailVerificationHandler.handleEvent(event2); + mockedUtils.verify(() -> Utils.publishRecoveryEvent(any(), + eq(IdentityEventConstants.Event.PRE_VERIFY_EMAIL_CLAIM), + any())); + } + + @Test + public void testHandleEventPreAddUserAskPasswordClaim() throws IdentityEventException { + + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.ENABLE_EMAIL_VERIFICATION, true); + mockedIdentityUtils.when(() -> IdentityUtil.getProperty(eq(IdentityRecoveryConstants + .ConnectorConfig.ASK_PASSWORD_DISABLE_RANDOM_VALUE_FOR_CREDENTIALS))) + .thenReturn(Boolean.TRUE.toString()); + char[] password = "test1".toCharArray(); + mockedUtils.when(() -> Utils.generateRandomPassword(anyInt())) + .thenReturn(password); + + StringBuffer credentials = new StringBuffer("test1"); + Map additionalProperties = new HashMap<>(); + additionalProperties.put(IdentityEventConstants.EventProperty.CREDENTIAL, credentials); + + Map additionalClaims = new HashMap<>(); + additionalClaims.put(IdentityRecoveryConstants.ASK_PASSWORD_CLAIM, Boolean.TRUE.toString()); + + Event event = createEvent(IdentityEventConstants.Event.PRE_ADD_USER, IdentityRecoveryConstants.FALSE, + null, null, NEW_EMAIL, additionalProperties, additionalClaims); + + + userEmailVerificationHandler.handleEvent(event); + mockedUtils.verify(() -> Utils.publishRecoveryEvent(any(), + eq(IdentityEventConstants.Event.PRE_ADD_USER_WITH_ASK_PASSWORD), + any())); + } + + @Test + public void testHandleEventPostAddUserVerifyEmailClaim() throws IdentityEventException { + + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.ENABLE_EMAIL_VERIFICATION, true); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.EMAIL_ACCOUNT_LOCK_ON_CREATION, + true); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig + .EMAIL_VERIFICATION_NOTIFICATION_INTERNALLY_MANAGE,true); + + mockedUtils.when(() -> Utils.isAccountStateClaimExisting(anyString())).thenReturn(true); + + Claim claim = new Claim(); + claim.setClaimUri(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM); + claim.setValue(Boolean.TRUE.toString()); + mockedUtils.when(Utils::getEmailVerifyTemporaryClaim).thenReturn(claim); + + Event event = createEvent(IdentityEventConstants.Event.POST_ADD_USER, IdentityRecoveryConstants.TRUE, + null, null, null); + + userEmailVerificationHandler.handleEvent(event); + verify(identityEventService).handleEvent(any()); + mockedUtils.verify(() -> Utils.publishRecoveryEvent(any(), + eq(IdentityEventConstants.Event.POST_VERIFY_EMAIL_CLAIM), + any())); + } + + @Test + public void testHandleEventPostAddUserAskPasswordClaimNotificationInternallyManaged() + throws IdentityEventException, IdentityRecoveryException { + + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.ENABLE_EMAIL_VERIFICATION, true); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.EMAIL_ACCOUNT_LOCK_ON_CREATION, + true); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig + .EMAIL_VERIFICATION_NOTIFICATION_INTERNALLY_MANAGE,true); + + mockedUtils.when(() -> Utils.isAccountStateClaimExisting(anyString())).thenReturn(true); + + Claim temporaryEmailClaim = new Claim(); + temporaryEmailClaim.setClaimUri(IdentityRecoveryConstants.ASK_PASSWORD_CLAIM); + temporaryEmailClaim.setValue(Boolean.TRUE.toString()); + mockedUtils.when(Utils::getEmailVerifyTemporaryClaim).thenReturn(temporaryEmailClaim); + + Event event = createEvent(IdentityEventConstants.Event.POST_ADD_USER, IdentityRecoveryConstants.FALSE, + null, null, null); + + userEmailVerificationHandler.handleEvent(event); + verify(userRecoveryDataStore).store(any()); + mockedUtils.verify(() -> Utils.publishRecoveryEvent(any(), + eq(IdentityEventConstants.Event.POST_ADD_USER_WITH_ASK_PASSWORD), + any())); + + // Case 2: Utils.generateSecretKey() throws an exception. + mockedUtils.when(() -> Utils.generateSecretKey( + NotificationChannels.EMAIL_CHANNEL.getChannelType(), RecoveryScenarios.ASK_PASSWORD.name(), + TEST_TENANT_DOMAIN, "EmailVerification")) + .thenThrow(new IdentityRecoveryServerException("test_error")); + try { + userEmailVerificationHandler.handleEvent(event); + } catch (Exception e) { + Assert.assertTrue(e instanceof IdentityEventException); + } + + // Reset. + mockedUtils.when(() -> Utils.generateSecretKey( + NotificationChannels.EMAIL_CHANNEL.getChannelType(), RecoveryScenarios.ASK_PASSWORD.name(), + TEST_TENANT_DOMAIN, "EmailVerification")) + .thenReturn("test_key"); + + // Case 3: Claims are null. + Map eventProperties = new HashMap<>(); + eventProperties.put(IdentityEventConstants.EventProperty.USER_NAME, TEST_USERNAME); + eventProperties.put(IdentityEventConstants.EventProperty.TENANT_DOMAIN, TEST_TENANT_DOMAIN); + eventProperties.put(IdentityEventConstants.EventProperty.USER_STORE_MANAGER, userStoreManager); + + Event event3 = new Event(IdentityEventConstants.Event.POST_ADD_USER, eventProperties); + userEmailVerificationHandler.handleEvent(event3); + verify(userRecoveryDataStore, atLeastOnce()).store(any()); + } + + @Test + public void testHandleEventPostAddUserAskPasswordClaimNotificationExternallyManaged() + throws IdentityEventException, IdentityRecoveryException { + + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.ENABLE_EMAIL_VERIFICATION, true); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.EMAIL_ACCOUNT_LOCK_ON_CREATION, + true); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig + .EMAIL_VERIFICATION_NOTIFICATION_INTERNALLY_MANAGE,false); + + Claim temporaryEmailClaim = new Claim(); + temporaryEmailClaim.setClaimUri(IdentityRecoveryConstants.ASK_PASSWORD_CLAIM); + temporaryEmailClaim.setValue(Boolean.TRUE.toString()); + mockedUtils.when(Utils::getEmailVerifyTemporaryClaim).thenReturn(temporaryEmailClaim); + + Event event = createEvent(IdentityEventConstants.Event.POST_ADD_USER, IdentityRecoveryConstants.FALSE, + null, null, null); + + userEmailVerificationHandler.handleEvent(event); + + verify(userRecoveryDataStore).store(any()); + mockedUtils.verify(() -> Utils.publishRecoveryEvent(any(), + eq(IdentityEventConstants.Event.POST_ADD_USER_WITH_ASK_PASSWORD), + any())); + } + + @Test + public void testGetPriority() { + + Assert.assertEquals(userEmailVerificationHandler.getPriority(messageContext), 65); + } + + @Test + public void testGetRecoveryData() throws IdentityEventException, IdentityRecoveryException { + + User user = new User(); + UserRecoveryData userRecoveryData = mock(UserRecoveryData.class); + when(userRecoveryDataStore.loadWithoutCodeExpiryValidation(user)).thenReturn(userRecoveryData); + + UserRecoveryData response = userEmailVerificationHandler.getRecoveryData(user); + Assert.assertEquals(response, userRecoveryData); + } + + private void mockExistingEmailAddressesList(List existingEmails) { + + mockedUtils.when(() -> Utils.getMultiValuedClaim(any(), any(), + eq(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM))) + .thenReturn(existingEmails); + } + + private void mockExistingVerifiedEmailAddressesList(List existingVerifiedEmails) { + + mockedUtils.when(() -> Utils.getMultiValuedClaim(any(), any(), + eq(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM))) + .thenReturn(existingVerifiedEmails); + } + + private void mockPrimaryEmail(String primaryEmail) throws UserStoreException { + + when(userStoreManager.getUserClaimValue(anyString(), eq(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM), + any())).thenReturn(primaryEmail); + } + + private void mockPendingVerificationEmail(String pendingEmail) throws UserStoreException { + + Map pendingEmailClaim = new HashMap<>(); + pendingEmailClaim.put(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM, pendingEmail); + when(userStoreManager.getUserClaimValues(anyString(), + eq(new String[]{IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM}), + any())).thenReturn(pendingEmailClaim); + } + + private void mockUtilMethods(boolean emailVerificationEnabled, boolean multiAttributeEnabled, + boolean userVerifyClaimEnabled, boolean notificationOnEmailUpdate) { + + mockedUtils.when( + Utils::isMultiEmailsAndMobileNumbersPerUserEnabled).thenReturn(multiAttributeEnabled); + mockedUtils.when(Utils::isUseVerifyClaimEnabled).thenReturn(userVerifyClaimEnabled); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.ENABLE_EMAIL_VERIFICATION_ON_UPDATE, + emailVerificationEnabled); + mockGetConnectorConfig(IdentityRecoveryConstants.ConnectorConfig.ENABLE_NOTIFICATION_ON_EMAIL_UPDATE, + notificationOnEmailUpdate); + } + + private void mockGetConnectorConfig(String connectorConfig, boolean value) { + + mockedUtils.when(() -> Utils.getConnectorConfig(eq(connectorConfig), anyString())) + .thenReturn(String.valueOf(value)); + } + + @SuppressWarnings("unchecked") + private static Map getUserClaimsFromEvent(Event event2) { + + Map eventProperties = event2.getEventProperties(); + return (Map) eventProperties.get(IdentityEventConstants.EventProperty.USER_CLAIMS); + } + + private Event createEvent(String eventType, String verifyEmailClaim, String verifiedEmailsClaim, + String emailsClaim, String primaryEmail) { + + return createEvent(eventType, verifyEmailClaim, verifiedEmailsClaim, emailsClaim, primaryEmail, null, null); + } + + private Event createEvent(String eventType, String verifyEmailClaim, String verifiedEmailsClaim, + String emailsClaim, String primaryEmail, Map additionalEventProperties, + Map additionalClaims) { + + Map eventProperties = new HashMap<>(); + eventProperties.put(IdentityEventConstants.EventProperty.USER_NAME, TEST_USERNAME); + eventProperties.put(IdentityEventConstants.EventProperty.TENANT_DOMAIN, TEST_TENANT_DOMAIN); + eventProperties.put(IdentityEventConstants.EventProperty.USER_STORE_MANAGER, userStoreManager); + + if (additionalEventProperties != null) { + eventProperties.putAll(additionalEventProperties); + } + + Map claims = new HashMap<>(); + if (primaryEmail != null && !primaryEmail.isEmpty()) { + claims.put(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM, primaryEmail); + } + if (verifyEmailClaim != null) { + claims.put(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM, verifyEmailClaim); + } + if (emailsClaim != null) { + claims.put(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM, emailsClaim); + } + if (verifiedEmailsClaim != null) { + claims.put(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM, verifiedEmailsClaim); + } + + if (additionalClaims != null) { + claims.putAll(additionalClaims); + } + + eventProperties.put(IdentityEventConstants.EventProperty.USER_CLAIMS, claims); + return new Event(eventType, eventProperties); + } +} diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/signup/UserSelfRegistrationManagerTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/signup/UserSelfRegistrationManagerTest.java index 08750db8ee..8005c2aaed 100644 --- a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/signup/UserSelfRegistrationManagerTest.java +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/signup/UserSelfRegistrationManagerTest.java @@ -22,23 +22,26 @@ import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.testng.Assert; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; -import org.testng.annotations.BeforeTest; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import org.wso2.carbon.CarbonConstants; import org.wso2.carbon.consent.mgt.core.ConsentManager; import org.wso2.carbon.consent.mgt.core.ConsentManagerImpl; import org.wso2.carbon.consent.mgt.core.exception.ConsentManagementException; import org.wso2.carbon.consent.mgt.core.model.AddReceiptResponse; import org.wso2.carbon.consent.mgt.core.model.ConsentManagerConfigurationHolder; import org.wso2.carbon.consent.mgt.core.model.ReceiptInput; +import org.wso2.carbon.context.PrivilegedCarbonContext; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; import org.wso2.carbon.identity.application.common.model.IdentityProvider; import org.wso2.carbon.identity.application.common.model.User; import org.wso2.carbon.identity.auth.attribute.handler.AuthAttributeHandlerManager; @@ -47,19 +50,30 @@ import org.wso2.carbon.identity.auth.attribute.handler.model.ValidationResult; import org.wso2.carbon.identity.base.IdentityException; import org.wso2.carbon.identity.common.testng.WithCarbonHome; +import org.wso2.carbon.identity.consent.mgt.services.ConsentUtilityService; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; import org.wso2.carbon.identity.core.util.IdentityUtil; import org.wso2.carbon.identity.event.IdentityEventException; import org.wso2.carbon.identity.event.event.Event; import org.wso2.carbon.identity.event.services.IdentityEventService; +import org.wso2.carbon.identity.governance.IdentityGovernanceException; import org.wso2.carbon.identity.governance.IdentityGovernanceService; +import org.wso2.carbon.identity.governance.service.notification.NotificationChannelManager; import org.wso2.carbon.identity.governance.service.notification.NotificationChannels; import org.wso2.carbon.identity.governance.service.otp.OTPGenerator; +import org.wso2.carbon.identity.input.validation.mgt.exceptions.InputValidationMgtException; +import org.wso2.carbon.identity.input.validation.mgt.model.RulesConfiguration; +import org.wso2.carbon.identity.input.validation.mgt.model.ValidationConfiguration; +import org.wso2.carbon.identity.input.validation.mgt.model.Validator; +import org.wso2.carbon.identity.input.validation.mgt.services.InputValidationManagementService; +import org.wso2.carbon.identity.input.validation.mgt.utils.Constants; import org.wso2.carbon.identity.recovery.IdentityRecoveryClientException; import org.wso2.carbon.identity.recovery.IdentityRecoveryConstants; import org.wso2.carbon.identity.recovery.IdentityRecoveryException; +import org.wso2.carbon.identity.recovery.IdentityRecoveryServerException; import org.wso2.carbon.identity.recovery.RecoveryScenarios; import org.wso2.carbon.identity.recovery.RecoverySteps; +import org.wso2.carbon.identity.recovery.UserWorkflowManagementService; import org.wso2.carbon.identity.recovery.bean.NotificationResponseBean; import org.wso2.carbon.identity.recovery.exception.SelfRegistrationException; import org.wso2.carbon.identity.recovery.internal.IdentityRecoveryServiceDataHolder; @@ -68,14 +82,25 @@ import org.wso2.carbon.identity.recovery.store.JDBCRecoveryDataStore; import org.wso2.carbon.identity.recovery.store.UserRecoveryDataStore; import org.wso2.carbon.identity.recovery.util.Utils; +import org.wso2.carbon.idp.mgt.IdentityProviderManagementException; import org.wso2.carbon.idp.mgt.IdentityProviderManager; import org.wso2.carbon.user.api.Claim; -import org.wso2.carbon.user.api.UserRealm; -import org.wso2.carbon.user.api.UserStoreManager; -import org.wso2.carbon.user.core.UserStoreException; +import org.wso2.carbon.user.core.UserCoreConstants; +import org.wso2.carbon.user.core.common.AbstractUserStoreManager; +import org.wso2.carbon.user.core.config.RealmConfiguration; +import org.wso2.carbon.user.core.tenant.TenantManager; +import org.wso2.carbon.user.core.UserRealm; +import org.wso2.carbon.user.api.UserStoreException; +import org.wso2.carbon.user.core.UserStoreManager; import org.wso2.carbon.user.core.service.RealmService; +import org.wso2.carbon.user.core.util.UserCoreUtil; +import org.wso2.carbon.utils.multitenancy.MultitenantUtils; import java.sql.Timestamp; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -84,12 +109,24 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.fail; + import static org.wso2.carbon.identity.auth.attribute.handler.AuthAttributeHandlerConstants.ErrorMessages.ERROR_CODE_AUTH_ATTRIBUTE_HANDLER_NOT_FOUND; +import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ConnectorConfig.ACCOUNT_LOCK_ON_CREATION; import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ConnectorConfig.ENABLE_SELF_SIGNUP; +import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ConnectorConfig.NOTIFICATION_INTERNALLY_MANAGE; import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ConnectorConfig.SELF_REGISTRATION_SEND_OTP_IN_EMAIL; import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ConnectorConfig.SELF_REGISTRATION_USE_LOWERCASE_CHARACTERS_IN_OTP; import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ConnectorConfig.SELF_REGISTRATION_USE_NUMBERS_IN_OTP; @@ -104,56 +141,151 @@ import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_MULTIPLE_REGISTRATION_OPTIONS; import static org.wso2.carbon.identity.recovery.IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_UNEXPECTED_ERROR_VALIDATING_ATTRIBUTES; +/** + * Test class for UserSelfRegistrationManager class. + */ @WithCarbonHome public class UserSelfRegistrationManagerTest { - private UserSelfRegistrationManager userSelfRegistrationManager = UserSelfRegistrationManager.getInstance(); - private ReceiptInput resultReceipt; - private String TEST_TENANT_DOMAIN_NAME = "carbon.super"; - private String TEST_USERSTORE_DOMAIN = "PRIMARY"; + @InjectMocks + private UserSelfRegistrationManager userSelfRegistrationManager; + + @Mock + private IdentityRecoveryServiceDataHolder identityRecoveryServiceDataHolder; + + @Mock + private UserRecoveryDataStore userRecoveryDataStore; + + @Mock + private IdentityEventService identityEventService; + + @Mock + private UserStoreManager userStoreManager; + + @Mock + private ConsentManager consentManger; + + @Mock + private TenantManager tenantManager; + + @Mock + private ConsentUtilityService consentUtilityService; + + @Mock + private UserRealm userRealm; + + @Mock private IdentityProviderManager identityProviderManager; + + @Mock + private IdentityProvider identityProvider; + + @Mock private AuthAttributeHandlerManager authAttributeHandlerManager; + + @Mock private IdentityGovernanceService identityGovernanceService; + + @Mock + private ReceiptInput resultReceipt; + + @Mock private RealmService realmService; + + @Mock private OTPGenerator otpGenerator; - private final String TEST_USER_NAME = "dummyUser"; - private final String TEST_CLAIM_URI = "ttp://wso2.org/claims/emailaddress"; - private final String TEST_CLAIM_VALUE = "dummyuser@wso2.com"; - private final String TEST_MOBILE_CLAIM_VALUE = "0775553443"; - private final UserRealm userRealm = Mockito.mock(UserRealm.class); - private final UserStoreManager userStoreManager = Mockito.mock(UserStoreManager.class); - private static final Log LOG = LogFactory.getLog(UserSelfRegistrationManagerTest.class); @Mock - UserRecoveryDataStore userRecoveryDataStore; + private PrivilegedCarbonContext privilegedCarbonContext; + + @Mock + private NotificationChannelManager notificationChannelManager; @Mock - IdentityEventService identityEventService; + private UserWorkflowManagementService userWorkflowManagementService; + @Mock + private RealmConfiguration realmConfiguration; + + @Mock + private InputValidationManagementService inputValidationManagementService; + + @Mock + private ValidationConfiguration validationConfiguration; + + @Mock + private AbstractUserStoreManager abstractUserStoreManager; + + private MockedStatic mockedServiceDataHolder; private MockedStatic mockedIdentityUtil; private MockedStatic mockedJDBCRecoveryDataStore; private MockedStatic mockedIdentityProviderManager; private MockedStatic mockedIdentityTenantUtil; + private MockedStatic mockedPrivilegedCarbonContext; + private MockedStatic mockedFrameworkUtils; + private MockedStatic mockedMultiTenantUtils; + private MockedStatic mockedUserCoreUtil; + + private final String TEST_TENANT_DOMAIN_NAME = "carbon.super"; + private final int TEST_TENANT_ID = 12; + private final String TEST_USERSTORE_DOMAIN = "PRIMARY"; + private final String TEST_USER_NAME = "dummyUser"; + private final String TEST_CLAIM_URI = "ttp://wso2.org/claims/emailaddress"; + private final String TEST_CLAIM_VALUE = "dummyuser@wso2.com"; + private final String TEST_MOBILE_CLAIM_VALUE = "0775553443"; + private final String TEST_PRIMARY_USER_STORE_DOMAIN = "PRIMARY"; + private final String TEST_RECOVERY_DATA_STORE_SECRET = "secret"; + private final String TEST_CODE = "test-code"; + + private static final Log LOG = LogFactory.getLog(UserSelfRegistrationManagerTest.class); @BeforeMethod - public void setUp() { - - mockedIdentityUtil = Mockito.mockStatic(IdentityUtil.class); - mockedJDBCRecoveryDataStore = Mockito.mockStatic(JDBCRecoveryDataStore.class); - mockedIdentityProviderManager = Mockito.mockStatic(IdentityProviderManager.class); - mockedIdentityTenantUtil = Mockito.mockStatic(IdentityTenantUtil.class); - identityProviderManager = Mockito.mock(IdentityProviderManager.class); - authAttributeHandlerManager = Mockito.mock(AuthAttributeHandlerManager.class); - identityGovernanceService = Mockito.mock(IdentityGovernanceService.class); - otpGenerator = Mockito.mock(OTPGenerator.class); - realmService = Mockito.mock(RealmService.class); + public void setUp() throws UserStoreException, IdentityProviderManagementException, InputValidationMgtException { - IdentityRecoveryServiceDataHolder.getInstance().setIdentityEventService(identityEventService); - IdentityRecoveryServiceDataHolder.getInstance().setIdentityGovernanceService(identityGovernanceService); - IdentityRecoveryServiceDataHolder.getInstance().setOtpGenerator(otpGenerator); - IdentityRecoveryServiceDataHolder.getInstance().setAuthAttributeHandlerManager(authAttributeHandlerManager); - IdentityRecoveryServiceDataHolder.getInstance().setRealmService(realmService); + MockitoAnnotations.openMocks(this); + + userSelfRegistrationManager = UserSelfRegistrationManager.getInstance(); + mockedServiceDataHolder = mockStatic(IdentityRecoveryServiceDataHolder.class); + mockedIdentityUtil = mockStatic(IdentityUtil.class); + mockedJDBCRecoveryDataStore = mockStatic(JDBCRecoveryDataStore.class); + mockedIdentityProviderManager = mockStatic(IdentityProviderManager.class); + mockedIdentityTenantUtil = mockStatic(IdentityTenantUtil.class); + mockedPrivilegedCarbonContext = mockStatic(PrivilegedCarbonContext.class); + mockedFrameworkUtils = mockStatic(FrameworkUtils.class); + mockedMultiTenantUtils = mockStatic(MultitenantUtils.class); + mockedUserCoreUtil = mockStatic(UserCoreUtil.class); + + mockedIdentityProviderManager.when(IdentityProviderManager::getInstance).thenReturn(identityProviderManager); + mockedServiceDataHolder.when(IdentityRecoveryServiceDataHolder::getInstance) + .thenReturn(identityRecoveryServiceDataHolder); + mockedPrivilegedCarbonContext.when(PrivilegedCarbonContext::getThreadLocalCarbonContext) + .thenReturn(privilegedCarbonContext); + mockedJDBCRecoveryDataStore.when(JDBCRecoveryDataStore::getInstance).thenReturn(userRecoveryDataStore); + mockedIdentityTenantUtil.when(() -> IdentityTenantUtil.getTenantId(any())).thenReturn(TEST_TENANT_ID); + mockedIdentityUtil.when(IdentityUtil::getPrimaryDomainName).thenReturn(TEST_PRIMARY_USER_STORE_DOMAIN); + mockedIdentityUtil.when(() -> IdentityUtil.addDomainToName(eq(TEST_USER_NAME), anyString())) + .thenReturn(String.format("%s/%s", TEST_USER_NAME, TEST_USERSTORE_DOMAIN)); + mockedFrameworkUtils.when(FrameworkUtils::getMultiAttributeSeparator).thenReturn(","); + when(identityProviderManager.getResidentIdP(anyString())).thenReturn(identityProvider); + + when(identityRecoveryServiceDataHolder.getIdentityEventService()).thenReturn(identityEventService); + when(identityRecoveryServiceDataHolder.getIdentityGovernanceService()).thenReturn(identityGovernanceService); + when(identityRecoveryServiceDataHolder.getOtpGenerator()).thenReturn(otpGenerator); + when(identityRecoveryServiceDataHolder.getAuthAttributeHandlerManager()).thenReturn(authAttributeHandlerManager); + when(identityRecoveryServiceDataHolder.getRealmService()).thenReturn(realmService); + when(identityRecoveryServiceDataHolder.getConsentManager()).thenReturn(consentManger); + when(identityRecoveryServiceDataHolder.getConsentUtilityService()).thenReturn(consentUtilityService); + when(identityRecoveryServiceDataHolder.getInputValidationMgtService()) + .thenReturn(inputValidationManagementService); + + when(realmService.getTenantUserRealm(anyInt())).thenReturn(userRealm); + when(realmService.getTenantManager()).thenReturn(tenantManager); + when(userRealm.getUserStoreManager()).thenReturn(userStoreManager); + when(userStoreManager.getSecondaryUserStoreManager(anyString())).thenReturn(userStoreManager); + when(userStoreManager.getRealmConfiguration()).thenReturn(realmConfiguration); + when(privilegedCarbonContext.getOSGiService(UserWorkflowManagementService.class, null)) + .thenReturn(userWorkflowManagementService); } @AfterMethod @@ -163,13 +295,11 @@ public void tearDown() { mockedJDBCRecoveryDataStore.close(); mockedIdentityProviderManager.close(); mockedIdentityTenantUtil.close(); - } - - @BeforeTest - void setup() { - - MockitoAnnotations.openMocks(this); - this.resultReceipt = null; + mockedPrivilegedCarbonContext.close(); + mockedServiceDataHolder.close(); + mockedFrameworkUtils.close(); + mockedMultiTenantUtils.close(); + mockedUserCoreUtil.close(); } String consentData = @@ -204,9 +334,8 @@ public void testResendConfirmationCode(String username, String userstore, String User user = new User(); user.setUserName(username); user.setUserStoreDomain(userstore); - user.setTenantDomain(tenantDomain); - UserRecoveryData userRecoveryData = new UserRecoveryData(user, "1234-4567-890", RecoveryScenarios + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, RecoveryScenarios .SELF_SIGN_UP, RecoverySteps.CONFIRM_SIGN_UP); // Storing preferred notification channel in remaining set ids. userRecoveryData.setRemainingSetIds(preferredChannel); @@ -315,6 +444,11 @@ private void mockConfigurations(String enableSelfSignUp, String enableInternalNo selfRegistrationSMSCodeExpiryConfig.setName(SELF_REGISTRATION_SMSOTP_VERIFICATION_CODE_EXPIRY_TIME); selfRegistrationSMSCodeExpiryConfig.setValue("1"); + org.wso2.carbon.identity.application.common.model.Property accountLockOnCreationConfig = + new org.wso2.carbon.identity.application.common.model.Property(); + accountLockOnCreationConfig.setName(ACCOUNT_LOCK_ON_CREATION); + accountLockOnCreationConfig.setValue("false"); + when(identityGovernanceService .getConfiguration(new String[]{ENABLE_SELF_SIGNUP}, TEST_TENANT_DOMAIN_NAME)) .thenReturn(new org.wso2.carbon.identity.application.common.model.Property[]{signupConfig}); @@ -351,6 +485,11 @@ private void mockConfigurations(String enableSelfSignUp, String enableInternalNo new String[]{SELF_REGISTRATION_SMSOTP_VERIFICATION_CODE_EXPIRY_TIME}, TEST_TENANT_DOMAIN_NAME)) .thenReturn(new org.wso2.carbon.identity.application.common.model.Property[] {selfRegistrationSMSCodeExpiryConfig}); + when(identityGovernanceService + .getConfiguration( + new String[]{ACCOUNT_LOCK_ON_CREATION}, TEST_TENANT_DOMAIN_NAME)) + .thenReturn(new org.wso2.carbon.identity.application.common.model.Property[] + {accountLockOnCreationConfig}); when(otpGenerator.generateOTP(anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyString())) .thenReturn("1234-4567-890"); mockedIdentityUtil.when(IdentityUtil::getPrimaryDomainName).thenReturn(TEST_USERSTORE_DOMAIN); @@ -386,18 +525,17 @@ private void mockEmailTrigger() throws IdentityEventException { public void testAddConsent() throws Exception { IdentityProvider identityProvider = new IdentityProvider(); - mockedIdentityProviderManager.when(IdentityProviderManager::getInstance).thenReturn(identityProviderManager); when(identityProviderManager.getResidentIdP(ArgumentMatchers.anyString())).thenReturn(identityProvider); ConsentManager consentManager = new MyConsentManager(new ConsentManagerConfigurationHolder()); - IdentityRecoveryServiceDataHolder.getInstance().setConsentManager(consentManager); + when(identityRecoveryServiceDataHolder.getConsentManager()).thenReturn(consentManager); userSelfRegistrationManager.addUserConsent(consentData, "wso2.com"); Assert.assertEquals(IdentityRecoveryConstants.Consent.COLLECTION_METHOD_SELF_REGISTRATION, resultReceipt.getCollectionMethod()); Assert.assertEquals("someJurisdiction", resultReceipt.getJurisdiction()); Assert.assertEquals("en", resultReceipt.getLanguage()); - Assert.assertNotNull(resultReceipt.getServices()); + assertNotNull(resultReceipt.getServices()); Assert.assertEquals(1, resultReceipt.getServices().size()); - Assert.assertNotNull(resultReceipt.getServices().get(0).getPurposes()); + assertNotNull(resultReceipt.getServices().get(0).getPurposes()); Assert.assertEquals(1, resultReceipt.getServices().get(0).getPurposes().size()); Assert.assertEquals(new Integer(3), resultReceipt.getServices().get(0).getPurposes().get(0).getPurposeId()); Assert.assertEquals(IdentityRecoveryConstants.Consent.EXPLICIT_CONSENT_TYPE, @@ -425,8 +563,8 @@ public void testAttributeVerification() throws Exception { Property property = new Property("registrationOption", "MagicLinkAuthAttributeHandler"); - Boolean response = userSelfRegistrationManager.verifyUserAttributes(user, "password", new Claim[]{claim}, - new Property[]{property}); + Boolean response = userSelfRegistrationManager.verifyUserAttributes(user, "password", + new Claim[]{claim}, new Property[]{property}); Assert.assertTrue(response); } @@ -487,23 +625,953 @@ public void testAttributeVerificationFailures(String scenario, Property[] proper } } + @Test + public void testConfirmVerificationCodeMe() + throws IdentityRecoveryException, UserStoreException { + + // Case 1: Multiple email and mobile per user is enabled. + String verificationPendingMobileNumber = "0700000000"; + User user = getUser(); + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER); + userRecoveryData.setRemainingSetIds(verificationPendingMobileNumber); + + when(userRecoveryDataStore.load(eq(TEST_CODE))).thenReturn(userRecoveryData); + when(privilegedCarbonContext.getUsername()).thenReturn(TEST_USER_NAME); + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + + mockMultiAttributeEnabled(true); + mockGetUserClaimValue(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM, StringUtils.EMPTY); + mockGetUserClaimValue(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM, StringUtils.EMPTY); + + userSelfRegistrationManager.confirmVerificationCodeMe(TEST_CODE, new HashMap<>()); + + ArgumentCaptor> claimsCaptor = ArgumentCaptor.forClass(Map.class); + verify(userStoreManager, atLeastOnce()).setUserClaimValues(anyString(), claimsCaptor.capture(), isNull()); + + Map capturedClaims = claimsCaptor.getValue(); + String updatedVerifiedMobileNumbers = + capturedClaims.get(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM); + String updatedVerificationPendingMobile = + capturedClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM); + String updatedPrimaryMobile = capturedClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); + + assertEquals(updatedVerificationPendingMobile, StringUtils.EMPTY); + assertEquals(updatedPrimaryMobile, verificationPendingMobileNumber); + assertTrue(StringUtils.contains(updatedVerifiedMobileNumbers, verificationPendingMobileNumber)); + + // Case 2: Multiple email and mobile per user is disabled. + mockMultiAttributeEnabled(false); + ArgumentCaptor> claimsCaptor2 = ArgumentCaptor.forClass(Map.class); + + userSelfRegistrationManager.confirmVerificationCodeMe(TEST_CODE, new HashMap<>()); + + verify(userStoreManager, atLeastOnce()).setUserClaimValues(anyString(), claimsCaptor2.capture(), isNull()); + Map capturedClaims2 = claimsCaptor2.getValue(); + String mobileNumberClaims = + capturedClaims2.get(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); + String updatedVerificationPendingMobile2 = + capturedClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM); + + assertEquals(updatedVerificationPendingMobile2, StringUtils.EMPTY); + assertEquals(mobileNumberClaims, verificationPendingMobileNumber); + + // Case 3: Wrong recovery step. + UserRecoveryData userRecoveryData3 = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VALIDATE_ALL_CHALLENGE_QUESTION); + userRecoveryData.setRemainingSetIds(verificationPendingMobileNumber); + when(userRecoveryDataStore.load(eq(TEST_CODE))).thenReturn(userRecoveryData3); + try { + userSelfRegistrationManager.confirmVerificationCodeMe(TEST_CODE, new HashMap<>()); + fail(); + } catch (IdentityRecoveryException e) { + assertEquals(e.getErrorCode(), IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_INVALID_CODE.getCode()); + } + } + + @Test + public void testConfirmVerificationCodeMeVerificationOnVerifiedListUpdate() + throws IdentityRecoveryException, UserStoreException { + + // Case 1: Recovery Scenario - MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE. + String verificationPendingMobileNumber = "0700000000"; + User user = getUser(); + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER); + userRecoveryData.setRemainingSetIds(verificationPendingMobileNumber); + + when(userRecoveryDataStore.load(eq(TEST_CODE))).thenReturn(userRecoveryData); + when(privilegedCarbonContext.getUsername()).thenReturn(TEST_USER_NAME); + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + + mockMultiAttributeEnabled(true); + mockGetUserClaimValue(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM, verificationPendingMobileNumber); + mockGetUserClaimValue(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM, verificationPendingMobileNumber); + + userSelfRegistrationManager.confirmVerificationCodeMe(TEST_CODE, new HashMap<>()); + + ArgumentCaptor> claimsCaptor = ArgumentCaptor.forClass(Map.class); + verify(userStoreManager).setUserClaimValues(anyString(), claimsCaptor.capture(), isNull()); + Map capturedClaims = claimsCaptor.getValue(); + String updatedVerificationPendingMobile = + capturedClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM); + + assertFalse(capturedClaims.containsKey(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM)); + assertEquals(updatedVerificationPendingMobile, StringUtils.EMPTY); + + reset(userStoreManager); + + // Case 2: When pending mobile number claim value is null. + UserRecoveryData userRecoveryData2 = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER); + when(userRecoveryDataStore.load(eq(TEST_CODE))).thenReturn(userRecoveryData2); + + userSelfRegistrationManager.confirmVerificationCodeMe(TEST_CODE, new HashMap<>()); + + ArgumentCaptor> claimsCaptor2 = ArgumentCaptor.forClass(Map.class); + verify(userStoreManager).setUserClaimValues(anyString(), claimsCaptor2.capture(), isNull()); + assertFalse(claimsCaptor2.getValue().containsKey(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM)); + assertFalse(claimsCaptor2.getValue().containsKey(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM)); + } + + @Test(expectedExceptions = IdentityRecoveryServerException.class) + public void testConfirmVerificationCodeMeUserStoreException() + throws IdentityRecoveryException, UserStoreException { + + // Case 3: Throws user store exception while getting user claim values. + String verificationPendingMobileNumber = "0700000000"; + User user = getUser(); + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER); + userRecoveryData.setRemainingSetIds(verificationPendingMobileNumber); + + when(userRecoveryDataStore.load(eq(TEST_CODE))).thenReturn(userRecoveryData); + when(privilegedCarbonContext.getUsername()).thenReturn(TEST_USER_NAME); + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + + mockMultiAttributeEnabled(true); + when(userStoreManager.getUserClaimValue(any(), eq(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM), + any())).thenThrow(new org.wso2.carbon.user.core.UserStoreException()); + + userSelfRegistrationManager.confirmVerificationCodeMe(TEST_CODE, new HashMap<>()); + } + + @Test + public void testGetConfirmedSelfRegisteredUserVerifyEmail() + throws IdentityRecoveryException, UserStoreException, IdentityGovernanceException { + + String verifiedChannelType = NotificationChannels.EMAIL_CHANNEL.getChannelType(); + String verifiedChannelClaim = "http://wso2.org/claims/emailaddress"; + String verificationPendingEmail = "pasindu@gmail.com"; + Map metaProperties = new HashMap<>(); + + User user = getUser(); + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL); + // Setting verification pending email claim value. + userRecoveryData.setRemainingSetIds(verificationPendingEmail); + + when(userRecoveryDataStore.load(eq(TEST_CODE))).thenReturn(userRecoveryData); + when(userRecoveryDataStore.load(eq(TEST_CODE), anyBoolean())).thenReturn(userRecoveryData); + when(privilegedCarbonContext.getUsername()).thenReturn(TEST_USER_NAME); + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + + mockMultiAttributeEnabled(true); + mockGetUserClaimValue(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM, StringUtils.EMPTY); + mockGetUserClaimValue(IdentityRecoveryConstants.EMAIL_ADDRESSES_CLAIM, StringUtils.EMPTY); + + org.wso2.carbon.identity.application.common.model.Property property = + new org.wso2.carbon.identity.application.common.model.Property(); + org.wso2.carbon.identity.application.common.model.Property[] testProperties = + new org.wso2.carbon.identity.application.common.model.Property[]{property}; + + when(identityGovernanceService.getConfiguration(any(), anyString())).thenReturn(testProperties); + + userSelfRegistrationManager.getConfirmedSelfRegisteredUser(TEST_CODE, verifiedChannelType, verifiedChannelClaim, + metaProperties); + + ArgumentCaptor> claimsCaptor = ArgumentCaptor.forClass(Map.class); + verify(userStoreManager, atLeastOnce()).setUserClaimValues(anyString(), claimsCaptor.capture(), isNull()); + + Map capturedClaims = claimsCaptor.getValue(); + String updatedVerifiedEmailAddresses = + capturedClaims.get(IdentityRecoveryConstants.VERIFIED_EMAIL_ADDRESSES_CLAIM); + String verificationPendingEmailAddress = + capturedClaims.get(IdentityRecoveryConstants.EMAIL_ADDRESS_PENDING_VALUE_CLAIM); + + assertTrue(StringUtils.contains(updatedVerifiedEmailAddresses, verificationPendingEmail)); + assertEquals(verificationPendingEmailAddress, StringUtils.EMPTY); + + // Case 2: Multiple email and mobile per user is disabled. + mockMultiAttributeEnabled(false); + + userSelfRegistrationManager.getConfirmedSelfRegisteredUser(TEST_CODE, verifiedChannelType, verifiedChannelClaim, + metaProperties); + + ArgumentCaptor> claimsCaptor2 = ArgumentCaptor.forClass(Map.class); + verify(userStoreManager, atLeastOnce()).setUserClaimValues(anyString(), claimsCaptor2.capture(), isNull()); + + Map capturedClaims2 = claimsCaptor2.getValue(); + String emailAddressClaim = + capturedClaims2.get(IdentityRecoveryConstants.EMAIL_ADDRESS_CLAIM); + assertEquals(emailAddressClaim, verificationPendingEmail); + + // Case 3 : Throws user store exception while getting user store manager. + when(userRealm.getUserStoreManager()).thenThrow(new org.wso2.carbon.user.core.UserStoreException()); + try { + userSelfRegistrationManager.getConfirmedSelfRegisteredUser(TEST_CODE, verifiedChannelType, + verifiedChannelClaim, metaProperties); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + } + + + @Test + public void testGetConfirmedSelfRegisteredUserVerifyMobile() + throws IdentityRecoveryException, UserStoreException, IdentityGovernanceException { + + String verifiedChannelType = NotificationChannels.SMS_CHANNEL.getChannelType(); + String verifiedChannelClaim = "http://wso2.org/claims/mobile"; + String verificationPendingMobileNumber = "077888888"; + Map metaProperties = new HashMap<>(); + + User user = getUser(); + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER); + // Setting verification pending email claim value. + userRecoveryData.setRemainingSetIds(verificationPendingMobileNumber); + + when(userRecoveryDataStore.load(eq(TEST_CODE))).thenReturn(userRecoveryData); + when(userRecoveryDataStore.load(eq(TEST_CODE), anyBoolean())).thenReturn(userRecoveryData); + when(privilegedCarbonContext.getUsername()).thenReturn(TEST_USER_NAME); + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + + mockGetUserClaimValue(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM, StringUtils.EMPTY); + mockGetUserClaimValue(IdentityRecoveryConstants.MOBILE_NUMBERS_CLAIM, StringUtils.EMPTY); + + org.wso2.carbon.identity.application.common.model.Property property = + new org.wso2.carbon.identity.application.common.model.Property(); + org.wso2.carbon.identity.application.common.model.Property[] testProperties = + new org.wso2.carbon.identity.application.common.model.Property[]{property}; + + when(identityGovernanceService.getConfiguration(any(), anyString())).thenReturn(testProperties); + + try (MockedStatic mockedUtils = mockStatic(Utils.class)) { + mockedUtils.when(Utils::isMultiEmailsAndMobileNumbersPerUserEnabled).thenReturn(true); + mockedUtils.when(() -> Utils.getConnectorConfig( + eq(IdentityRecoveryConstants.ConnectorConfig.ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER), + anyString())) + .thenReturn("true"); + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(IdentityRecoveryConstants.ConnectorConfig + .SELF_REGISTRATION_NOTIFY_ACCOUNT_CONFIRMATION), + anyString())).thenReturn("true"); + userSelfRegistrationManager.getConfirmedSelfRegisteredUser(TEST_CODE, verifiedChannelType, + verifiedChannelClaim, metaProperties); + } + + ArgumentCaptor> claimsCaptor = ArgumentCaptor.forClass(Map.class); + verify(userStoreManager, atLeastOnce()).setUserClaimValues(anyString(), claimsCaptor.capture(), isNull()); + + Map capturedClaims = claimsCaptor.getValue(); + String updatedVerifiedMobileNumbers = + capturedClaims.get(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM); + String verificationPendingMobileNumberClaim = + capturedClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_PENDING_VALUE_CLAIM); + String updatedMobileNumberClaimValue = + capturedClaims.get(IdentityRecoveryConstants.MOBILE_NUMBER_CLAIM); + + assertTrue(StringUtils.contains(updatedVerifiedMobileNumbers, verificationPendingMobileNumber)); + assertEquals(verificationPendingMobileNumberClaim, StringUtils.EMPTY); + assertEquals(updatedMobileNumberClaimValue, verificationPendingMobileNumber); + + // Case 2: External Verified Channel type. + verifiedChannelType = NotificationChannels.EXTERNAL_CHANNEL.getChannelType(); + try (MockedStatic mockedUtils = mockStatic(Utils.class)) { + mockedUtils.when(Utils::isMultiEmailsAndMobileNumbersPerUserEnabled).thenReturn(true); + mockedUtils.when(() -> Utils.getConnectorConfig( + eq(IdentityRecoveryConstants.ConnectorConfig.ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER), + anyString())) + .thenReturn("true"); + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(IdentityRecoveryConstants.ConnectorConfig + .SELF_REGISTRATION_NOTIFY_ACCOUNT_CONFIRMATION), + anyString())).thenReturn("true"); + userSelfRegistrationManager.getConfirmedSelfRegisteredUser(TEST_CODE, verifiedChannelType, + verifiedChannelClaim, metaProperties); + } + + ArgumentCaptor> claimsCaptor1 = ArgumentCaptor.forClass(Map.class); + verify(userStoreManager, atLeastOnce()).setUserClaimValues(anyString(), claimsCaptor1.capture(), isNull()); + + Map capturedClaims1 = claimsCaptor1.getValue(); + String emailVerifiedClaim = + capturedClaims1.get(IdentityRecoveryConstants.EMAIL_VERIFIED_CLAIM); + assertEquals(emailVerifiedClaim, Boolean.TRUE.toString()); + + // Case 3: Throws user store exception while getting user claim values. + try (MockedStatic mockedUtils = mockStatic(Utils.class)) { + mockedUtils.when(Utils::isMultiEmailsAndMobileNumbersPerUserEnabled).thenReturn(true); + mockedUtils.when(() -> Utils.getConnectorConfig( + eq(IdentityRecoveryConstants.ConnectorConfig.ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER), + anyString())) + .thenReturn("true"); + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(IdentityRecoveryConstants.ConnectorConfig + .SELF_REGISTRATION_NOTIFY_ACCOUNT_CONFIRMATION), + anyString())).thenReturn("true"); + + when(userStoreManager.getUserClaimValue(anyString(), eq(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM), isNull())) + .thenThrow(new org.wso2.carbon.user.core.UserStoreException("test exception")); + mockedUtils.when(() -> Utils.getMultiValuedClaim(eq(userStoreManager), eq(user), + eq(IdentityRecoveryConstants.VERIFIED_MOBILE_NUMBERS_CLAIM))).thenCallRealMethod(); + + userSelfRegistrationManager.getConfirmedSelfRegisteredUser(TEST_CODE, verifiedChannelType, + verifiedChannelClaim, metaProperties); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + + // Case 4: With wrong verified channel claim value. + verifiedChannelType = NotificationChannels.SMS_CHANNEL.getChannelType(); + verifiedChannelClaim = "http://wso2.org/claims/invalid"; + try (MockedStatic mockedUtils = mockStatic(Utils.class)) { + mockedUtils.when(Utils::isMultiEmailsAndMobileNumbersPerUserEnabled).thenReturn(true); + mockedUtils.when(() -> Utils.getConnectorConfig( + eq(IdentityRecoveryConstants.ConnectorConfig.ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER), + anyString())) + .thenReturn("true"); + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(IdentityRecoveryConstants.ConnectorConfig + .SELF_REGISTRATION_NOTIFY_ACCOUNT_CONFIRMATION), + anyString())).thenReturn("true"); + userSelfRegistrationManager.getConfirmedSelfRegisteredUser(TEST_CODE, verifiedChannelType, + verifiedChannelClaim, metaProperties); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryException); + } + } + + @Test + public void testGetConfirmedSelfRegisteredUserConfirmSignUp() + throws IdentityRecoveryException, UserStoreException, IdentityGovernanceException { + + String verifiedChannelType = NotificationChannels.EMAIL_CHANNEL.getChannelType(); + String verifiedChannelClaim = "http://wso2.org/claims/emailaddress"; + String verificationPendingEmail = "pasindu@gmail.com"; + Map metaProperties = new HashMap<>(); + + User user = getUser(); + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.CONFIRM_SIGN_UP); + // Setting verification pending email claim value. + userRecoveryData.setRemainingSetIds(verificationPendingEmail); + + when(userRecoveryDataStore.load(eq(TEST_CODE))).thenReturn(userRecoveryData); + when(userRecoveryDataStore.load(eq(TEST_CODE), anyBoolean())).thenReturn(userRecoveryData); + when(privilegedCarbonContext.getUsername()).thenReturn(TEST_USER_NAME); + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + + mockMultiAttributeEnabled(false); + + org.wso2.carbon.identity.application.common.model.Property property = + new org.wso2.carbon.identity.application.common.model.Property(); + org.wso2.carbon.identity.application.common.model.Property[] testProperties = + new org.wso2.carbon.identity.application.common.model.Property[]{property}; + + when(identityGovernanceService.getConfiguration(any(), anyString())).thenReturn(testProperties); + + userSelfRegistrationManager.getConfirmedSelfRegisteredUser(TEST_CODE, verifiedChannelType, verifiedChannelClaim, + metaProperties); + + ArgumentCaptor> claimsCaptor = ArgumentCaptor.forClass(Map.class); + verify(userStoreManager, atLeastOnce()).setUserClaimValues(anyString(), claimsCaptor.capture(), isNull()); + Map capturedClaims = claimsCaptor.getValue(); + String updatedVerifiedMobileNumbers = + capturedClaims.get(IdentityRecoveryConstants.ACCOUNT_LOCKED_CLAIM); + assertEquals(updatedVerifiedMobileNumbers, Boolean.FALSE.toString()); + } + + @Test + public void testRegisterUser() throws Exception { + + User user = getUser(); + mockConfigurations("true", "true"); + when(userStoreManager.isExistingRole(eq(IdentityRecoveryConstants.SELF_SIGNUP_ROLE))).thenReturn(true); + when(privilegedCarbonContext.getOSGiService(any(), isNull())).thenReturn(notificationChannelManager); + when(notificationChannelManager.resolveCommunicationChannel(anyString(), anyString(), anyString(), any())) + .thenReturn(NotificationChannels.EMAIL_CHANNEL.getChannelType()); + + Property property = new Property(IdentityRecoveryConstants.Consent.CONSENT, consentData); + NotificationResponseBean notificationResponseBean = + userSelfRegistrationManager.registerUser(user, "test-pwd", new Claim[0], + new Property[]{property}); + + User registeredUser = notificationResponseBean.getUser(); + assertEquals(user.getUserName(), registeredUser.getUserName()); + verify(userStoreManager).addUser(anyString(), anyString(), any(), any(), isNull()); + verify(consentManger).addConsent(any()); + verify(identityEventService, atLeastOnce()).handleEvent(any()); + } + + @Test + public void testRegisterUserNotificationExternallyAccountLockedOnCreation() throws Exception { + + User user = new User(); + user.setUserName(TEST_USER_NAME); + Property property = new Property(IdentityRecoveryConstants.Consent.CONSENT, consentData); + + try (MockedStatic mockedUtils = mockStatic(Utils.class)) { + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(ACCOUNT_LOCK_ON_CREATION), anyString())) + .thenReturn("true"); + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(NOTIFICATION_INTERNALLY_MANAGE), anyString())) + .thenReturn("false"); + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(ENABLE_SELF_SIGNUP), anyString())) + .thenReturn("true"); + mockedUtils.when(Utils::getNotificationChannelManager).thenReturn(notificationChannelManager); + + when(notificationChannelManager.resolveCommunicationChannel(anyString(), anyString(), anyString(), any())) + .thenReturn(NotificationChannels.EMAIL_CHANNEL.getChannelType()); + + NotificationResponseBean notificationResponseBean = + userSelfRegistrationManager.registerUser(user, "test-pwd", new Claim[0], + new Property[]{property}); + + assertEquals(IdentityRecoveryConstants.SuccessEvents + .SUCCESS_STATUS_CODE_SUCCESSFUL_USER_CREATION_EXTERNAL_VERIFICATION.getCode(), + notificationResponseBean.getCode()); + verify(userStoreManager).addUser(anyString(), anyString(), any(), any(), isNull()); + verify(consentManger).addConsent(any()); + verify(identityEventService, atLeastOnce()).handleEvent(any()); + } + } + + @Test + public void testRegisterUserVerifiedPreferredChannel() throws Exception { + + String emailVerifiedClaimURI = "http://wso2.org/claims/identity/emailVerified"; + User user = getUser(); + Property property = new Property(IdentityRecoveryConstants.Consent.CONSENT, consentData); + mockedIdentityUtil.when(() -> IdentityUtil.getProperty( + eq(IdentityRecoveryConstants.ConnectorConfig + .ENABLE_ACCOUNT_LOCK_FOR_VERIFIED_PREFERRED_CHANNEL))) + .thenReturn("false"); + when(consentUtilityService.filterPIIsFromReceipt(any(), any())).thenReturn( + new HashSet<>(Arrays.asList(emailVerifiedClaimURI))); + + try (MockedStatic mockedUtils = mockStatic(Utils.class)) { + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(ACCOUNT_LOCK_ON_CREATION), anyString())) + .thenReturn("true"); + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(NOTIFICATION_INTERNALLY_MANAGE), anyString())) + .thenReturn("false"); + mockedUtils.when(() -> Utils.getSignUpConfigs(eq(ENABLE_SELF_SIGNUP), anyString())) + .thenReturn("true"); + mockedUtils.when(Utils::getNotificationChannelManager).thenReturn(notificationChannelManager); + + when(notificationChannelManager.resolveCommunicationChannel(anyString(), anyString(), anyString(), any())) + .thenReturn(NotificationChannels.EMAIL_CHANNEL.getChannelType()); + + Claim claim = new Claim(); + claim.setClaimUri(emailVerifiedClaimURI); + claim.setValue("true"); + Claim[] claims = new Claim[]{claim}; + + NotificationResponseBean notificationResponseBean = + userSelfRegistrationManager.registerUser(user, "test-pwd", claims, + new Property[]{property}); + + assertEquals(IdentityRecoveryConstants.SuccessEvents + .SUCCESS_STATUS_CODE_SUCCESSFUL_USER_CREATION_WITH_VERIFIED_CHANNEL.getCode(), + notificationResponseBean.getCode()); + verify(userStoreManager).addUser(anyString(), anyString(), any(), any(), isNull()); + verify(consentManger).addConsent(any()); + verify(identityEventService, atLeastOnce()).handleEvent(any()); + } + } + + @Test + public void testIsUserConfirmed() throws IdentityRecoveryException { + + User user = new User(); + user.setUserName(TEST_USER_NAME); + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.CONFIRM_SIGN_UP); + when(userRecoveryDataStore.loadWithoutCodeExpiryValidation(any())).thenReturn(userRecoveryData); + + // SELF_SIGN_UP scenario. + boolean isUserConfirmed = userSelfRegistrationManager.isUserConfirmed(user); + assertFalse(isUserConfirmed); + } + + @Test + public void testConfirmUserSelfRegistration() throws IdentityRecoveryException, UserStoreException { + + User user = getUser(); + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.CONFIRM_SIGN_UP); + when(userRecoveryDataStore.load(anyString())).thenReturn(userRecoveryData); + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + + userSelfRegistrationManager.confirmUserSelfRegistration(TEST_CODE); + + ArgumentCaptor> claimsCaptor = ArgumentCaptor.forClass(Map.class); + verify(userStoreManager, atLeastOnce()).setUserClaimValues(anyString(), claimsCaptor.capture(), isNull()); + + Map capturedClaims = claimsCaptor.getValue(); + String updatedAccountLockedClaim = + capturedClaims.get(IdentityRecoveryConstants.ACCOUNT_LOCKED_CLAIM); + String updatedEmailVerifiedClaim = + capturedClaims.get(IdentityRecoveryConstants.EMAIL_VERIFIED_CLAIM); + assertEquals(updatedAccountLockedClaim, Boolean.FALSE.toString()); + assertEquals(updatedEmailVerifiedClaim, Boolean.TRUE.toString()); + + // Case 2: Invalid Tenant. + User user1 = getUser(); + user1.setTenantDomain("invalid"); + userRecoveryData = new UserRecoveryData(user1, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.CONFIRM_SIGN_UP); + when(userRecoveryDataStore.load(anyString())).thenReturn(userRecoveryData); + try { + userSelfRegistrationManager.confirmUserSelfRegistration(TEST_CODE); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + + // Case 3: Invalid recovery step. + userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.UPDATE_PASSWORD); + when(userRecoveryDataStore.load(anyString())).thenReturn(userRecoveryData); + try { + userSelfRegistrationManager.confirmUserSelfRegistration(TEST_CODE); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + } + + @Test + public void testIntrospectUserSelfRegistration() throws IdentityRecoveryException { + + User user = getUser(); + UserRecoveryData userRecoveryData = new UserRecoveryData(user, TEST_RECOVERY_DATA_STORE_SECRET, + RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.CONFIRM_SIGN_UP); + when(userRecoveryDataStore.load(eq(TEST_CODE))).thenReturn(userRecoveryData); + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + + String verifiedChannelType = NotificationChannels.EMAIL_CHANNEL.getChannelType(); + String verifiedChannelClaim = "http://wso2.org/claims/emailaddress"; + + UserRecoveryData resultUserRecoveryData = userSelfRegistrationManager.introspectUserSelfRegistration(TEST_CODE, + verifiedChannelType, verifiedChannelClaim, new HashMap<>()); + User resultUser = resultUserRecoveryData.getUser(); + assertEquals(resultUser.getUserName(), user.getUserName()); + + // Case 2: Provide invalid verifiedChannelType. + String verifiedChannelType2 = "TEST"; + String verifiedChannelClaim2 = "http://wso2.org/claims/emailaddress"; + + try { + userSelfRegistrationManager.introspectUserSelfRegistration(TEST_CODE, + verifiedChannelType2, verifiedChannelClaim2, new HashMap<>()); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryException); + assertEquals(((IdentityRecoveryException) e).getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_UNSUPPORTED_VERIFICATION_CHANNEL.getCode()); + } + } + + @Test + public void testIsValidTenantDomain() throws UserStoreException, IdentityRecoveryException { + + // Case 1: Valid tenant domain. + when(tenantManager.getTenantId(eq(TEST_TENANT_DOMAIN_NAME))).thenReturn(TEST_TENANT_ID); + + boolean isValid = userSelfRegistrationManager.isValidTenantDomain(TEST_TENANT_DOMAIN_NAME); + assertTrue(isValid); + + // Case 2: Invalid tenant domain + String invalidTenantDomain = "INVALID_TENANT"; + when(realmService.getTenantManager().getTenantId(eq(invalidTenantDomain))) + .thenThrow(new UserStoreException()); + try { + userSelfRegistrationManager.isValidTenantDomain("INVALID_TENANT"); + } catch (IdentityRecoveryException e) { + assertTrue(StringUtils.contains(e.getMessage(), invalidTenantDomain)); + } + } + + @Test + public void testIsValidUserStoreDomain() throws IdentityRecoveryException, UserStoreException { + + // Case 1: Valid user store domain. + when(tenantManager.getTenantId(eq(TEST_TENANT_DOMAIN_NAME))).thenReturn(TEST_TENANT_ID); + when(userStoreManager.getSecondaryUserStoreManager(eq(TEST_USERSTORE_DOMAIN))) + .thenReturn(userStoreManager); + + boolean isValid = userSelfRegistrationManager.isValidUserStoreDomain( + TEST_USERSTORE_DOMAIN, TEST_TENANT_DOMAIN_NAME); + assertTrue(isValid); + + // Case 2: Throws UserStoreException. + when(userRealm.getUserStoreManager()).thenThrow(new org.wso2.carbon.user.core.UserStoreException()); + try { + userSelfRegistrationManager.isValidUserStoreDomain(TEST_USERSTORE_DOMAIN, TEST_TENANT_DOMAIN_NAME); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryException); + } + } + + @Test + public void testIsUsernameAlreadyTaken() throws UserStoreException, IdentityRecoveryException { + + mockedMultiTenantUtils.when(() -> + MultitenantUtils.getTenantDomain(anyString())).thenReturn(TEST_TENANT_DOMAIN_NAME); + mockedMultiTenantUtils.when(() -> MultitenantUtils.getTenantAwareUsername(anyString())) + .thenReturn(TEST_USER_NAME); + + when(userStoreManager.isExistingUser(anyString())).thenReturn(true); + + boolean isUsernameAlreadyTaken = userSelfRegistrationManager.isUsernameAlreadyTaken(TEST_USER_NAME); + assertTrue(isUsernameAlreadyTaken); + + // Case 2: Has pending workflow. + when(userStoreManager.isExistingUser(anyString())).thenReturn(false); + when(userWorkflowManagementService.isUserExists(anyString(), anyString())).thenReturn(true); + + boolean isUsernameAlreadyTaken2 = userSelfRegistrationManager.isUsernameAlreadyTaken(TEST_USER_NAME); + assertTrue(isUsernameAlreadyTaken2); + + // Case 3: Throw UserStoreException. + when(userRealm.getUserStoreManager()).thenThrow(new org.wso2.carbon.user.core.UserStoreException()); + try { + userSelfRegistrationManager.isUsernameAlreadyTaken(TEST_USER_NAME); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryException); + } + } + + @Test + public void testIsSelfRegistrationEnabled() throws Exception{ + + mockConfigurations("true", "true"); + boolean isSelfRegistrationEnabled = userSelfRegistrationManager + .isSelfRegistrationEnabled(TEST_TENANT_DOMAIN_NAME); + assertTrue(isSelfRegistrationEnabled); + + // Case 2: Throw Governance Exception. + when(identityGovernanceService.getConfiguration(any(), anyString())) + .thenThrow(new IdentityGovernanceException("test error")); + try { + userSelfRegistrationManager.isSelfRegistrationEnabled(TEST_TENANT_DOMAIN_NAME); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryException); + } + } + + @Test + public void testIsMatchUserNameRegex() + throws IdentityRecoveryException, InputValidationMgtException, + UserStoreException { + + mockedMultiTenantUtils.when(() -> MultitenantUtils + .getTenantAwareUsername(anyString())).thenReturn(TEST_USER_NAME); + mockedUserCoreUtil.when(() -> UserCoreUtil.removeDomainFromName(anyString())).thenReturn(TEST_USER_NAME); + mockedIdentityUtil.when(() -> IdentityUtil.extractDomainFromName(anyString())) + .thenReturn(TEST_USERSTORE_DOMAIN); + when(realmConfiguration.getTenantId()).thenReturn(TEST_TENANT_ID); + mockedIdentityUtil.when(() -> IdentityTenantUtil.getTenantDomain(TEST_TENANT_ID)) + .thenReturn(TEST_TENANT_DOMAIN_NAME); + + when(inputValidationManagementService.getInputValidationConfiguration(anyString())) + .thenReturn(Arrays.asList(validationConfiguration)); + when(validationConfiguration.getField()).thenReturn("username"); + mockedIdentityUtil.when(() -> IdentityUtil.getProperty(Constants.INPUT_VALIDATION_USERNAME_ENABLED_CONFIG)) + .thenReturn("false"); + when(realmConfiguration.getUserStoreProperty(UserCoreConstants.RealmConfig.PROPERTY_USER_NAME_JAVA_REG_EX)) + .thenReturn("^[\\S]{5,30}$"); + + boolean isMatchUsernameRegex = + userSelfRegistrationManager.isMatchUserNameRegex(TEST_TENANT_DOMAIN_NAME, TEST_USER_NAME); + assertTrue(isMatchUsernameRegex); + + /* + Case 2: INPUT_VALIDATION_USERNAME_ENABLED_CONFIG enabled. + */ + mockedIdentityUtil.when(() -> IdentityUtil.getProperty(Constants.INPUT_VALIDATION_USERNAME_ENABLED_CONFIG)) + .thenReturn("true"); + + Map validators = new HashMap<>(); + Validator validator = mock(Validator.class); + validators.put("MockValidator", validator); + when(inputValidationManagementService.getValidators(anyString())).thenReturn(validators); + + RulesConfiguration rulesConfiguration = mock(RulesConfiguration.class); + when(rulesConfiguration.getValidatorName()).thenReturn("MockValidator"); + when(rulesConfiguration.getProperties()).thenReturn(new HashMap<>()); + when(validationConfiguration.getRegEx()).thenReturn(Arrays.asList(rulesConfiguration)); + + isMatchUsernameRegex = + userSelfRegistrationManager.isMatchUserNameRegex(TEST_TENANT_DOMAIN_NAME, TEST_USER_NAME); + assertTrue(isMatchUsernameRegex); + + /* + Case 3: Throw UserStoreException while getting user store manager. + */ + when(userRealm.getUserStoreManager()).thenThrow(new org.wso2.carbon.user.core.UserStoreException()); + try { + userSelfRegistrationManager.isMatchUserNameRegex(TEST_TENANT_DOMAIN_NAME, TEST_USER_NAME); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryException); + } + + /* + Case 4: Throw Error while getting tenant id. + */ + when(tenantManager.getTenantId(anyString())).thenThrow(new UserStoreException()); + try { + userSelfRegistrationManager.isMatchUserNameRegex(TEST_TENANT_DOMAIN_NAME, TEST_USER_NAME); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryException); + } + } + + @Test + public void testPreValidatePasswordWithConfirmationKey() throws Exception { + + String confirmationKey = "testConfirmationKey"; + String password = "testPassword"; + User user = getUser(); + + UserRecoveryData recoveryData = new UserRecoveryData(user, confirmationKey, + RecoveryScenarios.NOTIFICATION_BASED_PW_RECOVERY, RecoverySteps.UPDATE_PASSWORD); + when(userRecoveryDataStore.load(confirmationKey)).thenReturn(recoveryData); + + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + when(userRealm.getUserStoreManager()).thenReturn(abstractUserStoreManager); + when(abstractUserStoreManager.getSecondaryUserStoreManager(TEST_USERSTORE_DOMAIN)).thenReturn(userStoreManager); + + userSelfRegistrationManager.preValidatePasswordWithConfirmationKey(confirmationKey, password); + verify(identityEventService).handleEvent(any(Event.class)); + + // Case 2: Throws UserStoreException. + when(userRealm.getUserStoreManager()).thenThrow(new org.wso2.carbon.user.core.UserStoreException()); + try { + userSelfRegistrationManager.preValidatePasswordWithConfirmationKey(confirmationKey, password); + } catch(Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + } + + @Test + public void testPreValidatePasswordWithUsername() throws Exception { + + String password = "testPassword"; + String username = TEST_USERSTORE_DOMAIN + CarbonConstants.DOMAIN_SEPARATOR + TEST_USER_NAME; + mockedUserCoreUtil.when(() -> UserCoreUtil.removeDomainFromName(username)).thenReturn(TEST_USER_NAME); + + when(privilegedCarbonContext.getTenantDomain()).thenReturn(TEST_TENANT_DOMAIN_NAME); + when(userRealm.getUserStoreManager()).thenReturn(abstractUserStoreManager); + when(abstractUserStoreManager.getSecondaryUserStoreManager(TEST_USERSTORE_DOMAIN)).thenReturn(userStoreManager); + + userSelfRegistrationManager.preValidatePasswordWithUsername(username, password); + verify(identityEventService).handleEvent(any(Event.class)); + } + + @Test + public void testRegisterLiteUser() throws Exception { + + String emailVerifiedClaimURI = "http://wso2.org/claims/identity/emailVerified"; + User user = new User(); + user.setUserName(TEST_USER_NAME); + + Claim claim1 = new Claim(); + claim1.setClaimUri("http://wso2.org/claims/emailaddress"); + claim1.setValue("test@example.com"); + + Claim[] claims = new Claim[]{claim1}; + + mockedIdentityUtil.when(() -> IdentityUtil.getProperty( + eq(IdentityRecoveryConstants.ConnectorConfig + .ENABLE_ACCOUNT_LOCK_FOR_VERIFIED_PREFERRED_CHANNEL))) + .thenReturn("false"); + when(consentUtilityService.filterPIIsFromReceipt(any(), any())).thenReturn( + new HashSet<>(Arrays.asList(emailVerifiedClaimURI))); + + Property[] properties = new Property[]{ + new Property(IdentityRecoveryConstants.Consent.CONSENT, consentData) + }; + when(notificationChannelManager.resolveCommunicationChannel(anyString(), anyString(), anyString(), anyMap())) + .thenReturn(NotificationChannels.EMAIL_CHANNEL.getChannelType()); + + try (MockedStatic mockedUtils = mockStatic(Utils.class)) { + mockedUtils.when(Utils::getNotificationChannelManager).thenReturn(notificationChannelManager); + mockedUtils.when(() -> Utils.getCallbackURLFromRegistration(any())).thenReturn("https://test.com"); + mockedUtils.when(() -> Utils.validateCallbackURL(anyString(), anyString(), anyString())) + .thenReturn(true); + mockedUtils.when(() -> Utils.generateRandomPassword(anyInt())).thenReturn("test-pwd-123".toCharArray()); + mockedUtils.when(() -> Utils.handleClientException(any(), anyString())) + .thenThrow(new IdentityRecoveryClientException("error-code", "message")); + mockedUtils.when(() -> Utils.handleClientException(any(IdentityRecoveryConstants.ErrorMessages.class), + anyString(), any())) + .thenReturn(new IdentityRecoveryClientException("error-code", "message")); + mockedUtils.when(() -> Utils.handleServerException(any(), anyString())) + .thenThrow(new IdentityRecoveryServerException("error-code", "message")); + mockedUtils.when(() -> Utils.handleServerException(any(IdentityRecoveryConstants.ErrorMessages.class), + anyString(), any())) + .thenReturn(new IdentityRecoveryServerException("error-code", "message")); + + mockedUtils.when(() -> Utils.getSignUpConfigs( + eq(IdentityRecoveryConstants.ConnectorConfig.ENABLE_LITE_SIGN_UP), + anyString())).thenReturn("true"); + mockedUtils.when(() -> Utils.getSignUpConfigs(eq( + IdentityRecoveryConstants.ConnectorConfig.LITE_ACCOUNT_LOCK_ON_CREATION), + anyString())).thenReturn("true"); + mockedUtils.when(() -> Utils.getSignUpConfigs( + eq(IdentityRecoveryConstants.ConnectorConfig.LITE_SIGN_UP_NOTIFICATION_INTERNALLY_MANAGE), + anyString())).thenReturn("true"); + + /* + Case 1: Notification internally managed. Account Lock on creation enabled. + */ + NotificationResponseBean response = userSelfRegistrationManager.registerLiteUser(user, claims, properties); + + verify(userStoreManager).addUser(anyString(), any(), any(), anyMap(), isNull()); + assertNotNull(response); + assertEquals(IdentityRecoveryConstants.SuccessEvents + .SUCCESS_STATUS_CODE_SUCCESSFUL_USER_CREATION_INTERNAL_VERIFICATION.getCode(), + response.getCode()); + assertEquals(NotificationChannels.EMAIL_CHANNEL.getChannelType(), response.getNotificationChannel()); + + /* + Case 2: Lite sign-up disabled. + Expected: Client error should be thrown. + */ + mockedUtils.when(() -> Utils.getSignUpConfigs( + eq(IdentityRecoveryConstants.ConnectorConfig.ENABLE_LITE_SIGN_UP), + anyString())).thenReturn("false"); + + try { + userSelfRegistrationManager.registerLiteUser(user, claims, properties); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + + /* + Case 3: Preferred channel is verified. + */ + mockedUtils.when(() -> Utils.getSignUpConfigs( + eq(IdentityRecoveryConstants.ConnectorConfig.ENABLE_LITE_SIGN_UP), + anyString())).thenReturn("true"); + + Claim claim2 = new Claim(); + claim2.setClaimUri(emailVerifiedClaimURI); + claim2.setValue("true"); + claims = new Claim[]{claim1, claim2}; + + response = userSelfRegistrationManager.registerLiteUser(user, claims, properties); + + assertNotNull(response); + assertEquals(IdentityRecoveryConstants.SuccessEvents + .SUCCESS_STATUS_CODE_SUCCESSFUL_USER_CREATION_WITH_VERIFIED_CHANNEL.getCode(), + response.getCode()); + + /* + Case 4: Notification externally managed. Account Lock on creation enabled. + */ + mockedUtils.when(() -> Utils.getSignUpConfigs(eq( + IdentityRecoveryConstants.ConnectorConfig.LITE_ACCOUNT_LOCK_ON_CREATION), + anyString())).thenReturn("true"); + mockedUtils.when(() -> Utils.getSignUpConfigs( + eq(IdentityRecoveryConstants.ConnectorConfig.LITE_SIGN_UP_NOTIFICATION_INTERNALLY_MANAGE), + anyString())).thenReturn("false"); + claims = new Claim[]{claim1}; + + response = userSelfRegistrationManager.registerLiteUser(user, claims, properties); + assertNotNull(response); + assertEquals(IdentityRecoveryConstants.SuccessEvents + .SUCCESS_STATUS_CODE_SUCCESSFUL_USER_CREATION_EXTERNAL_VERIFICATION.getCode(), + response.getCode()); + + /* + Case 5: Account Lock on creation is disabled. + */ + mockedUtils.when(() -> Utils.getSignUpConfigs(eq( + IdentityRecoveryConstants.ConnectorConfig.LITE_ACCOUNT_LOCK_ON_CREATION), + anyString())).thenReturn("false"); + mockedUtils.when(() -> Utils.getSignUpConfigs( + eq(IdentityRecoveryConstants.ConnectorConfig.LITE_SIGN_UP_NOTIFICATION_INTERNALLY_MANAGE), + anyString())).thenReturn("true"); + claims = new Claim[]{claim1}; + + response = userSelfRegistrationManager.registerLiteUser(user, claims, properties); + assertNotNull(response); + assertEquals(IdentityRecoveryConstants.SuccessEvents + .SUCCESS_STATUS_CODE_SUCCESSFUL_USER_CREATION_UNLOCKED_WITH_NO_VERIFICATION.getCode(), + response.getCode()); + + /* + Case 6: Invalid callback URL. + */ + mockedUtils.when(() -> Utils.validateCallbackURL(anyString(), anyString(), anyString())) + .thenReturn(false); + try { + userSelfRegistrationManager.registerLiteUser(user, claims, properties); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + + mockedUtils.when(() -> Utils.validateCallbackURL(anyString(), anyString(), anyString())) + .thenReturn(true); + /* + Case 7: Throw UserStoreException while adding user. + */ + doThrow(new org.wso2.carbon.user.core.UserStoreException("test-msg")) + .when(userStoreManager).addUser(anyString(), any(), any(), anyMap(), isNull()); + try { + userSelfRegistrationManager.registerLiteUser(user, claims, properties); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + } + } + @Test(expectedExceptions = IdentityRecoveryClientException.class) public void registerUserInvalidOrganizationEmailDomain() throws IdentityRecoveryException, org.wso2.carbon.user.api.UserStoreException { - try (MockedStatic utilsMockedStatic = Mockito.mockStatic(Utils.class)) { + try (MockedStatic utilsMockedStatic = mockStatic(Utils.class)) { utilsMockedStatic.when(() -> Utils.handleClientException(any(IdentityRecoveryConstants.ErrorMessages.class), isNull(), any())).thenReturn(IdentityException.error(IdentityRecoveryClientException.class, "err-code", "")); utilsMockedStatic.when(() -> Utils.getSignUpConfigs(anyString(), anyString())).thenReturn("true"); when(realmService.getTenantUserRealm(anyInt())).thenReturn(userRealm); when(userRealm.getUserStoreManager()).thenReturn(userStoreManager); - // Mock addUser to throw UserStoreException - doThrow(new UserStoreException("Simulated exception", "ORG-60091")).when(userStoreManager) + // Mock addUser to throw UserStoreException. + doThrow(new org.wso2.carbon.user.core.UserStoreException("Simulated exception", "ORG-60091")) + .when(userStoreManager) .isExistingRole(anyString()); userSelfRegistrationManager.registerUser(new User(), "", new Claim[]{}, null); } + } + + private User getUser() { + + User user = new User(); + user.setUserName(TEST_USER_NAME); + user.setUserStoreDomain(TEST_USERSTORE_DOMAIN); + user.setTenantDomain(TEST_TENANT_DOMAIN_NAME); + return user; + } + + private void mockMultiAttributeEnabled(Boolean isEnabled) { + + mockedIdentityUtil.when(() -> IdentityUtil.getProperty( + eq(IdentityRecoveryConstants.ConnectorConfig.SUPPORT_MULTI_EMAILS_AND_MOBILE_NUMBERS_PER_USER))) + .thenReturn(isEnabled.toString()); + } + + private void mockGetUserClaimValue(String claimUri, String claimValue) throws UserStoreException { + when(userStoreManager.getUserClaimValue(any(), eq(claimUri), any())).thenReturn(claimValue); } /** diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java new file mode 100644 index 0000000000..623d5ca0e5 --- /dev/null +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/store/JDBCRecoveryDataStoreTest.java @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.recovery.store; + +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.application.common.model.User; +import org.wso2.carbon.identity.base.IdentityException; +import org.wso2.carbon.identity.core.util.IdentityDatabaseUtil; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.event.services.IdentityEventService; +import org.wso2.carbon.identity.recovery.IdentityRecoveryClientException; +import org.wso2.carbon.identity.recovery.IdentityRecoveryConstants; +import org.wso2.carbon.identity.recovery.IdentityRecoveryServerException; +import org.wso2.carbon.identity.recovery.RecoveryScenarios; +import org.wso2.carbon.identity.recovery.RecoverySteps; +import org.wso2.carbon.identity.recovery.internal.IdentityRecoveryServiceDataHolder; +import org.wso2.carbon.identity.recovery.model.UserRecoveryData; +import org.wso2.carbon.identity.recovery.util.Utils; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.Calendar; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +/** + * Unit tests for JDBCRecoveryDataStore. + */ +public class JDBCRecoveryDataStoreTest { + + private UserRecoveryDataStore userRecoveryDataStore; + + @Mock + private Connection mockConnection; + + @Mock + private PreparedStatement mockPreparedStatement; + + @Mock + private ResultSet mockResultSet; + + @Mock + private IdentityRecoveryServiceDataHolder identityRecoveryServiceDataHolder; + + @Mock + private IdentityEventService identityEventService; + + private MockedStatic mockedIdentityDatabaseUtil; + private MockedStatic mockedIdentityTenantUtils; + private MockedStatic mockedIdentityUtil; + private MockedStatic mockedUtils; + private MockedStatic mockedIdentityRecoveryServiceDataHolder; + + private static final int TEST_TENANT_ID = 12; + private static final String TEST_TENANT_DOMAIN = "test.com"; + private static final String TEST_USER_NAME = "testUser"; + private static final String TEST_USER_STORE_DOMAIN = "testUserStore"; + private static final String TEST_SECRET_CODE = "test-sec"; + + @BeforeMethod + public void setUp() throws Exception { + + MockitoAnnotations.openMocks(this); + userRecoveryDataStore = JDBCRecoveryDataStore.getInstance(); + + mockedIdentityDatabaseUtil = mockStatic(IdentityDatabaseUtil.class); + mockedIdentityTenantUtils = mockStatic(IdentityTenantUtil.class); + mockedIdentityUtil = mockStatic(IdentityUtil.class); + mockedUtils = mockStatic(Utils.class); + mockedIdentityRecoveryServiceDataHolder = mockStatic(IdentityRecoveryServiceDataHolder.class); + + mockedIdentityRecoveryServiceDataHolder.when(IdentityRecoveryServiceDataHolder::getInstance) + .thenReturn(identityRecoveryServiceDataHolder); + mockedIdentityDatabaseUtil.when(() -> IdentityDatabaseUtil.getDBConnection(anyBoolean())) + .thenReturn(mockConnection); + mockedIdentityTenantUtils.when(() -> IdentityTenantUtil.getTenantId(TEST_TENANT_DOMAIN)) + .thenReturn(TEST_TENANT_ID); + mockedIdentityUtil.when(() -> IdentityUtil.isUserStoreCaseSensitive(anyString(), anyInt())) + .thenReturn(true); + + when(identityRecoveryServiceDataHolder.getIdentityEventService()).thenReturn(identityEventService); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + } + + @AfterMethod + public void tearDown() { + + mockedIdentityDatabaseUtil.close(); + mockedIdentityTenantUtils.close(); + mockedIdentityUtil.close(); + mockedUtils.close(); + mockedIdentityRecoveryServiceDataHolder.close(); + + reset(mockConnection, mockPreparedStatement, mockResultSet); + } + + @Test + public void testStore() throws Exception { + + UserRecoveryData recoveryData = createSampleUserRecoveryData(); + + userRecoveryDataStore.store(recoveryData); + + verify(mockPreparedStatement).setString(1, recoveryData.getUser().getUserName()); + verify(mockPreparedStatement).setString(2, + recoveryData.getUser().getUserStoreDomain().toUpperCase()); + verify(mockPreparedStatement).setInt(3, TEST_TENANT_ID); + verify(mockPreparedStatement).setString(4, TEST_SECRET_CODE); + verify(mockPreparedStatement).setString(5, String.valueOf(recoveryData.getRecoveryScenario())); + verify(mockPreparedStatement).setString(6, String.valueOf(recoveryData.getRecoveryStep())); + verify(mockPreparedStatement).execute(); + mockedIdentityDatabaseUtil.verify(() -> IdentityDatabaseUtil.commitTransaction(mockConnection)); + mockedIdentityDatabaseUtil.verify(() -> IdentityDatabaseUtil.closeConnection(mockConnection)); + + // Case 2: SQL Exception. + mockUtilsErrors(); + doThrow(new SQLException()).when(mockPreparedStatement).execute(); + try { + userRecoveryDataStore.store(recoveryData); + fail("Expected IdentityRecoveryException was not thrown."); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + mockedIdentityDatabaseUtil.verify(() -> IdentityDatabaseUtil.closeConnection(mockConnection), + times(2)); + } + + @DataProvider(name = "loadData") + private Object[][] loadData() { + + return new Object[][] { + { RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER }, + { RecoveryScenarios.MOBILE_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER }, + { RecoveryScenarios.EMAIL_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_EMAIL }, + { RecoveryScenarios.EMAIL_VERIFICATION_ON_VERIFIED_LIST_UPDATE, RecoverySteps.VERIFY_EMAIL }, + { RecoveryScenarios.TENANT_ADMIN_ASK_PASSWORD, RecoverySteps.VERIFY_EMAIL }, + { RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.CONFIRM_SIGN_UP }, + { RecoveryScenarios.ASK_PASSWORD, RecoverySteps.UPDATE_PASSWORD }, + { RecoveryScenarios.TENANT_ADMIN_ASK_PASSWORD, RecoverySteps.UPDATE_PASSWORD }, + { RecoveryScenarios.LITE_SIGN_UP, RecoverySteps.CONFIRM_LITE_SIGN_UP }, + { RecoveryScenarios.LITE_SIGN_UP, RecoverySteps.VALIDATE_CHALLENGE_QUESTION }, + { RecoveryScenarios.ADMIN_FORCED_PASSWORD_RESET_VIA_OTP, RecoverySteps.UPDATE_PASSWORD }, + { RecoveryScenarios.ADMIN_FORCED_PASSWORD_RESET_VIA_EMAIL_LINK, RecoverySteps.UPDATE_PASSWORD }, + }; + } + + @Test(dataProvider = "loadData") + public void testLoadSuccessful(RecoveryScenarios recoveryScenario, RecoverySteps recoveryStep) throws Exception { + + User user = createSampleUser(); + String remainingSetId = "0777898721"; + + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(true); + when(mockResultSet.getString("REMAINING_SETS")).thenReturn(remainingSetId); + when(mockResultSet.getTimestamp(eq("TIME_CREATED"), any(Calendar.class))) + .thenReturn(new Timestamp(System.currentTimeMillis() - 60000)); + + mockExpiryTimes(); + UserRecoveryData result = userRecoveryDataStore.load(user, recoveryScenario, recoveryStep, TEST_SECRET_CODE); + + assertNotNull(result); + assertEquals(result.getUser().getUserName(), user.getUserName()); + assertEquals(result.getSecret(), TEST_SECRET_CODE); + assertEquals(result.getRecoveryScenario(), recoveryScenario); + assertEquals(result.getRecoveryStep(), recoveryStep); + assertEquals(result.getRemainingSetIds(), remainingSetId); + + verify(identityEventService, times(2)).handleEvent(any()); + mockedIdentityDatabaseUtil.verify(() -> IdentityDatabaseUtil + .closeAllConnections(mockConnection, mockResultSet, mockPreparedStatement)); + } + + @Test() + public void testLoadExpiredCode() throws Exception { + + User user = createSampleUser(); + String remainingSetId = "0777898721"; + + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(true); + when(mockResultSet.getString("REMAINING_SETS")).thenReturn(remainingSetId); + when(mockResultSet.getTimestamp(eq("TIME_CREATED"), any(Calendar.class))) + .thenReturn(new Timestamp(System.currentTimeMillis() - 600000)); + + mockExpiryTimes(); + mockUtilsErrors(); + try { + userRecoveryDataStore.load(user, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, RecoverySteps.VERIFY_MOBILE_NUMBER, + TEST_SECRET_CODE); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + } + + private void mockExpiryTimes() { + + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.EMAIL_VERIFICATION_ON_UPDATE_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.MOBILE_NUM_VERIFICATION_ON_UPDATE_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + mockedIdentityUtil.when(() -> IdentityUtil.getProperty(IdentityRecoveryConstants + .ConnectorConfig.TENANT_ADMIN_ASK_PASSWORD_EXPIRY_TIME)) + .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.ASK_PASSWORD_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.ADMIN_PASSWORD_RESET_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.SELF_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.SELF_REGISTRATION_SMSOTP_VERIFICATION_CODE_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.RESEND_CODE_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.LITE_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + mockedUtils.when(() -> Utils.getRecoveryConfigs(IdentityRecoveryConstants + .ConnectorConfig.LITE_REGISTRATION_SMSOTP_VERIFICATION_CODE_EXPIRY_TIME, TEST_TENANT_DOMAIN)) + .thenReturn("10"); + } + + private void mockUtilsErrors() { + + mockedUtils.when(() -> Utils.handleServerException( + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_ISSUE_IN_LOADING_RECOVERY_CONFIGS, null)) + .thenReturn(IdentityException.error(IdentityRecoveryServerException.class, + "err-code", + "")); + + mockedUtils.when(() -> Utils.handleServerException( + any(IdentityRecoveryConstants.ErrorMessages.class), + isNull(), + any())) + .thenReturn(IdentityException.error(IdentityRecoveryServerException.class, + "err-code", + new Throwable())); + + mockedUtils.when(() -> Utils.handleClientException(any(IdentityRecoveryConstants.ErrorMessages.class), + anyString())).thenReturn(IdentityException.error(IdentityRecoveryClientException.class, + "err-code", + "")); + } + + private UserRecoveryData createSampleUserRecoveryData() { + + User user = createSampleUser(); + return new UserRecoveryData(user, TEST_SECRET_CODE, + RecoveryScenarios.MOBILE_VERIFICATION_ON_UPDATE, + RecoverySteps.VERIFY_MOBILE_NUMBER); + } + + private User createSampleUser() { + + User user = new User(); + user.setUserName(TEST_USER_NAME); + user.setTenantDomain(TEST_TENANT_DOMAIN); + user.setUserStoreDomain(TEST_USER_STORE_DOMAIN); + return user; + } +} diff --git a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/util/UtilsTest.java b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/util/UtilsTest.java index a1e4c25c34..d5a6c4f244 100644 --- a/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/util/UtilsTest.java +++ b/components/org.wso2.carbon.identity.recovery/src/test/java/org/wso2/carbon/identity/recovery/util/UtilsTest.java @@ -18,6 +18,7 @@ package org.wso2.carbon.identity.recovery.util; +import org.apache.commons.lang.StringUtils; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -26,27 +27,82 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; import org.wso2.carbon.identity.application.common.model.User; +import org.wso2.carbon.identity.auth.attribute.handler.exception.AuthAttributeHandlerClientException; +import org.wso2.carbon.identity.auth.attribute.handler.exception.AuthAttributeHandlerException; +import org.wso2.carbon.identity.auth.attribute.handler.model.ValidationFailureReason; +import org.wso2.carbon.identity.auth.attribute.handler.model.ValidationResult; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.event.IdentityEventConstants; +import org.wso2.carbon.identity.event.IdentityEventException; +import org.wso2.carbon.identity.event.services.IdentityEventService; +import org.wso2.carbon.identity.governance.IdentityGovernanceException; +import org.wso2.carbon.identity.governance.IdentityGovernanceService; +import org.wso2.carbon.identity.handler.event.account.lock.exception.AccountLockServiceException; +import org.wso2.carbon.identity.handler.event.account.lock.service.AccountLockService; import org.wso2.carbon.identity.recovery.IdentityRecoveryClientException; import org.wso2.carbon.identity.recovery.IdentityRecoveryConstants; +import org.wso2.carbon.identity.recovery.IdentityRecoveryServerException; +import org.wso2.carbon.identity.recovery.RecoveryScenarios; +import org.wso2.carbon.identity.recovery.RecoverySteps; +import org.wso2.carbon.identity.recovery.exception.SelfRegistrationClientException; +import org.wso2.carbon.identity.recovery.exception.SelfRegistrationException; import org.wso2.carbon.identity.recovery.internal.IdentityRecoveryServiceDataHolder; -import org.wso2.carbon.user.api.UserStoreException; +import org.wso2.carbon.identity.user.functionality.mgt.UserFunctionalityMgtConstants; +import org.wso2.carbon.user.api.Claim; +import org.wso2.carbon.user.api.RealmConfiguration; +import org.wso2.carbon.user.core.UserStoreException; +import org.wso2.carbon.user.core.UserCoreConstants; import org.wso2.carbon.user.core.UserRealm; import org.wso2.carbon.user.core.UserStoreManager; +import org.wso2.carbon.user.core.claim.ClaimManager; +import org.wso2.carbon.user.core.common.AbstractUserStoreManager; +import org.wso2.carbon.user.core.constants.UserCoreErrorConstants; import org.wso2.carbon.user.core.service.RealmService; +import org.wso2.carbon.identity.application.common.model.Property; +import org.wso2.carbon.user.core.tenant.TenantManager; +import org.wso2.carbon.user.core.util.UserCoreUtil; +import org.wso2.carbon.utils.multitenancy.MultitenantUtils; +import org.wso2.carbon.identity.governance.service.notification.NotificationChannels; +import org.wso2.carbon.identity.recovery.model.UserRecoveryData; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.security.NoSuchAlgorithmException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static junit.framework.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import static org.wso2.carbon.identity.auth.attribute.handler.AuthAttributeHandlerConstants.ErrorMessages.ERROR_CODE_AUTH_ATTRIBUTE_HANDLER_NOT_FOUND; public class UtilsTest { - private static final String TENANT_DOMAIN = "test.com"; - private static final int TENANT_ID = 123; - private static final String USER_NAME = "testUser"; - private static final String USER_STORE_DOMAIN = "TEST"; @Mock private UserStoreManager userStoreManager; @Mock @@ -54,52 +110,92 @@ public class UtilsTest { @Mock private RealmService realmService; @Mock + private RealmConfiguration realmConfiguration; + @Mock private IdentityRecoveryServiceDataHolder identityRecoveryServiceDataHolder; + @Mock + private IdentityGovernanceService identityGovernanceService; + @Mock + private AccountLockService accountLockService; + @Mock + private TenantManager tenantManager; + @Mock + private ClaimManager claimManager; + @Mock + private AbstractUserStoreManager abstractUserStoreManager; + @Mock + private IdentityEventService identityEventService; - private MockedStatic mockedStaticIdentityTenantUtil; - private MockedStatic mockedStaticUserStoreManager; - private MockedStatic mockedIdentityRecoveryServiceDataHolder; - private MockedStatic mockedIdentityUtil; + private static MockedStatic mockedStaticIdentityTenantUtil; + private static MockedStatic mockedStaticUserStoreManager; + private static MockedStatic mockedIdentityRecoveryServiceDataHolder; + private static MockedStatic mockedStaticIdentityUtil; + private static MockedStatic mockedStaticFrameworkUtils; + private static MockedStatic mockedStaticMultiTenantUtils; + private static MockedStatic mockedStaticUserCoreUtil; - @BeforeMethod - public void setUp() { - - MockitoAnnotations.openMocks(this); - } + private static final String TENANT_DOMAIN = "test.com"; + private static final int TENANT_ID = 123; + private static final String USER_NAME = "testUser"; + private static final String USER_STORE_DOMAIN = "TEST"; @BeforeClass - public void beforeTest() { + public static void beforeClass() { mockedStaticIdentityTenantUtil = mockStatic(IdentityTenantUtil.class); mockedStaticUserStoreManager = mockStatic(UserStoreManager.class); mockedIdentityRecoveryServiceDataHolder = Mockito.mockStatic(IdentityRecoveryServiceDataHolder.class); - mockedIdentityUtil = mockStatic(IdentityUtil.class); + mockedStaticIdentityUtil = mockStatic(IdentityUtil.class); + mockedStaticFrameworkUtils = mockStatic(FrameworkUtils.class); + mockedStaticMultiTenantUtils = mockStatic(MultitenantUtils.class); + mockedStaticUserCoreUtil = mockStatic(UserCoreUtil.class); } @AfterClass - public void afterTest() { + public static void afterClass() { mockedStaticIdentityTenantUtil.close(); mockedStaticUserStoreManager.close(); mockedIdentityRecoveryServiceDataHolder.close(); - mockedIdentityUtil.close(); + mockedStaticIdentityUtil.close(); + mockedStaticFrameworkUtils.close(); + mockedStaticMultiTenantUtils.close(); + mockedStaticUserCoreUtil.close(); } - @Test(expectedExceptions = IdentityRecoveryClientException.class) - public void testCheckPasswordPatternViolationForInvalidDomain() throws Exception { + @BeforeMethod + public void setUp() throws org.wso2.carbon.user.api.UserStoreException { - User user = new User(); - user.setUserName(USER_NAME); - user.setTenantDomain(TENANT_DOMAIN); - user.setUserStoreDomain(USER_STORE_DOMAIN); + MockitoAnnotations.openMocks(this); + + mockedIdentityRecoveryServiceDataHolder.when(IdentityRecoveryServiceDataHolder::getInstance) + .thenReturn(identityRecoveryServiceDataHolder); + mockedStaticIdentityUtil.when(() -> IdentityTenantUtil.getTenantId(TENANT_DOMAIN)).thenReturn(TENANT_ID); + mockedStaticIdentityUtil.when(IdentityUtil::getPrimaryDomainName).thenReturn("PRIMARY"); + mockedStaticIdentityUtil.when(() -> IdentityUtil.addDomainToName(USER_NAME, USER_STORE_DOMAIN)) + .thenReturn(USER_STORE_DOMAIN + UserCoreConstants.DOMAIN_SEPARATOR + USER_NAME); + mockedStaticFrameworkUtils.when(FrameworkUtils::getMultiAttributeSeparator).thenReturn(","); + mockedStaticMultiTenantUtils.when(() -> + MultitenantUtils.getTenantAwareUsername(USER_NAME)).thenReturn(USER_NAME); - when(IdentityTenantUtil.getTenantId(user.getTenantDomain())).thenReturn(TENANT_ID); - mockedIdentityRecoveryServiceDataHolder.when(IdentityRecoveryServiceDataHolder::getInstance).thenReturn( - identityRecoveryServiceDataHolder); when(identityRecoveryServiceDataHolder.getRealmService()).thenReturn(realmService); + when(identityRecoveryServiceDataHolder.getIdentityGovernanceService()).thenReturn(identityGovernanceService); + when(identityRecoveryServiceDataHolder.getAccountLockService()).thenReturn(accountLockService); + when(identityRecoveryServiceDataHolder.getIdentityEventService()).thenReturn(identityEventService); + when(realmService.getTenantUserRealm(TENANT_ID)).thenReturn(userRealm); + when(realmService.getBootstrapRealm()).thenReturn(userRealm); + when(realmService.getTenantManager()).thenReturn(tenantManager); when(userRealm.getUserStoreManager()).thenReturn(userStoreManager); - mockedIdentityUtil.when(IdentityUtil::getPrimaryDomainName).thenReturn("PRIMARY"); + when(userRealm.getClaimManager()).thenReturn(claimManager); + when(userStoreManager.getRealmConfiguration()).thenReturn(realmConfiguration); + when(tenantManager.getTenantId(eq(TENANT_DOMAIN))).thenReturn(TENANT_ID); + } + + @Test(expectedExceptions = IdentityRecoveryClientException.class) + public void testCheckPasswordPatternViolationForInvalidDomain() throws Exception { + + User user = getUser(); when(userStoreManager.getSecondaryUserStoreManager(USER_STORE_DOMAIN)).thenReturn(null); try { @@ -111,4 +207,1182 @@ public void testCheckPasswordPatternViolationForInvalidDomain() throws Exception throw e; } } + + @Test + public void testGetArbitraryProperties() { + + org.wso2.carbon.identity.recovery.model.Property property = + new org.wso2.carbon.identity.recovery.model.Property("key", "value"); + + org.wso2.carbon.identity.recovery.model.Property[] properties = + new org.wso2.carbon.identity.recovery.model.Property[]{property}; + Utils.setArbitraryProperties(properties); + + org.wso2.carbon.identity.recovery.model.Property[] result = Utils.getArbitraryProperties(); + + assertNotNull(result); + assertEquals(result.length, 1); + assertEquals(result[0].getKey(), "key"); + assertEquals(result[0].getValue(), "value"); + + Utils.clearArbitraryProperties(); + org.wso2.carbon.identity.recovery.model.Property[] result1 = Utils.getArbitraryProperties(); + assertNull(result1); + } + + @Test + public void testGetEmailVerifyTemporaryClaim() { + + Claim claim = new Claim(); + claim.setClaimUri(IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM); + Utils.setEmailVerifyTemporaryClaim(claim); + + Claim result = Utils.getEmailVerifyTemporaryClaim(); + assertNotNull(result); + assertEquals(result.getClaimUri(), IdentityRecoveryConstants.VERIFY_EMAIL_CLIAM); + + Utils.clearEmailVerifyTemporaryClaim(); + Claim result1 = Utils.getEmailVerifyTemporaryClaim(); + assertNull(result1); + } + + @Test + public void testThreadLocalToSkipSendingEmailVerificationOnUpdate() { + + String threadLocalValue = "test-value"; + Utils.setThreadLocalToSkipSendingEmailVerificationOnUpdate(threadLocalValue); + + String result = Utils.getThreadLocalToSkipSendingEmailVerificationOnUpdate(); + assertEquals(result, threadLocalValue); + + Utils.unsetThreadLocalToSkipSendingEmailVerificationOnUpdate(); + String result1 = Utils.getThreadLocalToSkipSendingEmailVerificationOnUpdate(); + assertNull(result1); + } + + @Test + public void ThreadLocalToSkipSendingSmsOtpVerificationOnUpdate() { + + String threadLocalValue = "test-value"; + Utils.setThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(threadLocalValue); + + String result = Utils.getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(); + assertEquals(result, threadLocalValue); + + Utils.unsetThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(); + String result1 = Utils.getThreadLocalToSkipSendingSmsOtpVerificationOnUpdate(); + assertNull(result1); + } + + @Test + public void testIsOnlyVerifiedMobileNumbersUpdatedThreadLocal() { + + // Initially should be false. + assertFalse(Utils.getThreadLocalIsOnlyVerifiedMobileNumbersUpdated()); + + // Set to true. + Utils.setThreadLocalIsOnlyVerifiedMobileNumbersUpdated(true); + assertTrue(Utils.getThreadLocalIsOnlyVerifiedMobileNumbersUpdated()); + + // Set to false. + Utils.setThreadLocalIsOnlyVerifiedMobileNumbersUpdated(false); + assertFalse(Utils.getThreadLocalIsOnlyVerifiedMobileNumbersUpdated()); + + // Unset. + Utils.unsetThreadLocalIsOnlyVerifiedMobileNumbersUpdated(); + assertFalse(Utils.getThreadLocalIsOnlyVerifiedMobileNumbersUpdated()); + } + + @Test + public void testIsOnlyVerifiedEmailAddressesUpdatedThreadLocal() { + + // Initially should be false. + assertFalse(Utils.getThreadLocalIsOnlyVerifiedEmailAddressesUpdated()); + + // Set to true. + Utils.setThreadLocalIsOnlyVerifiedEmailAddressesUpdated(true); + assertTrue(Utils.getThreadLocalIsOnlyVerifiedEmailAddressesUpdated()); + + // Set to false. + Utils.setThreadLocalIsOnlyVerifiedEmailAddressesUpdated(false); + assertFalse(Utils.getThreadLocalIsOnlyVerifiedEmailAddressesUpdated()); + + // Unset. + Utils.unsetThreadLocalIsOnlyVerifiedEmailAddressesUpdated(); + assertFalse(Utils.getThreadLocalIsOnlyVerifiedEmailAddressesUpdated()); + } + + @Test + public void testGetClaimFromUserStoreManager() throws Exception { + + User user = getUser(); + Map claimMap = new HashMap<>(); + claimMap.put("testClaim", "testValue"); + when(userStoreManager.getUserClaimValues(any(), any(), anyString())) + .thenReturn(claimMap); + + String result = Utils.getClaimFromUserStoreManager(user, "testClaim"); + assertEquals("testValue", result); + } + + @Test + public void testRemoveClaimFromUserStoreManager() throws Exception { + + User user = getUser(); + String[] claims = new String[]{"testClaim"}; + Utils.removeClaimFromUserStoreManager(user, claims); + String userStoreQualifiedUsername = USER_STORE_DOMAIN + UserCoreConstants.DOMAIN_SEPARATOR + USER_NAME; + + verify(userStoreManager).deleteUserClaimValues(eq(userStoreQualifiedUsername), eq(claims), anyString()); + } + + @Test + public void testHandleServerException() throws IdentityRecoveryServerException { + + Exception exception = + Utils.handleServerException(IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE, "data"); + assertEquals(exception.getClass(), IdentityRecoveryServerException.class); + assertEquals(((IdentityRecoveryServerException) exception).getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE.getCode()); + + Exception exception1 = + Utils.handleServerException(IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE, + "data", new Exception("test")); + assertEquals(exception1.getClass(), IdentityRecoveryServerException.class); + assertEquals(((IdentityRecoveryServerException) exception1).getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE.getCode()); + assertEquals(exception1.getCause().getMessage(), "test"); + + Exception exception2 = + Utils.handleServerException("code2", "message2%s", "data2"); + assertEquals(exception2.getClass(), IdentityRecoveryServerException.class); + assertEquals(((IdentityRecoveryServerException) exception2).getErrorCode(), "code2"); + assertEquals(exception2.getMessage(), String.format("message2%s", "data2")); + } + + @Test + public void testHandleClientException() throws IdentityRecoveryClientException { + + Exception exception1 = + Utils.handleClientException(IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE, "data1"); + assertEquals(exception1.getClass(), IdentityRecoveryClientException.class); + assertEquals(((IdentityRecoveryClientException) exception1).getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE.getCode()); + assertEquals(exception1.getMessage(), + String.format(IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE.getMessage(), "data1")); + + Exception exception2 = + Utils.handleClientException(IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE, + "data", new Exception("test")); + assertEquals(exception2.getClass(), IdentityRecoveryClientException.class); + assertEquals(((IdentityRecoveryClientException) exception2).getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE.getCode()); + assertEquals(exception2.getCause().getMessage(), "test"); + + Exception exception3 = + Utils.handleClientException("code2", "message2%s", "data2"); + assertEquals(exception3.getClass(), IdentityRecoveryClientException.class); + assertEquals(((IdentityRecoveryClientException) exception3).getErrorCode(), "code2"); + assertEquals(exception3.getMessage(), String.format("message2%s", "data2")); + } + + @Test + public void testHandleFunctionalityLockMgtServerException() { + + IdentityRecoveryConstants.ErrorMessages error = IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_EXPIRED_CODE; + String userId = "testUser"; + String functionalityIdentifier = "PASSWORD_RECOVERY"; + boolean isDetailedErrorMessagesEnabled = true; + + try { + Utils.handleFunctionalityLockMgtServerException(error, userId, TENANT_ID, functionalityIdentifier, + isDetailedErrorMessagesEnabled); + } catch (IdentityRecoveryServerException e) { + String expectedErrorCode = IdentityRecoveryConstants.PASSWORD_RECOVERY_SCENARIO + "-" + error.getCode(); + assertEquals(e.getErrorCode(), expectedErrorCode); + String expectedErrorMessage = error.getMessage() + + String.format("functionality: %s \nuserId: %s \ntenantId: %d.", functionalityIdentifier, userId, + TENANT_ID); + assertEquals(e.getMessage(), expectedErrorMessage); + assertNull(e.getCause()); + } + + // Test with detailed error messages disabled. + isDetailedErrorMessagesEnabled = false; + try { + Utils.handleFunctionalityLockMgtServerException(error, userId, TENANT_ID, + functionalityIdentifier, isDetailedErrorMessagesEnabled); + } catch (IdentityRecoveryServerException e) { + // Verify that the error message doesn't contain the detailed information. + assertFalse(e.getMessage().contains("functionality:")); + assertFalse(e.getMessage().contains("userId:")); + assertFalse(e.getMessage().contains("tenantId:")); + } + } + + @Test + public void testDoHash() throws org.wso2.carbon.user.api.UserStoreException { + + String value = "testValue"; + String expectedHash = "expected_hash"; + + try (MockedStatic mockedUtils = mockStatic(Utils.class)) { + mockedUtils.when(() -> Utils.hashCode(value)).thenReturn(expectedHash); + mockedUtils.when(() -> Utils.doHash(value)).thenCallRealMethod(); + + String result = Utils.doHash(value); + assertEquals(result, expectedHash); + } + + try (MockedStatic mockedUtils = mockStatic(Utils.class)) { + mockedUtils.when(() -> Utils.hashCode(value)).thenThrow(new NoSuchAlgorithmException("Test exception")); + mockedUtils.when(() -> Utils.doHash(value)).thenCallRealMethod(); + + Utils.doHash(value); + } catch (Exception e) { + assertTrue(e instanceof org.wso2.carbon.user.api.UserStoreException); + } + } + + @Test + public void testSetClaimInUserStoreManager() throws org.wso2.carbon.user.api.UserStoreException { + + String claim = "testClaim"; + String value = "testValue"; + + Map existingValues = new HashMap<>(); + existingValues.put(claim, "oldValue"); + when(userStoreManager.getUserClaimValues(anyString(), any(String[].class), anyString())) + .thenReturn(existingValues); + + Utils.setClaimInUserStoreManager(getUser(), claim, value); + + verify(userStoreManager).setUserClaimValues(anyString(), + argThat(map -> map.containsKey(claim) && map.get(claim).equals(value)), anyString()); + } + + @Test + public void testGetClaimListOfUser() throws IdentityRecoveryClientException, IdentityRecoveryServerException, + UserStoreException { + + String[] claimsList = {"claim1", "claim2"}; + Map expectedClaims = new HashMap<>(); + expectedClaims.put("claim1", "value1"); + expectedClaims.put("claim2", "value2"); + + when(userStoreManager.getUserClaimValues(anyString(), eq(claimsList), anyString())) + .thenReturn(expectedClaims); + + Map result = Utils.getClaimListOfUser(getUser(), claimsList); + assertEquals(result, expectedClaims); + + // Case 2: Throw UserStoreException. + when(userStoreManager.getUserClaimValues(anyString(), eq(claimsList), anyString())) + .thenThrow(new UserStoreException()); + try { + Utils.getClaimListOfUser(getUser(), claimsList); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + } + + @Test + public void testSetClaimsListOfUser() throws Exception { + + User user = getUser(); + Map claims = new HashMap<>(); + claims.put("http://wso2.org/claims/givenname", "John"); + claims.put("http://wso2.org/claims/emailaddress", "john@example.com"); + + Utils.setClaimsListOfUser(user, claims); + + String userStoreDomainQualifiedUsername = getUserStoreQualifiedUsername(USER_NAME, USER_STORE_DOMAIN); + verify(userStoreManager).setUserClaimValues(eq(userStoreDomainQualifiedUsername), eq(claims), anyString()); + + // Case 2: Throw UserStoreException. + try { + doThrow(new UserStoreException("Test exception")) + .when(userStoreManager).setUserClaimValues(anyString(), anyMap(), anyString()); + Utils.setClaimsListOfUser(user, claims); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + } + + @Test + public void testGetRecoveryConfigs() throws Exception { + + String key = "recovery.key"; + String expectedValue = "test_value"; + Property property = new Property(); + property.setName(key); + property.setValue(expectedValue); + Property[] properties = new Property[]{property}; + + when(identityGovernanceService.getConfiguration(eq(new String[]{key}), eq(TENANT_DOMAIN))) + .thenReturn(properties); + + String result = Utils.getRecoveryConfigs(key, TENANT_DOMAIN); + assertEquals(result, expectedValue); + + // Case 2: Throw IdentityGovernanceException. + when(identityGovernanceService.getConfiguration(eq(new String[]{key}), eq(TENANT_DOMAIN))) + .thenThrow(new IdentityGovernanceException("Test exception")); + try { + Utils.getRecoveryConfigs(key, TENANT_DOMAIN); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + + // Case 3: Return empty connectorConfigs. + when(identityGovernanceService.getConfiguration(eq(new String[]{key}), eq(TENANT_DOMAIN))) + .thenReturn(new Property[0]); + try { + Utils.getRecoveryConfigs(key, TENANT_DOMAIN); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + } + + @Test + public void testGetSignUpConfigs_Success() throws Exception { + + String key = "recovery.key"; + String expectedValue = "test_value"; + Property property = new Property(); + property.setName(key); + property.setValue(expectedValue); + Property[] properties = new Property[]{property}; + when(identityGovernanceService.getConfiguration(eq(new String[]{key}), eq(TENANT_DOMAIN))) + .thenReturn(properties); + + String result = Utils.getSignUpConfigs(key, TENANT_DOMAIN); + assertEquals(result, expectedValue); + + // Case 2: Throw IdentityGovernanceException. + when(identityGovernanceService.getConfiguration(eq(new String[]{key}), eq(TENANT_DOMAIN))) + .thenThrow(new IdentityGovernanceException("Test exception")); + try { + Utils.getSignUpConfigs(key, TENANT_DOMAIN); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + } + + @Test + public void testGetConnectorConfig() throws Exception { + + String key = "recovery.key"; + String expectedValue = "test_value"; + Property property = new Property(); + property.setName(key); + property.setValue(expectedValue); + Property[] properties = new Property[]{property}; + when(identityGovernanceService.getConfiguration(eq(new String[]{key}), eq(TENANT_DOMAIN))) + .thenReturn(properties); + + String result = Utils.getConnectorConfig(key, TENANT_DOMAIN); + assertEquals(result, expectedValue); + + // Case 2: Throw IdentityGovernanceException. + when(identityGovernanceService.getConfiguration(eq(new String[]{key}), eq(TENANT_DOMAIN))) + .thenThrow(new IdentityGovernanceException("Test exception")); + try { + Utils.getSignUpConfigs(key, TENANT_DOMAIN); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + } + + @Test + public void testGetChallengeSetDirFromUri() { + + String uri1 = "http://wso2.org/claims/challengeQuestion1"; + String uri2 = "challengeQuestion1"; + String uri3 = null; + + assertEquals(Utils.getChallengeSetDirFromUri(uri1), "challengeQuestion1"); + assertEquals(Utils.getChallengeSetDirFromUri(uri2), "challengeQuestion1"); + assertNull(Utils.getChallengeSetDirFromUri(uri3)); + } + + @Test + public void testIsAccountLocked() throws Exception { + + User user = getUser(); + when(accountLockService.isAccountLocked(eq(USER_NAME), eq(TENANT_DOMAIN), eq(USER_STORE_DOMAIN))) + .thenReturn(true); + assertTrue(Utils.isAccountLocked(user)); + + // Case 2: Throws AccountLockServiceException. + when(accountLockService.isAccountLocked(anyString(), anyString(), anyString())) + .thenThrow(new AccountLockServiceException("Test exception")); + try { + Utils.isAccountLocked(user); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryServerException); + } + } + + @Test + public void testIsAccountDisabled() throws Exception { + + User user = getUser(); + Map claimValues = new HashMap<>(); + claimValues.put(IdentityRecoveryConstants.ACCOUNT_DISABLED_CLAIM, Boolean.TRUE.toString()); + when(userStoreManager.getUserClaimValues(eq(getUserStoreQualifiedUsername(USER_NAME, USER_STORE_DOMAIN)), + eq(new String[]{IdentityRecoveryConstants.ACCOUNT_DISABLED_CLAIM}), anyString())) + .thenReturn(claimValues); + + assertTrue(Utils.isAccountDisabled(user)); + + // Case 2: Throws error while loading realm service. + when(realmService.getTenantUserRealm(anyInt())).thenThrow( + new UserStoreException("Test exception")); + + try { + Utils.isAccountDisabled(user); + fail("Expected IdentityRecoveryServerException was not thrown"); + } catch (IdentityRecoveryServerException e) { + assertEquals(e.getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_FAILED_TO_LOAD_REALM_SERVICE.getCode()); + } + + // Case 3: Throws error while loading user store manager. + doReturn(userRealm).when(realmService).getTenantUserRealm(anyInt()); + when(userRealm.getUserStoreManager()).thenThrow( + new UserStoreException("Test exception")); + + try { + Utils.isAccountDisabled(user); + fail("Expected IdentityRecoveryServerException was not thrown"); + } catch (IdentityRecoveryServerException e) { + assertEquals(e.getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_FAILED_TO_LOAD_USER_STORE_MANAGER.getCode()); + } + + // Case 4: Throws error while getting user claim values. + doReturn(userRealm).when(realmService).getTenantUserRealm(anyInt()); + doReturn(userStoreManager).when(userRealm).getUserStoreManager(); + when(userStoreManager.getUserClaimValues(eq(getUserStoreQualifiedUsername(USER_NAME, USER_STORE_DOMAIN)), + eq(new String[]{IdentityRecoveryConstants.ACCOUNT_DISABLED_CLAIM}), anyString())) + .thenThrow(new UserStoreException("Test exception")); + + try { + Utils.isAccountDisabled(user); + fail("Expected IdentityRecoveryServerException was not thrown"); + } catch (IdentityRecoveryServerException e) { + assertEquals(e.getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_FAILED_TO_LOAD_USER_CLAIMS.getCode()); + } + } + + @Test + public void testCreateUser() { + + User user = Utils.createUser(USER_NAME, TENANT_DOMAIN); + assertEquals(user.getUserName(), USER_NAME); + assertEquals(user.getTenantDomain(), TENANT_DOMAIN); + } + + @Test + public void testValidateCallbackURL() throws IdentityEventException, IdentityGovernanceException { + + String callbackURL = "https://example.com/callback"; + String tenantDomain = "example.com"; + String callbackRegexType = "RECOVERY_CALLBACK_REGEX"; + + String expectedValue = "https://.*\\.com/.*"; + Property property = new Property(); + property.setName(callbackRegexType); + property.setValue(expectedValue); + Property[] properties = new Property[]{property}; + when(identityGovernanceService.getConfiguration(new String[]{callbackRegexType,}, tenantDomain)) + .thenReturn(properties); + + boolean result = Utils.validateCallbackURL(callbackURL, tenantDomain, callbackRegexType); + assertTrue(result); + + result = Utils.validateCallbackURL("http://malicious.com", tenantDomain, callbackRegexType); + assertFalse(result); + } + + @Test + public void testGetCallbackURLFromRegistration() throws MalformedURLException, UnsupportedEncodingException { + + org.wso2.carbon.identity.recovery.model.Property[] properties = + new org.wso2.carbon.identity.recovery.model.Property[]{ + new org.wso2.carbon.identity.recovery.model.Property(IdentityRecoveryConstants.CALLBACK, + "https://example.com/callback?param=value"), + new org.wso2.carbon.identity.recovery.model.Property("other", "value") + }; + + String result = Utils.getCallbackURLFromRegistration(properties); + assertEquals(result, "https://example.com/callback"); + } + + @Test + public void testGetCallbackURL() throws UnsupportedEncodingException, URISyntaxException { + + org.wso2.carbon.identity.recovery.model.Property[] properties = + new org.wso2.carbon.identity.recovery.model.Property[]{ + new org.wso2.carbon.identity.recovery.model.Property(IdentityRecoveryConstants.CALLBACK, + "https://example.com/callback?param=value"), + new org.wso2.carbon.identity.recovery.model.Property("other", "value") + }; + + String result = Utils.getCallbackURL(properties); + assertEquals(result, "https://example.com/callback"); + } + + @Test + public void testIsAccessUrlAvailable() { + + org.wso2.carbon.identity.recovery.model.Property[] propertiesTrue = + new org.wso2.carbon.identity.recovery.model.Property[]{ + new org.wso2.carbon.identity.recovery.model.Property(IdentityRecoveryConstants.IS_ACCESS_URL_AVAILABLE, + "true") + }; + assertTrue(Utils.isAccessUrlAvailable(propertiesTrue)); + + // Case 2: When IS_ACCESS_URL_AVAILABLE property is not present. + org.wso2.carbon.identity.recovery.model.Property[] propertiesNoAccessUrl = + new org.wso2.carbon.identity.recovery.model.Property[]{ + new org.wso2.carbon.identity.recovery.model.Property("someOtherKey", "someValue") + }; + assertFalse(Utils.isAccessUrlAvailable(propertiesNoAccessUrl)); + + // Case 3: Null properties. + assertFalse(Utils.isAccessUrlAvailable(null)); + } + + @Test + public void testIsLiteSignUp() { + + org.wso2.carbon.identity.recovery.model.Property[] propertiesTrue = + new org.wso2.carbon.identity.recovery.model.Property[]{ + new org.wso2.carbon.identity.recovery.model.Property(IdentityRecoveryConstants.IS_LITE_SIGN_UP, "true") + }; + assertTrue(Utils.isLiteSignUp(propertiesTrue)); + + // Case 2: Test when property is not present. + org.wso2.carbon.identity.recovery.model.Property[] propertiesNoLiteSignUp = + new org.wso2.carbon.identity.recovery.model.Property[]{ + new org.wso2.carbon.identity.recovery.model.Property("someOtherKey", "someValue") + }; + assertFalse(Utils.isLiteSignUp(propertiesNoLiteSignUp)); + + // Case 3: Test with null properties. + assertFalse(Utils.isLiteSignUp(null)); + } + + @Test + public void testIsUserPortalURL() { + + org.wso2.carbon.identity.recovery.model.Property[] propertiesTrue = + new org.wso2.carbon.identity.recovery.model.Property[]{ + new org.wso2.carbon.identity.recovery.model.Property(IdentityRecoveryConstants.IS_USER_PORTAL_URL, + "true") + }; + assertTrue(Utils.isUserPortalURL(propertiesTrue)); + + // Case 2: Test when property is not present. + org.wso2.carbon.identity.recovery.model.Property[] propertiesNoUserPortalURL = + new org.wso2.carbon.identity.recovery.model.Property[]{ + new org.wso2.carbon.identity.recovery.model.Property("someOtherKey", "someValue") + }; + assertFalse(Utils.isUserPortalURL(propertiesNoUserPortalURL)); + + // Case 3: Test with null properties. + assertFalse(Utils.isUserPortalURL(null)); + } + + @Test + public void testCheckPasswordPatternViolation() throws Exception { + + String PROPERTY_PASSWORD_ERROR_MSG = "PasswordJavaRegExViolationErrorMsg"; + String errorCode = UserCoreErrorConstants.ErrorMessages + .ERROR_CODE_ERROR_DURING_PRE_UPDATE_CREDENTIAL_BY_ADMIN.getCode(); + String errorMessage = "TEST_CUSTOM_PASSWORD_ERROR_MSG"; + User user = getUser(); + user.setUserStoreDomain(UserCoreConstants.PRIMARY_DEFAULT_DOMAIN_NAME); + + when(realmConfiguration.getUserStoreProperty(PROPERTY_PASSWORD_ERROR_MSG)).thenReturn(errorMessage); + UserStoreException exceptionWithViolation = + new UserStoreException(String.format("%s: %s", errorCode, errorMessage)); + + try { + Utils.checkPasswordPatternViolation(exceptionWithViolation, user); + fail("Expected IdentityRecoveryClientException was not thrown"); + } catch (IdentityRecoveryClientException e) { + assertEquals(IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_POLICY_VIOLATION.getCode(), + e.getErrorCode()); + } + + // Case 2 - Password error message property is not set. + when(realmConfiguration.getUserStoreProperty(PROPERTY_PASSWORD_ERROR_MSG)).thenReturn(""); + UserStoreException exceptionWithInvalidPasswordCode = new UserStoreException( + UserCoreErrorConstants.ErrorMessages.ERROR_CODE_INVALID_PASSWORD.getCode()); + when(realmConfiguration.getUserStoreProperty(UserCoreConstants.RealmConfig.PROPERTY_JAVA_REG_EX)) + .thenReturn("^[a-zA-Z0-9]{5,10}$"); + try { + Utils.checkPasswordPatternViolation(exceptionWithInvalidPasswordCode, user); + fail("Expected IdentityRecoveryClientException was not thrown"); + } catch (IdentityRecoveryClientException e) { + assertEquals(IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_POLICY_VIOLATION.getCode(), e.getErrorCode()); + assertTrue(e.getMessage().contains("^[a-zA-Z0-9]{5,10}$")); + } + } + + @Test + public void testIsAccountStateClaimExisting() throws Exception { + + Claim mockClaim = mock(Claim.class); + when(claimManager.getClaim(IdentityRecoveryConstants.ACCOUNT_STATE_CLAIM_URI)).thenReturn(mockClaim); + boolean result = Utils.isAccountStateClaimExisting(TENANT_DOMAIN); + assertTrue(result); + + // Case 2: Throws UserStoreException. + when(claimManager.getClaim(IdentityRecoveryConstants.ACCOUNT_STATE_CLAIM_URI)) + .thenThrow(new UserStoreException("Test exception")); + try { + Utils.isAccountStateClaimExisting(TENANT_DOMAIN); + } catch (Exception e) { + assertTrue(e instanceof IdentityEventException); + } + } + + @Test + public void testPrependOperationScenarioToErrorCode() { + + String scenario = "USR"; + String errorCode = "20045"; + + // Test with valid scenario and error code. + String result = Utils.prependOperationScenarioToErrorCode(errorCode, scenario); + assertEquals(result, "USR-20045"); + + // Test with empty error code. + result = Utils.prependOperationScenarioToErrorCode("", scenario); + assertEquals(result, ""); + + // Test with scenario already in error code. + result = Utils.prependOperationScenarioToErrorCode("USR-20045", scenario); + assertEquals(result, "USR-20045"); + } + + @Test + public void testIsNotificationsInternallyManaged() throws Exception { + + Property[] properties = new Property[1]; + properties[0] = new Property(); + properties[0].setName(IdentityRecoveryConstants.ConnectorConfig.NOTIFICATION_INTERNALLY_MANAGE); + properties[0].setValue(Boolean.TRUE.toString()); + when(identityGovernanceService.getConfiguration(any(String[].class), eq(TENANT_DOMAIN))) + .thenReturn(properties); + + // Case 1: Empty properties. + assertTrue(Utils.isNotificationsInternallyManaged(TENANT_DOMAIN, new HashMap<>())); + + // Case 2: Properties with MANAGE_NOTIFICATIONS_INTERNALLY_PROPERTY_KEY set to true. + Map props = new HashMap<>(); + props.put(IdentityRecoveryConstants.MANAGE_NOTIFICATIONS_INTERNALLY_PROPERTY_KEY, "true"); + assertTrue(Utils.isNotificationsInternallyManaged(TENANT_DOMAIN, props)); + + // Case 4: Properties without MANAGE_NOTIFICATIONS_INTERNALLY_PROPERTY_KEY. + props.remove(IdentityRecoveryConstants.MANAGE_NOTIFICATIONS_INTERNALLY_PROPERTY_KEY); + assertTrue(Utils.isNotificationsInternallyManaged(TENANT_DOMAIN, props)); + + // Case 5: Invalid boolean value. + props.put(IdentityRecoveryConstants.MANAGE_NOTIFICATIONS_INTERNALLY_PROPERTY_KEY, "invalid"); + assertFalse(Utils.isNotificationsInternallyManaged(TENANT_DOMAIN, props)); + } + + @Test + public void testResolveEventName() { + + // Case 1: SMS channel. + String smsChannel = NotificationChannels.SMS_CHANNEL.getChannelType(); + String expectedSmsEventName = IdentityRecoveryConstants.NOTIFICATION_EVENTNAME_PREFIX + smsChannel + + IdentityRecoveryConstants.NOTIFICATION_EVENTNAME_SUFFIX + + IdentityRecoveryConstants.NOTIFICATION_EVENTNAME_SUFFIX_LOCAL; + assertEquals(Utils.resolveEventName(smsChannel), expectedSmsEventName); + + // Case 2: Other channels. + String otherChannel = "EMAIL"; + assertEquals(Utils.resolveEventName(otherChannel), IdentityEventConstants.Event.TRIGGER_NOTIFICATION); + } + + @Test + public void testValidateEmailUsernameValidEmail() throws IdentityRecoveryClientException { + + mockedStaticIdentityUtil.when(IdentityUtil::isEmailUsernameEnabled).thenReturn(true); + User user = new User(); + user.setUserName("test@example.com"); + user.setTenantDomain("carbon.super"); + + Utils.validateEmailUsername(user); + + // Case 2: Invalid email username. + User user2 = new User(); + user2.setUserName("testuser"); + user2.setTenantDomain(TENANT_DOMAIN); + + try { + Utils.validateEmailUsername(user); + } catch (Exception e) { + assertTrue(e instanceof IdentityRecoveryClientException); + } + } + + @Test + public void testBuildUser() { + + mockedStaticUserCoreUtil.when(() -> UserCoreUtil.removeDomainFromName(USER_NAME)).thenReturn(USER_NAME); + User user = Utils.buildUser(USER_NAME, TENANT_DOMAIN); + assertEquals(user.getUserName(), USER_NAME); + assertEquals(user.getTenantDomain(), TENANT_DOMAIN); + } + + @Test + public void testIsPerUserFunctionalityLockingEnabled() { + + mockedStaticIdentityUtil.when(() -> IdentityUtil.getProperty( + UserFunctionalityMgtConstants.ENABLE_PER_USER_FUNCTIONALITY_LOCKING)).thenReturn("true"); + assertTrue(Utils.isPerUserFunctionalityLockingEnabled()); + } + + @Test + public void testIsDetailedErrorResponseEnabled() { + + mockedStaticIdentityUtil.when(() -> IdentityUtil.getProperty( + IdentityRecoveryConstants.ENABLE_DETAILED_ERROR_RESPONSE)).thenReturn("true"); + assertTrue(Utils.isDetailedErrorResponseEnabled()); + } + + @Test + public void testGetUserId() throws Exception { + + String expectedUserId = "12345-67890-abcde-fghij"; + when(userRealm.getUserStoreManager()).thenReturn(abstractUserStoreManager); + when(abstractUserStoreManager.getUserIDFromUserName(USER_NAME)).thenReturn(expectedUserId); + + String userId = Utils.getUserId(USER_NAME, TENANT_ID); + assertEquals(userId, expectedUserId); + + // Case 2: RealmService is null. + when(identityRecoveryServiceDataHolder.getRealmService()).thenReturn(null); + try { + Utils.getUserId(USER_NAME, TENANT_ID); + fail("Expected IdentityRecoveryServerException was not thrown"); + } catch (IdentityRecoveryServerException e) { + assertEquals(e.getErrorCode(), IdentityRecoveryConstants.ErrorMessages + .ERROR_CODE_FAILED_TO_LOAD_REALM_SERVICE.getCode()); + } + + // Reset RealmService mock. + when(identityRecoveryServiceDataHolder.getRealmService()).thenReturn(realmService); + + // Case 4: UserStoreException when getting UserStoreManager. + when(realmService.getTenantUserRealm(TENANT_ID)).thenThrow(new UserStoreException("Test exception")); + try { + Utils.getUserId(USER_NAME, TENANT_ID); + fail("Expected IdentityRecoveryServerException was not thrown"); + } catch (IdentityRecoveryServerException e) { + assertEquals(e.getErrorCode(), IdentityRecoveryConstants.ErrorMessages + .ERROR_CODE_FAILED_TO_LOAD_REALM_SERVICE.getCode()); + } + + // Reset UserStoreManager mock. + doReturn(userRealm).when(realmService).getTenantUserRealm(TENANT_ID); + when(userRealm.getUserStoreManager()).thenReturn(abstractUserStoreManager); + + // Case 5: UserStoreException when getting user ID + when(abstractUserStoreManager.getUserIDFromUserName(USER_NAME)).thenThrow( + new UserStoreException("Test exception")); + try { + Utils.getUserId(USER_NAME, TENANT_ID); + fail("Expected IdentityRecoveryServerException was not thrown"); + } catch (IdentityRecoveryServerException e) { + assertEquals(e.getErrorCode(), IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_FAILED_TO_UPDATE_USER_CLAIMS.getCode()); + } + } + + @Test + public void testIsSkipRecoveryWithChallengeQuestionsForInsufficientAnswersEnabled() { + + mockedStaticIdentityUtil.when(() -> IdentityUtil.getProperty( + IdentityRecoveryConstants.RECOVERY_QUESTION_PASSWORD_SKIP_ON_INSUFFICIENT_ANSWERS)) + .thenReturn("true"); + assertTrue(Utils.isSkipRecoveryWithChallengeQuestionsForInsufficientAnswersEnabled()); + } + + @Test + public void testIsUseVerifyClaimEnabled() { + + mockedStaticIdentityUtil.when(() ->IdentityUtil.getProperty + (IdentityRecoveryConstants.ConnectorConfig.USE_VERIFY_CLAIM_ON_UPDATE)).thenReturn("true"); + assertTrue(Utils.isUseVerifyClaimEnabled()); + } + + @Test + public void testPublishRecoveryEvent() throws IdentityEventException { + + Map map = new HashMap<>(); + String eventName = "testEvent"; + String confirmationCode = "123456"; + + Utils.publishRecoveryEvent(map, eventName, confirmationCode); + verify(identityEventService).handleEvent(any()); + } + + @Test + public void testGetAccountState() throws UserStoreException { + + String expectedAccountState = "testValue"; + User user = getUser(); + + // Case 1: Existing user. + when(userRealm.getUserStoreManager()).thenReturn(abstractUserStoreManager); + when(abstractUserStoreManager.isExistingUser(user.getUserName())).thenReturn(true); + + Map claimMap = new HashMap<>(); + claimMap.put(IdentityRecoveryConstants.ACCOUNT_STATE_CLAIM_URI, expectedAccountState); + when(abstractUserStoreManager.getUserClaimValues(user.getUserName(), + new String[]{IdentityRecoveryConstants.ACCOUNT_STATE_CLAIM_URI}, "default")) + .thenReturn(claimMap); + String accountState = Utils.getAccountState(user); + assertEquals(accountState, expectedAccountState); + + // Case 2: User doesn't exist in primary user store, secondary user store null. + when(abstractUserStoreManager.isExistingUser(user.getUserName())).thenReturn(false); + when(abstractUserStoreManager.getSecondaryUserStoreManager()).thenReturn(null); + + accountState = Utils.getAccountState(user); + assertEquals(accountState, StringUtils.EMPTY); + } + + @Test + public void testGetAccountStateForUserNameWithoutUserDomain() throws UserStoreException { + + User user = getUser(); + String expectedAccountState = "testValue"; + String userStoreDomainQualifiedUsername = USER_STORE_DOMAIN + UserCoreConstants.DOMAIN_SEPARATOR + USER_NAME; + mockedStaticUserCoreUtil.when(() -> UserCoreUtil.addDomainToName(USER_NAME, USER_STORE_DOMAIN)) + .thenReturn(userStoreDomainQualifiedUsername); + + // Case 1: Existing user. + when(userRealm.getUserStoreManager()).thenReturn(abstractUserStoreManager); + when(abstractUserStoreManager.isExistingUser(user.getUserName())).thenReturn(true); + + Map claimMap = new HashMap<>(); + claimMap.put(IdentityRecoveryConstants.ACCOUNT_STATE_CLAIM_URI, expectedAccountState); + when(abstractUserStoreManager.getUserClaimValues(userStoreDomainQualifiedUsername, + new String[]{IdentityRecoveryConstants.ACCOUNT_STATE_CLAIM_URI}, "default")) + .thenReturn(claimMap); + + String accountState = Utils.getAccountStateForUserNameWithoutUserDomain(user); + assertEquals(accountState, expectedAccountState); + } + + @Test + public void testGenerateRandomPassword() { + + int passwordLength = 10; + char[] result = Utils.generateRandomPassword(passwordLength); + assertEquals(result.length, passwordLength); + } + + @Test + public void testReIssueExistingConfirmationCodeNotificationBasedPasswordRecovery() { + + int recoveryConfirmationTolerancePeriod = 10; + int recoveryCodeExpiryTime = 20; + + UserRecoveryData recoveryData = new UserRecoveryData(getUser(), "12345", + RecoveryScenarios.NOTIFICATION_BASED_PW_RECOVERY, RecoverySteps.UPDATE_PASSWORD); + + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, -5); + Date creationTime = calendar.getTime(); + Timestamp timestamp = new Timestamp(creationTime.getTime()); + + recoveryData.setTimeCreated(timestamp); + + // Case 1: With RECOVERY_CONFIRMATION_CODE_DEFAULT_TOLERANCE, ConfirmationTolerancePeriod = 10. + boolean result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertFalse(result); + + // Case 2: With RECOVERY_CODE_DEFAULT_EXPIRY_TIME, ConfirmationTolerancePeriod = 10. + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.RECOVERY_CONFIRMATION_CODE_TOLERANCE_PERIOD, + String.valueOf(recoveryConfirmationTolerancePeriod)); + result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertFalse(result); + + // Case 3: RecoveryCodeExpiryTime = 20, ConfirmationTolerancePeriod = 10. + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.ConnectorConfig.RECOVERY_CODE_EXPIRY_TIME, + String.valueOf(recoveryCodeExpiryTime)); + result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertTrue(result); + + // Case 4: Invalid value for RECOVERY_CODE_EXPIRY_TIME. + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.ConnectorConfig.RECOVERY_CODE_EXPIRY_TIME, + "invalid"); + result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertFalse(result); + + // Case 5: Invalid value for RECOVERY_CONFIRMATION_CODE_TOLERANCE_PERIOD. + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.RECOVERY_CONFIRMATION_CODE_TOLERANCE_PERIOD, "invalid"); + result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertFalse(result); + } + + @Test + public void testReIssueExistingConfirmationCodeSelfSignupEmail() throws IdentityGovernanceException { + + int selfRegistrationCodeTolerance = 10; + int selfRegistrationCodeExpiryTime = 20; + + UserRecoveryData recoveryData = new UserRecoveryData(getUser(), "12345", + RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.UPDATE_PASSWORD); + + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, -5); + Date creationTime = calendar.getTime(); + Timestamp timestamp = new Timestamp(creationTime.getTime()); + + recoveryData.setTimeCreated(timestamp); + + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.SELF_SIGN_UP_EMAIL_CONFIRMATION_CODE_TOLERANCE_PERIOD, + String.valueOf(selfRegistrationCodeTolerance)); + mockGetRecoveryConfig(IdentityRecoveryConstants.ConnectorConfig + .SELF_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME, String.valueOf(selfRegistrationCodeExpiryTime)); + + boolean result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertTrue(result); + + // Case 2: Invalid value for SELF_SIGN_UP_EMAIL_CONFIRMATION_CODE_TOLERANCE_PERIOD. + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.SELF_SIGN_UP_EMAIL_CONFIRMATION_CODE_TOLERANCE_PERIOD, + "invalid"); + + result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertFalse(result); + + // Case 3: Invalid value for SELF_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME. + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.SELF_SIGN_UP_EMAIL_CONFIRMATION_CODE_TOLERANCE_PERIOD, + String.valueOf(selfRegistrationCodeTolerance)); + mockGetRecoveryConfig(IdentityRecoveryConstants.ConnectorConfig + .SELF_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME, "invalid"); + + result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertFalse(result); + + // Case 4: SELF_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME less than tolerance period. + selfRegistrationCodeTolerance = 10; + selfRegistrationCodeExpiryTime = 5; + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.SELF_SIGN_UP_EMAIL_CONFIRMATION_CODE_TOLERANCE_PERIOD, + String.valueOf(selfRegistrationCodeTolerance)); + mockGetRecoveryConfig(IdentityRecoveryConstants.ConnectorConfig + .SELF_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME, String.valueOf(selfRegistrationCodeExpiryTime)); + + result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertFalse(result); + } + + @Test + public void testReIssueExistingConfirmationCodeSelfSignupSMS() throws IdentityGovernanceException { + + int selfRegistrationCodeTolerance = 10; + int selfRegistrationCodeExpiryTime = 20; + + UserRecoveryData recoveryData = new UserRecoveryData(getUser(), "12345", + RecoveryScenarios.SELF_SIGN_UP, RecoverySteps.UPDATE_PASSWORD); + + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, -5); + Date creationTime = calendar.getTime(); + Timestamp timestamp = new Timestamp(creationTime.getTime()); + + recoveryData.setTimeCreated(timestamp); + + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.SELF_SIGN_UP_SMS_CONFIRMATION_CODE_TOLERANCE_PERIOD, + String.valueOf(selfRegistrationCodeTolerance)); + mockGetRecoveryConfig(IdentityRecoveryConstants.ConnectorConfig + .SELF_REGISTRATION_SMSOTP_VERIFICATION_CODE_EXPIRY_TIME, + String.valueOf(selfRegistrationCodeExpiryTime)); + + boolean result = Utils.reIssueExistingConfirmationCode(recoveryData, "SMS"); + assertTrue(result); + + // Other notification channel. + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.SELF_SIGN_UP_EMAIL_CONFIRMATION_CODE_TOLERANCE_PERIOD, + String.valueOf(5)); + mockGetRecoveryConfig(IdentityRecoveryConstants.ConnectorConfig + .SELF_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME, String.valueOf(selfRegistrationCodeExpiryTime)); + + result = Utils.reIssueExistingConfirmationCode(recoveryData, "OTHER"); + assertFalse(result); + } + + @Test + public void testReIssueExistingConfirmationCodeAskPassword() throws IdentityGovernanceException { + + int askPasswordCodeTolerance = 10; + int askPasswordCodeExpiryTime = 20; + + UserRecoveryData recoveryData = new UserRecoveryData(getUser(), "12345", + RecoveryScenarios.ASK_PASSWORD, RecoverySteps.UPDATE_PASSWORD); + + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, -5); + Date creationTime = calendar.getTime(); + Timestamp timestamp = new Timestamp(creationTime.getTime()); + + recoveryData.setTimeCreated(timestamp); + + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.ASK_PASSWORD_CONFIRMATION_CODE_TOLERANCE_PERIOD, + String.valueOf(askPasswordCodeTolerance)); + mockGetRecoveryConfig(IdentityRecoveryConstants.ConnectorConfig + .ASK_PASSWORD_EXPIRY_TIME, + String.valueOf(askPasswordCodeExpiryTime)); + + boolean result = Utils.reIssueExistingConfirmationCode(recoveryData, "SMS"); + assertTrue(result); + + // Case 3: Invalid value for SELF_REGISTRATION_VERIFICATION_CODE_EXPIRY_TIME. + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.ASK_PASSWORD_CONFIRMATION_CODE_TOLERANCE_PERIOD, + String.valueOf(askPasswordCodeTolerance)); + mockGetRecoveryConfig(IdentityRecoveryConstants.ConnectorConfig + .ASK_PASSWORD_EXPIRY_TIME, "invalid"); + + result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertFalse(result); + + // Case 4: Code expiry time is less than tolerance period. + askPasswordCodeExpiryTime = 5; + mockIdentityUtilsGetProperty(IdentityRecoveryConstants.ASK_PASSWORD_CONFIRMATION_CODE_TOLERANCE_PERIOD, + String.valueOf(askPasswordCodeTolerance)); + mockGetRecoveryConfig(IdentityRecoveryConstants.ConnectorConfig + .ASK_PASSWORD_EXPIRY_TIME, String.valueOf(askPasswordCodeExpiryTime)); + + result = Utils.reIssueExistingConfirmationCode(recoveryData, "EMAIL"); + assertFalse(result); + } + + @Test + public void testHandleAttributeValidationFailureWithValidationResult() { + + // Case 1: Validation result is null. + ValidationResult validationResult = null; + try { + Utils.handleAttributeValidationFailure(validationResult); + } catch (Exception e) { + assertTrue(e instanceof SelfRegistrationClientException); + assertEquals(((SelfRegistrationClientException) e).getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_UNEXPECTED_ERROR_VALIDATING_ATTRIBUTES + .getCode()); + } + + // Case 2: Validation result is not null. + ValidationFailureReason validationFailureReason = new ValidationFailureReason(); + validationFailureReason.setErrorCode("test-code"); + validationFailureReason.setReason("test-reason"); + validationFailureReason.setAuthAttribute("test-auth-attribute"); + + validationResult = new ValidationResult(); + validationResult.setValidationFailureReasons(new ArrayList<>(Arrays.asList(validationFailureReason))); + + try { + Utils.handleAttributeValidationFailure(validationResult); + } catch (Exception e) { + assertTrue(e instanceof SelfRegistrationClientException); + assertEquals(((SelfRegistrationClientException) e).getErrorCode(), + IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_INVALID_USER_ATTRIBUTES_FOR_REGISTRATION + .getCode()); + } + } + + @Test + public void testHandleAttributeValidationFailure() { + + AuthAttributeHandlerException exception = + new AuthAttributeHandlerClientException(ERROR_CODE_AUTH_ATTRIBUTE_HANDLER_NOT_FOUND.getCode(), + ERROR_CODE_AUTH_ATTRIBUTE_HANDLER_NOT_FOUND.getMessage()); + try { + Utils.handleAttributeValidationFailure(exception); + } catch (Exception e) { + assertTrue(e instanceof SelfRegistrationClientException); + } + + // Case 2: AuthAttributeHandlerException exception. + AuthAttributeHandlerException exception1 = + new AuthAttributeHandlerException("test-code", "test-message"); + try { + Utils.handleAttributeValidationFailure(exception1); + } catch (Exception e) { + assertTrue(e instanceof SelfRegistrationException); + } + } + + @Test + public void testGetMultiValuedClaim() throws IdentityEventException, org.wso2.carbon.user.core.UserStoreException { + + User user = getUser(); + String claimValue = "value1,value2,value3"; + List expectedClaimList = Arrays.asList("value1", "value2", "value3"); + when(userStoreManager.getUserClaimValue(any(), anyString(), any())) + .thenReturn(claimValue); + + List result = Utils.getMultiValuedClaim(userStoreManager, user, "testClaim"); + assertEquals(expectedClaimList, result); + + // Case 2: Throw user store exception when retrieving user claim value. + when(userStoreManager.getUserClaimValue(any(), anyString(), any())) + .thenThrow(new org.wso2.carbon.user.core.UserStoreException()); + try { + Utils.getMultiValuedClaim(userStoreManager, user, "testClaim"); + } catch (Exception e) { + assertTrue(e instanceof IdentityEventException); + } + } + + @Test + public void testIsMultiEmailsAndMobileNumbersPerUserEnabled() { + + mockedStaticIdentityUtil.when(() -> IdentityUtil.getProperty(IdentityRecoveryConstants.ConnectorConfig + .SUPPORT_MULTI_EMAILS_AND_MOBILE_NUMBERS_PER_USER)) + .thenReturn("true"); + boolean result = Utils.isMultiEmailsAndMobileNumbersPerUserEnabled(); + assertEquals(result, true); + } + + private static User getUser() { + + User user = new User(); + user.setUserName(USER_NAME); + user.setTenantDomain(TENANT_DOMAIN); + user.setUserStoreDomain(USER_STORE_DOMAIN); + return user; + } + + private static void mockIdentityUtilsGetProperty(String key, String value) { + + mockedStaticIdentityUtil.when(() -> IdentityUtil.getProperty(key)).thenReturn(value); + } + + private void mockGetRecoveryConfig(String key, String value) throws IdentityGovernanceException { + + Property property = new Property(); + property.setName(key); + property.setValue(value); + Property[] properties = new Property[]{property}; + + when(identityGovernanceService.getConfiguration(eq(new String[]{key}), eq(TENANT_DOMAIN))) + .thenReturn(properties); + } + + private static String getUserStoreQualifiedUsername(String username, String userStoreDomainName) { + + return userStoreDomainName + UserCoreConstants.DOMAIN_SEPARATOR + username; + } } diff --git a/components/org.wso2.carbon.identity.recovery/src/test/resources/testng.xml b/components/org.wso2.carbon.identity.recovery/src/test/resources/testng.xml index 1810202392..c6712ecab9 100644 --- a/components/org.wso2.carbon.identity.recovery/src/test/resources/testng.xml +++ b/components/org.wso2.carbon.identity.recovery/src/test/resources/testng.xml @@ -29,6 +29,10 @@ + + + +