diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/OperationComparator.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/OperationComparator.java index 02e37bf27631..11d932aabeed 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/OperationComparator.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/OperationComparator.java @@ -54,11 +54,34 @@ public static boolean compare(AllowedOperation allowedOp, PerformableOperation p for (String allowedPath : allowedOp.getPaths()) { if (performableOp.getPath().equals(allowedPath) || - performableOperationBasePath.equals(allowedPath)) { + performableOperationBasePath.equals(allowedPath) || + performableOp.getPath().startsWith(allowedPath) || + matchesPathTemplate(allowedPath, performableOp.getPath())) { return true; } } return false; } + + // Allowed operation paths can contain placeholders (e.g /user/claims[uri={claim_uri}]). + private static boolean matchesPathTemplate(String allowedPathTemplate, String performablePath) { + + if (allowedPathTemplate == null || performablePath == null || !allowedPathTemplate.contains("{")) { + return false; + } + + int templateVariableStart = allowedPathTemplate.indexOf('{'); + int templateVariableEnd = allowedPathTemplate.indexOf('}', templateVariableStart); + if (templateVariableStart == -1 || templateVariableEnd == -1 || templateVariableEnd < templateVariableStart) { + return false; + } + + String prefix = allowedPathTemplate.substring(0, templateVariableStart); + String suffix = allowedPathTemplate.substring(templateVariableEnd + 1); + + return performablePath.startsWith(prefix) && + performablePath.endsWith(suffix) && + performablePath.length() > (prefix.length() + suffix.length()); + } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/test/java/org/wso2/carbon/identity/action/execution/util/OperationComparatorTest.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/test/java/org/wso2/carbon/identity/action/execution/util/OperationComparatorTest.java index 2438af9d7e6a..29a69ced1b3d 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/test/java/org/wso2/carbon/identity/action/execution/util/OperationComparatorTest.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/test/java/org/wso2/carbon/identity/action/execution/util/OperationComparatorTest.java @@ -102,4 +102,19 @@ public void testCompareNonMatchingPath() { assertFalse(OperationComparator.compare(allowedOp, performableOp)); } + + @Test + public void testCompareMatchingPathForTemplateBasedClaimFilterPath() { + + AllowedOperation allowedOp = new AllowedOperation(); + allowedOp.setOp(Operation.ADD); + allowedOp.setPaths(Arrays.asList("/user/claims[uri={claim_uri}]")); + + PerformableOperation performableOp = new PerformableOperation(); + performableOp.setOp(Operation.ADD); + performableOp.setPath("/user/claims[uri=http://wso2.org/claims/country]"); + performableOp.setValue("Sri Lanka"); + + assertTrue(OperationComparator.compare(allowedOp, performableOp)); + } } diff --git a/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/execution/PreUpdateProfileRequestBuilder.java b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/execution/PreUpdateProfileRequestBuilder.java index c352f1c52353..4403f11143d5 100644 --- a/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/execution/PreUpdateProfileRequestBuilder.java +++ b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/execution/PreUpdateProfileRequestBuilder.java @@ -23,8 +23,10 @@ import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequest; import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequestContext; import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.AllowedOperation; import org.wso2.carbon.identity.action.execution.api.model.Event; import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Operation; import org.wso2.carbon.identity.action.execution.api.model.Organization; import org.wso2.carbon.identity.action.execution.api.model.Tenant; import org.wso2.carbon.identity.action.execution.api.model.User; @@ -54,7 +56,9 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Function; import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * Pre Update Profile Action Request Builder. @@ -63,6 +67,7 @@ public class PreUpdateProfileRequestBuilder implements ActionExecutionRequestBui private static final String ROLE_CLAIM_URI = "http://wso2.org/claims/roles"; private static final String GROUP_CLAIM_URI = "http://wso2.org/claims/groups"; + public static final String USER_CLAIMS_FILTERED_PATH_TEMPLATE = "/user/claims[uri={claim_uri}]"; @Override public ActionType getSupportedActionType() { @@ -78,13 +83,37 @@ public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContex UserActionContext userActionContext = flowContext.getValue(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, UserActionContext.class); PreUpdateProfileAction preUpdateProfileAction = (PreUpdateProfileAction) actionExecutionContext.getAction(); + Event event = getEvent(userActionContext, preUpdateProfileAction); return new ActionExecutionRequest.Builder() .actionType(getSupportedActionType()) - .event(getEvent(userActionContext, preUpdateProfileAction)) + .event(event) + .allowedOperations(getAllowedOperations()) .build(); } + private List getAllowedOperations() { + + List allowedPaths = new ArrayList<>(); + allowedPaths.add(USER_CLAIMS_FILTERED_PATH_TEMPLATE); + + List allowedOperations = new ArrayList<>(); + + allowedOperations.add(createAllowedOperation(Operation.ADD, allowedPaths)); + allowedOperations.add(createAllowedOperation(Operation.REMOVE, allowedPaths)); + allowedOperations.add(createAllowedOperation(Operation.REPLACE, allowedPaths)); + + return allowedOperations; + } + + private AllowedOperation createAllowedOperation(Operation op, List paths) { + + AllowedOperation operation = new AllowedOperation(); + operation.setOp(op); + operation.setPaths(new ArrayList<>(paths)); + return operation; + } + private Event getEvent(UserActionContext userActionContext, PreUpdateProfileAction preUpdateProfileAction) throws ActionExecutionRequestBuilderException { @@ -93,10 +122,9 @@ private Event getEvent(UserActionContext userActionContext, PreUpdateProfileActi eventBuilder.action(PreUpdateProfileEvent.Action.UPDATE); eventBuilder.request(getPreUpdateProfileRequest(userActionContext)); eventBuilder.tenant(getTenant()); - eventBuilder.user(getUser(userActionContext, preUpdateProfileAction)); - String tenantDomain = IdentityContext.getThreadLocalIdentityContext().getTenantDomain(); - UniqueIDUserStoreManager userStoreManager = RequestBuilderUtil.getUserStoreManager(tenantDomain); + UniqueIDUserStoreManager userStoreManager = getUserStoreManager(); + eventBuilder.user(getUser(userActionContext, preUpdateProfileAction, userStoreManager)); eventBuilder.userStore(getUserStore(userActionContext.getUserActionRequestDTO(), userStoreManager)); eventBuilder.organization(getOrganization()); @@ -180,8 +208,8 @@ private Organization getOrganization() { .build(); } - private User getUser(UserActionContext userActionContext, PreUpdateProfileAction preUpdateProfileAction) - throws ActionExecutionRequestBuilderException { + private User getUser(UserActionContext userActionContext, PreUpdateProfileAction preUpdateProfileAction, + UniqueIDUserStoreManager userStoreManager) throws ActionExecutionRequestBuilderException { UserActionRequestDTO userActionRequestDTO = userActionContext.getUserActionRequestDTO(); List userClaimsToSetInEvent = preUpdateProfileAction.getAttributes(); @@ -193,9 +221,8 @@ private User getUser(UserActionContext userActionContext, PreUpdateProfileAction return userBuilder.build(); } - String tenantDomain = IdentityContext.getThreadLocalIdentityContext().getTenantDomain(); - Map claimValues = RequestBuilderUtil.getClaimValues(resolveOrgBoundUserId(userActionRequestDTO), - userClaimsToSetInEvent, tenantDomain); + Map claimValues = getClaimValues(resolveOrgBoundUserId(userActionRequestDTO), + userClaimsToSetInEvent, userStoreManager); String multiAttributeSeparator = FrameworkUtils.getMultiAttributeSeparator(); setClaimsInUserBuilder(userBuilder, claimValues, userActionRequestDTO.getClaims(), multiAttributeSeparator); @@ -204,6 +231,23 @@ private User getUser(UserActionContext userActionContext, PreUpdateProfileAction return userBuilder.build(); } + private Map getClaimValues(String userId, List requestedClaims, + UniqueIDUserStoreManager userStoreManager) + throws ActionExecutionRequestBuilderException { + + try { + Map claimValues = userStoreManager.getUserClaimValuesWithID(userId, + requestedClaims.toArray(new String[0]), UserCoreConstants.DEFAULT_PROFILE); + + // Filter out the extra claims that are not requested. + return requestedClaims.stream() + .filter(claimValues::containsKey) + .collect(Collectors.toMap(Function.identity(), claimValues::get)); + } catch (org.wso2.carbon.user.core.UserStoreException e) { + throw new ActionExecutionRequestBuilderException("Failed to retrieve user claims from user store.", e); + } + } + private void setClaimsInUserBuilder(User.Builder userBuilder, Map claimValues, Map updatingUserClaimsInRequest, String multiAttributeSeparator) throws ActionExecutionRequestBuilderException { @@ -217,13 +261,37 @@ private void setClaimsInUserBuilder(User.Builder userBuilder, Map entry : updatingUserClaimsInRequest.entrySet()) { + String claimKey = entry.getKey(); + if (isRoleOrGroupClaim(claimKey) || claimValues.containsKey(claimKey) && + StringUtils.isNotBlank(claimValues.get(claimKey))) { + continue; + } + + Object updatingClaimValue = entry.getValue(); + UpdatingUserClaim claim; + if (isMultiValuedClaim(claimKey)) { + if (!(updatingClaimValue instanceof String[])) { + throw new ActionExecutionRequestBuilderException( + "Invalid claim value format for multi-valued claim: " + claimKey + + " Only String[] types are expected."); + } + claim = new UpdatingUserClaim(claimKey, new String[0], (String[]) updatingClaimValue); + } else { + claim = new UpdatingUserClaim(claimKey, null, String.valueOf(updatingClaimValue)); + } + userClaimValuesToSetInEvent.add(claim); } } @@ -293,6 +361,12 @@ private UserStore getUserStore(UserActionRequestDTO userActionRequestDTO, Unique return new UserStore(userStoreDomain); } + private UniqueIDUserStoreManager getUserStoreManager() throws ActionExecutionRequestBuilderException { + + String tenantDomain = IdentityContext.getThreadLocalIdentityContext().getTenantDomain(); + return RequestBuilderUtil.getUserStoreManager(tenantDomain); + } + private boolean isMultiValuedClaim(String claimUri) throws ActionExecutionRequestBuilderException { ClaimMetadataManagementService claimMetadataManagementService = diff --git a/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/execution/PreUpdateProfileResponseProcessor.java b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/execution/PreUpdateProfileResponseProcessor.java index 75d2d57cb027..fb1ca22ea0a6 100644 --- a/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/execution/PreUpdateProfileResponseProcessor.java +++ b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/execution/PreUpdateProfileResponseProcessor.java @@ -18,21 +18,66 @@ package org.wso2.carbon.identity.user.pre.update.profile.action.internal.execution; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.action.execution.api.constant.ActionExecutionLogConstants; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionResponseContext; import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationSuccessResponse; import org.wso2.carbon.identity.action.execution.api.model.ActionType; import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; import org.wso2.carbon.identity.action.execution.api.model.Success; import org.wso2.carbon.identity.action.execution.api.model.SuccessStatus; import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionResponseProcessor; +import org.wso2.carbon.identity.action.execution.api.util.RequestBuilderUtil; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; +import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; +import org.wso2.carbon.identity.claim.metadata.mgt.model.Claim; +import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; +import org.wso2.carbon.identity.claim.metadata.mgt.util.ClaimConstants; +import org.wso2.carbon.identity.core.context.IdentityContext; +import org.wso2.carbon.identity.user.pre.update.profile.action.internal.component.PreUpdateProfileActionServiceComponentHolder; +import org.wso2.carbon.identity.user.pre.update.profile.action.internal.model.PreUpdateProfileEvent; +import org.wso2.carbon.identity.user.pre.update.profile.action.internal.model.ProfileOperationExecutionResult; +import org.wso2.carbon.user.core.UniqueIDUserStoreManager; +import org.wso2.carbon.user.core.UserCoreConstants; +import org.wso2.carbon.utils.DiagnosticLog; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * Pre Update Profile Action Response Processor. */ public class PreUpdateProfileResponseProcessor implements ActionExecutionResponseProcessor { + private static final Log LOG = LogFactory.getLog(PreUpdateProfileResponseProcessor.class); + private static final String GROUP_CLAIM_URI = "http://wso2.org/claims/groups"; + private static final String ROLE_CLAIM_URI = "http://wso2.org/claims/roles"; + private static final String SCIM_SCHEMA_URI_PREFIX = "urn:ietf:params:scim:schemas"; + private static final String URI = "uri"; + private static final String VALUE = "value"; + private static final String USER_CLAIMS_TO_BE_ADDED = "userClaimsToBeAdded"; + private static final String USER_CLAIMS_TO_BE_MODIFIED = "userClaimsToBeModified"; + private static final String USER_CLAIMS_TO_BE_REMOVED = "userClaimsToBeRemoved"; + private static final String MULTI_VALUED_CLAIMS_TO_BE_ADDED = "multiValuedClaimsToBeAdded"; + private static final String MULTI_VALUED_CLAIMS_TO_BE_REMOVED = "multiValuedClaimsToBeRemoved"; + private static final String USER_CLAIMS_FILTER_PATH_PREFIX = "/user/claims["; + @Override public ActionType getSupportedActionType() { @@ -40,10 +85,663 @@ public ActionType getSupportedActionType() { } @Override - public ActionExecutionStatus processSuccessResponse(FlowContext flowContext, - ActionExecutionResponseContext responseContext) + public ActionExecutionStatus processSuccessResponse( + FlowContext flowContext, ActionExecutionResponseContext responseContext) throws ActionExecutionResponseProcessorException { + List operationsToPerform = responseContext.getActionInvocationResponse().getOperations(); + Map userClaimsToBeAdded = new HashMap<>(); + Map userClaimsToBeModified = new HashMap<>(); + Map userClaimsToBeRemoved = new HashMap<>(); + Map> simpleMultiValuedClaimsToBeAdded = new HashMap<>(); + Map> simpleMultiValuedClaimsToBeRemoved = new HashMap<>(); + List operationExecutionResultList = new ArrayList<>(); + + if (operationsToPerform != null && !operationsToPerform.isEmpty()) { + UniqueIDUserStoreManager userStoreManager = getUserStoreManager(); + for (PerformableOperation operation : operationsToPerform) { + switch (operation.getOp()) { + case ADD: + operationExecutionResultList.add(handleAddOperation(operation, responseContext, + userClaimsToBeAdded, userClaimsToBeModified, simpleMultiValuedClaimsToBeAdded, + userStoreManager)); + break; + case REPLACE: + operationExecutionResultList.add(handleReplaceOperation(operation, responseContext, + userClaimsToBeModified, simpleMultiValuedClaimsToBeRemoved, + simpleMultiValuedClaimsToBeAdded, userStoreManager)); + break; + case REMOVE: + operationExecutionResultList.add(handleRemoveOperation(operation, responseContext, + userClaimsToBeModified, userClaimsToBeRemoved)); + break; + default: + break; + } + } + } + + logOperationExecutionResults(getSupportedActionType(), operationExecutionResultList); + + flowContext.add(USER_CLAIMS_TO_BE_ADDED, userClaimsToBeAdded); + flowContext.add(USER_CLAIMS_TO_BE_MODIFIED, userClaimsToBeModified); + flowContext.add(USER_CLAIMS_TO_BE_REMOVED, userClaimsToBeRemoved); + flowContext.add(MULTI_VALUED_CLAIMS_TO_BE_ADDED, simpleMultiValuedClaimsToBeAdded); + flowContext.add(MULTI_VALUED_CLAIMS_TO_BE_REMOVED, simpleMultiValuedClaimsToBeRemoved); + return new SuccessStatus.Builder().setResponseContext(flowContext.getContextData()).build(); } + + private ProfileOperationExecutionResult handleAddOperation( + PerformableOperation operation, + ActionExecutionResponseContext responseContext, + Map userClaimsToBeAdded, + Map userClaimsToBeModified, + Map> simpleMultiValuedClaimsToBeAdded, + UniqueIDUserStoreManager userStoreManager) { + + try { + populateAddOperationResult(operation, responseContext, userClaimsToBeAdded, userClaimsToBeModified, + simpleMultiValuedClaimsToBeAdded, userStoreManager); + return new ProfileOperationExecutionResult(operation, ProfileOperationExecutionResult.Status.SUCCESS, + "Operation applied."); + } catch (ActionExecutionResponseProcessorException e) { + return new ProfileOperationExecutionResult(operation, ProfileOperationExecutionResult.Status.FAILURE, + e.getMessage()); + } + } + + private ProfileOperationExecutionResult handleReplaceOperation( + PerformableOperation operation, + ActionExecutionResponseContext responseContext, + Map userClaimsToBeModified, + Map> simpleMultiValuedClaimsToBeRemoved, + Map> simpleMultiValuedClaimsToBeAdded, + UniqueIDUserStoreManager userStoreManager) { + + try { + populateModifyOperationResult(operation, responseContext, userClaimsToBeModified, + simpleMultiValuedClaimsToBeRemoved, simpleMultiValuedClaimsToBeAdded, userStoreManager); + return new ProfileOperationExecutionResult(operation, ProfileOperationExecutionResult.Status.SUCCESS, + "Operation applied."); + } catch (ActionExecutionResponseProcessorException e) { + return new ProfileOperationExecutionResult(operation, ProfileOperationExecutionResult.Status.FAILURE, + e.getMessage()); + } + } + + private ProfileOperationExecutionResult handleRemoveOperation( + PerformableOperation operation, + ActionExecutionResponseContext responseContext, + Map userClaimsToBeModified, + Map userClaimsToBeRemoved) { + + try { + populateRemoveOperationResult(operation, responseContext, userClaimsToBeModified, userClaimsToBeRemoved); + return new ProfileOperationExecutionResult(operation, ProfileOperationExecutionResult.Status.SUCCESS, + "Operation applied."); + } catch (ActionExecutionResponseProcessorException e) { + return new ProfileOperationExecutionResult(operation, ProfileOperationExecutionResult.Status.FAILURE, + e.getMessage()); + } + } + + private void populateAddOperationResult(PerformableOperation operation, + ActionExecutionResponseContext + responseContext, + Map userClaimsToBeAdded, + Map userClaimsToBeModified, + Map> simpleMultiValuedClaimsToBeAdded, + UniqueIDUserStoreManager userStoreManager) + throws ActionExecutionResponseProcessorException { + + String userId = responseContext.getActionEvent().getUser().getId(); + PreUpdateProfileEvent.FlowInitiatorType initiatorType = ((PreUpdateProfileEvent) responseContext + .getActionEvent()).getInitiatorType(); + + String claimUri; + String path = operation.getPath(); + + // Determine claim URI: from path (path-based format) or from value map. + if (isClaimPathFormat(path)) { + claimUri = getClaimUriFromPath(path); + } else if (operation.getValue() instanceof LinkedHashMap) { + LinkedHashMap valueMap = (LinkedHashMap) operation.getValue(); + claimUri = Optional.ofNullable(valueMap.get(URI)) + .filter(value -> value instanceof String) + .map(value -> (String) value) + .orElseThrow(() -> new ActionExecutionResponseProcessorException("Missing or wrong format for " + + "claim uri in operation")); + } else { + throw new ActionExecutionResponseProcessorException("Operation path does not contain claim URI and " + + "operation value is not a map"); + } + + Optional localClaim = isLocalClaim(claimUri); + validateGroupAndRoleClaims(claimUri); + validateFlowInitiatorClaims(claimUri, localClaim); + validateSCIMLevelAttributes(claimUri, operation.getOp(), operation.getValue()); + + if (!isMultiValuedClaim(localClaim)) { + // Extract single claim value from String directly or from value map. + String claimValue; + if (operation.getValue() instanceof String) { + claimValue = (String) operation.getValue(); + } else if (operation.getValue() instanceof LinkedHashMap) { + LinkedHashMap valueMap = (LinkedHashMap) operation.getValue(); + claimValue = Optional.ofNullable(valueMap.get(VALUE)) + .filter(value -> value instanceof String) + .map(value -> (String) value) + .orElseThrow( + () -> new ActionExecutionResponseProcessorException("Missing or wrong format for " + + "single valued claim in operation")); + } else { + throw new ActionExecutionResponseProcessorException("Missing or wrong format for " + + "claim value in add operation"); + } + + Map userClaimValues = getUserClaimValues(userId, claimUri, userStoreManager); + // Validate if the claim value already exists for the user + if (operation.getOp() == Operation.ADD && userClaimValues.get(claimUri) != null && + !claimValue.trim().isEmpty()) { + userClaimsToBeModified.put(claimUri, claimValue.trim()); + } else { + userClaimsToBeAdded.put(claimUri, claimValue.trim()); + } + } else { + // Extract multi-valued claim value from List directly or from value map. + List claimValue; + if (operation.getValue() instanceof LinkedHashMap) { + LinkedHashMap valueMap = (LinkedHashMap) operation.getValue(); + claimValue = Optional.ofNullable(valueMap.get(VALUE)) + .filter(value -> value instanceof List) + .map(value -> (List) value) + .filter(list -> list.stream().allMatch(e -> e instanceof String)) + .map(list -> (List) list) + .orElseThrow(() -> new ActionExecutionResponseProcessorException( + "Missing or wrong format for multi valued claim in operation")); + } else if (operation.getValue() instanceof List) { + List rawList = (List) operation.getValue(); + if (!rawList.stream().allMatch(e -> e instanceof String)) { + throw new ActionExecutionResponseProcessorException( + "Missing or wrong format for multi valued claim in operation"); + } + List typedList = (List) operation.getValue(); + claimValue = typedList; + } else { + throw new ActionExecutionResponseProcessorException( + "Missing or wrong format for multi valued claim in add operation"); + } + populateMultivaluedClaimsForAddOperation(localClaim, claimValue, userId, userClaimsToBeModified, + simpleMultiValuedClaimsToBeAdded, initiatorType, userStoreManager); + } + } + + private void populateModifyOperationResult(PerformableOperation operation, + ActionExecutionResponseContext + responseContext, + Map userClaimsToBeModified, + Map> simpleMultiValuedClaimsToBeRemoved, + Map> simpleMultiValuedClaimsToBeAdded, + UniqueIDUserStoreManager userStoreManager) + throws ActionExecutionResponseProcessorException { + + String userId = responseContext.getActionEvent().getUser().getId(); + PreUpdateProfileEvent.FlowInitiatorType initiatorType = ((PreUpdateProfileEvent) responseContext + .getActionEvent()).getInitiatorType(); + + String path = operation.getPath(); + String claimUri = getClaimUriFromPath(path); + + Optional localClaim = isLocalClaim(claimUri); + validateGroupAndRoleClaims(claimUri); + validateFlowInitiatorClaims(claimUri, localClaim); + validateSCIMLevelAttributes(claimUri, operation.getOp(), operation.getValue()); + String separator = FrameworkUtils.getMultiAttributeSeparator(); + + if (!isMultiValuedClaim(localClaim)) { + // For single valued claims, value is directly the new value string. + String claimValue; + if (operation.getValue() instanceof String) { + claimValue = (String) operation.getValue(); + } else if (operation.getValue() instanceof LinkedHashMap) { + LinkedHashMap valueMap = (LinkedHashMap) operation.getValue(); + claimValue = Optional.ofNullable(valueMap.get(VALUE)) + .filter(value -> value instanceof String) + .map(value -> (String) value) + .orElseThrow( + () -> new ActionExecutionResponseProcessorException("Missing or wrong format for " + + "claim value in operation")); + } else { + throw new ActionExecutionResponseProcessorException("Missing or wrong format for " + + "claim value in replace operation"); + } + userClaimsToBeModified.put(claimUri, claimValue.trim()); + } else { + populateMultiValuedClaimsForReplaceOperation(operation, initiatorType, userClaimsToBeModified, + simpleMultiValuedClaimsToBeAdded, simpleMultiValuedClaimsToBeRemoved, userId, claimUri, separator, + userStoreManager); + } + } + + private void populateRemoveOperationResult(PerformableOperation operation, + ActionExecutionResponseContext + responseContext, + Map userClaimsToBeModified, + Map userClaimsToBeRemoved) + throws ActionExecutionResponseProcessorException { + + String path = operation.getPath(); + PreUpdateProfileEvent.FlowInitiatorType initiatorType = ((PreUpdateProfileEvent) responseContext + .getActionEvent()).getInitiatorType(); + + // Extract the claim URI and optionally the specific value to remove for multivalued claims. + String claimUri = getClaimUriFromPath(path); + + Optional localClaim = isLocalClaim(claimUri); + validateGroupAndRoleClaims(claimUri); + validateSCIMLevelAttributes(claimUri, operation.getOp(), operation.getValue()); + + if (!isMultiValuedClaim(localClaim)) { + userClaimsToBeRemoved.put(claimUri, ""); + userClaimsToBeModified.remove(claimUri); + } else { + if (operation.getValue() != null) { + throw new ActionExecutionResponseProcessorException( + "Remove specific value from a multivalued claim is not supported."); + } + populateMultiValuedClaimsForRemoveOperation(claimUri, userClaimsToBeRemoved); + } + } + + private void populateMultivaluedClaimsForAddOperation(Optional localClaim, List claimValue, + String userId, + Map userClaimsToBeModified, + Map> simpleMultiValuedClaimsToBeAdded, + PreUpdateProfileEvent.FlowInitiatorType initiatorType, + UniqueIDUserStoreManager userStoreManager) + throws ActionExecutionResponseProcessorException { + + String claimUri = localClaim.get().getClaimURI(); + String separator = FrameworkUtils.getMultiAttributeSeparator(); + Map userClaimValues = getUserClaimValues(userId, claimUri, userStoreManager); + List filteredValues = getFilteredModifyingClaimValues(userClaimValues, claimUri, claimValue, + separator); + if (initiatorType == PreUpdateProfileEvent.FlowInitiatorType.ADMIN || + initiatorType == PreUpdateProfileEvent.FlowInitiatorType.APPLICATION) { + simpleMultiValuedClaimsToBeAdded.put(claimUri, filteredValues); + } else if (initiatorType == PreUpdateProfileEvent.FlowInitiatorType.USER) { + String addingClaimValue = (userClaimValues.get(claimUri) == null) ? String.join(separator, filteredValues) + : userClaimValues.get(claimUri) + separator + String.join(separator, filteredValues); + userClaimsToBeModified.put(claimUri, addingClaimValue); + } + } + + private void populateMultiValuedClaimsForReplaceOperation(PerformableOperation operation, + PreUpdateProfileEvent.FlowInitiatorType initiatorType, + Map userClaimsToBeModified, + Map> + simpleMultiValuedClaimsToBeAdded, + Map> + simpleMultiValuedClaimsToBeRemoved, + String userId, String claimUri, String separator, + UniqueIDUserStoreManager userStoreManager) + throws ActionExecutionResponseProcessorException { + + // For multivalued replace, value can be a map with "value" (list) and optional + // "oldValue" (string), or directly a list of strings for replacing the entire array. + List claimValue; + String oldValueName = null; + + if (operation.getValue() instanceof LinkedHashMap) { + LinkedHashMap valueMap = (LinkedHashMap) operation.getValue(); + claimValue = Optional.ofNullable(valueMap.get(VALUE)) + .filter(value -> value instanceof List) + .map(value -> (List) value) + .filter(list -> list.stream().allMatch(e -> e instanceof String)) + .map(list -> (List) list) + .orElseThrow(() -> new ActionExecutionResponseProcessorException( + "Missing or wrong format for multi valued claim in operation")); + + oldValueName = Optional.ofNullable(valueMap.get("oldValue")) + .filter(value -> value instanceof String) + .map(value -> (String) value) + .orElse(null); + } else if (operation.getValue() instanceof List) { + List rawList = (List) operation.getValue(); + if (!rawList.stream().allMatch(e -> e instanceof String)) { + throw new ActionExecutionResponseProcessorException( + "Missing or wrong format for multi valued claim in replace operation"); + } + claimValue = (List) operation.getValue(); + } else { + throw new ActionExecutionResponseProcessorException( + "Missing or wrong format for multi valued claim in replace operation"); + } + + if (oldValueName == null) { + // Replacing the entire array: calculate deltas to avoid changing unaffected values. + Map userClaimValues = getUserClaimValues(userId, claimUri, userStoreManager); + List existingValues = new ArrayList<>(); + if (userClaimValues.get(claimUri) != null && !userClaimValues.get(claimUri).isEmpty()) { + existingValues.addAll(Arrays.asList(userClaimValues.get(claimUri).split(Pattern.quote(separator)))); + } + + if (claimValue.size() < existingValues.size()) { + throw new ActionExecutionResponseProcessorException( + "To replace a multivalued attribute, the full array must be passed to " + + "prevent accidental deletion of existing values."); + } + + List addedValues = claimValue.stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .filter(s -> !existingValues.contains(s)) + .collect(Collectors.toList()); + + List removedValues = existingValues.stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .filter(s -> !claimValue.contains(s)) + .collect(Collectors.toList()); + + if (initiatorType == PreUpdateProfileEvent.FlowInitiatorType.ADMIN || + initiatorType == PreUpdateProfileEvent.FlowInitiatorType.APPLICATION) { + if (!removedValues.isEmpty()) { + simpleMultiValuedClaimsToBeRemoved.put(claimUri, removedValues); + } + if (!addedValues.isEmpty()) { + simpleMultiValuedClaimsToBeAdded.put(claimUri, addedValues); + } + } else if (initiatorType == PreUpdateProfileEvent.FlowInitiatorType.USER) { + String modifyingClaimValue = claimValue.stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.joining(separator)); + userClaimsToBeModified.put(claimUri, modifyingClaimValue); + } + } + } + + private void populateMultiValuedClaimsForRemoveOperation(String claimUri, + Map userClaimsToBeRemoved) { + + userClaimsToBeRemoved.put(claimUri, ""); + } + + private Optional isLocalClaim(String claimUri) throws ActionExecutionResponseProcessorException { + + ClaimMetadataManagementService claimMetadataManagementService = PreUpdateProfileActionServiceComponentHolder + .getInstance().getClaimManagementService(); + String tenantDomain = IdentityContext.getThreadLocalIdentityContext().getTenantDomain(); + try { + Optional localClaim = claimMetadataManagementService.getLocalClaim(claimUri, tenantDomain); + if (!localClaim.isPresent()) { + throw new ActionExecutionResponseProcessorException("Local claim not found for claim uri: " + claimUri); + } + return localClaim; + } catch (ClaimMetadataException e) { + throw new ActionExecutionResponseProcessorException("Error while retrieving localClaim for claim uri: " + + claimUri, e); + } + } + + private boolean isMultiValuedClaim(Optional localClaim) { + + return localClaim.isPresent() && + Boolean.parseBoolean(localClaim.get().getClaimProperty(ClaimConstants.MULTI_VALUED_PROPERTY)); + } + + private String getClaimUriFromPath(String path) + throws ActionExecutionResponseProcessorException { + + if (path.startsWith(USER_CLAIMS_FILTER_PATH_PREFIX)) { + int uriKeyStart = path.indexOf("[uri="); + if (uriKeyStart == -1) { + throw new ActionExecutionResponseProcessorException("Invalid filter path format: " + path); + } + + int valueStart = uriKeyStart + "[uri=".length(); + if (valueStart >= path.length()) { + throw new ActionExecutionResponseProcessorException("Invalid filter path format: " + path); + } + + char quoteChar = path.charAt(valueStart); + if (quoteChar == '\'' || quoteChar == '"') { + throw new ActionExecutionResponseProcessorException("Invalid filter path format: " + path); + } + + int valueEnd = path.indexOf(']', valueStart); + if (valueEnd == -1) { + throw new ActionExecutionResponseProcessorException("Invalid filter path format: " + path); + } + if (valueEnd + 1 != path.length()) { + throw new ActionExecutionResponseProcessorException("Invalid filter path format: " + path); + } + return path.substring(valueStart, valueEnd); + } + + throw new ActionExecutionResponseProcessorException("Invalid path format: " + path); + } + + private boolean isClaimPathFormat(String path) { + + return path != null && path.startsWith(USER_CLAIMS_FILTER_PATH_PREFIX); + } + + private UniqueIDUserStoreManager getUserStoreManager() throws ActionExecutionResponseProcessorException { + + String tenantDomain = IdentityContext.getThreadLocalIdentityContext().getTenantDomain(); + try { + return RequestBuilderUtil.getUserStoreManager(tenantDomain); + } catch (ActionExecutionRequestBuilderException e) { + throw new ActionExecutionResponseProcessorException( + "Error while loading user store manager for tenant: " + tenantDomain, e); + } + } + + private Map getUserClaimValues(String userId, String claimUri, + UniqueIDUserStoreManager userStoreManager) throws + ActionExecutionResponseProcessorException { + + try { + return userStoreManager.getUserClaimValuesWithID(userId, new String[] { claimUri }, + UserCoreConstants.DEFAULT_PROFILE); + } catch (org.wso2.carbon.user.core.UserStoreException e) { + throw new ActionExecutionResponseProcessorException("Failed to retrieve user claims from user store.", e); + } + } + + private List getFilteredModifyingClaimValues(Map userClaimValues, String claimUri, + List claimValue, String separator) { + + List userValues = Optional.ofNullable(userClaimValues.get(claimUri)) + .map(value -> Arrays.asList(value.split(separator))) + .orElse(Collections.emptyList()); + String[] claimValuesList = filterDuplicatedValues(claimValue); + + return Arrays.stream(claimValuesList) + .filter(value -> !userValues.contains(value)) + .collect(Collectors.toList()); + } + + private String[] filterDuplicatedValues(List claimValue) { + + return claimValue.stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .distinct() + .toArray(String[]::new); + } + + private void validateFlowInitiatorClaims(String claimUri, Optional localClaim) + throws ActionExecutionResponseProcessorException { + + if (localClaim.get().getFlowInitiator()) { + throw new ActionExecutionResponseProcessorException(claimUri + " is not allowed to modified."); + } + } + + private void validateGroupAndRoleClaims(String claimUri) + throws ActionExecutionResponseProcessorException { + + if (claimUri.equals(GROUP_CLAIM_URI) || claimUri.equals(ROLE_CLAIM_URI)) { + throw new ActionExecutionResponseProcessorException("Groups/Roles are not allowed to modified: " + + claimUri); + } + } + + private void validateSCIMLevelAttributes(String claimUri, Operation op, Object value) + throws ActionExecutionResponseProcessorException { + + List scimClaims = convertLocalToSCIMDialect(claimUri); + if (scimClaims.isEmpty()) { + return; + } + + boolean isReadOnly = false; + boolean isRequired = false; + boolean isSingleValued = false; + + // multiple scim mapping + for (Claim scimClaim : scimClaims) { + if (Boolean.parseBoolean(scimClaim.getClaimProperty(ClaimConstants.READ_ONLY_PROPERTY))) { + isReadOnly = true; + } + if (Boolean.parseBoolean(scimClaim.getClaimProperty(ClaimConstants.REQUIRED_PROPERTY))) { + isRequired = true; + } + if (!Boolean.parseBoolean(scimClaim.getClaimProperty(ClaimConstants.MULTI_VALUED_PROPERTY))) { + isSingleValued = true; + } + } + + if (isReadOnly) { + throw new ActionExecutionResponseProcessorException( + "Cannot modify read-only SCIM attribute mapped to local claim: " + claimUri); + } + + if (isRequired) { + if (op == Operation.REMOVE) { + throw new ActionExecutionResponseProcessorException( + "Cannot remove required SCIM attribute mapped to local claim: " + claimUri); + } + if (op == Operation.REPLACE || op == Operation.ADD) { + boolean isEmpty = false; + if (value == null) { + isEmpty = true; + } else if (value instanceof String && ((String) value).trim().isEmpty()) { + isEmpty = true; + } else if (value instanceof List && ((List) value).isEmpty()) { + isEmpty = true; + } else if (value instanceof LinkedHashMap) { + Object val = ((LinkedHashMap) value).get(VALUE); + if (val == null || (val instanceof String && ((String) val).trim().isEmpty()) || + (val instanceof List && ((List) val).isEmpty())) { + isEmpty = true; + } + } + if (isEmpty) { + throw new ActionExecutionResponseProcessorException( + "Cannot set empty value to required SCIM attribute mapped to local claim: " + claimUri); + } + } + } + + if (isSingleValued) { + boolean hasMultipleValues = false; + if (value instanceof List && ((List) value).size() > 1) { + hasMultipleValues = true; + } else if (value instanceof LinkedHashMap) { + Object val = ((LinkedHashMap) value).get(VALUE); + if (val instanceof List && ((List) val).size() > 1) { + hasMultipleValues = true; + } + } + if (hasMultipleValues) { + throw new ActionExecutionResponseProcessorException( + "Cannot set multiple values to single-valued SCIM attribute mapped to local claim: " + + claimUri); + } + } + } + + /** + * Converts claims in local WSO2 dialect to SCIM dialect. + * + * @param claimUri The local claim URI. + * @return A list of SCIM dialect claims. + * @throws ActionExecutionResponseProcessorException If an error occurs during the conversion. + */ + private static List convertLocalToSCIMDialect(String claimUri) throws + ActionExecutionResponseProcessorException { + + if (claimUri == null || claimUri.trim().isEmpty()) { + return Collections.emptyList(); + } + + ClaimMetadataManagementService claimMetadataManagementService = PreUpdateProfileActionServiceComponentHolder + .getInstance().getClaimManagementService(); + String tenantDomain = IdentityContext.getThreadLocalIdentityContext().getTenantDomain(); + try { + List mappedExternalClaims = claimMetadataManagementService + .getMappedExternalClaimsForLocalClaim(claimUri, tenantDomain); + if (mappedExternalClaims == null || mappedExternalClaims.isEmpty()) { + return Collections.emptyList(); + } + + return mappedExternalClaims.stream() + .filter(claim -> claim.getClaimDialectURI() != null + && claim.getClaimDialectURI().contains(SCIM_SCHEMA_URI_PREFIX)) + .collect(Collectors.toList()); + + } catch (ClaimMetadataException e) { + throw new ActionExecutionResponseProcessorException( + "Error while retrieving mapped external claims for claim uri: " + claimUri, e); + } + } + + private void logOperationExecutionResults(ActionType actionType, + List operationExecutionResultList) { + + if (isDiagnosticLoggingEnabled()) { + List> operationDetailsList = new ArrayList<>(); + operationExecutionResultList.forEach( + performedOperation -> operationDetailsList.add(Map.of( + "operation", performedOperation.getOperation().getOp() + " path: " + + performedOperation.getOperation().getPath(), + "status", performedOperation.getStatus().toString(), + "message", performedOperation.getMessage() + ))); + + DiagnosticLog.DiagnosticLogBuilder diagnosticLogBuilder = new DiagnosticLog.DiagnosticLogBuilder( + ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, + ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_RESPONSE); + diagnosticLogBuilder + .inputParam("executedOperations", + operationDetailsList.isEmpty() ? "empty" : operationDetailsList) + .resultMessage("Allowed operations are executed for " + actionType.getDisplayName() + " action.") + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS) + .build(); + LoggerUtils.triggerDiagnosticLogEvent(diagnosticLogBuilder); + } + + if (LOG.isDebugEnabled()) { + LOG.debug(String.format("Processed response for action type: %s. Results of operations performed: %s", + actionType, operationExecutionResultList)); + } + } + + private boolean isDiagnosticLoggingEnabled() { + + try { + return LoggerUtils.isDiagnosticLogsEnabled(); + } catch (Throwable t) { + if (LOG.isDebugEnabled()) { + LOG.debug("Skipping diagnostic log due to runtime context unavailability.", t); + } + return false; + } + } } diff --git a/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/model/ProfileOperationExecutionResult.java b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/model/ProfileOperationExecutionResult.java new file mode 100644 index 000000000000..be0066cdb2b0 --- /dev/null +++ b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/main/java/org/wso2/carbon/identity/user/pre/update/profile/action/internal/model/ProfileOperationExecutionResult.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, 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.user.pre.update.profile.action.internal.model; + +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; + +/** + * This class represents the result of a profile operation execution. + */ +public class ProfileOperationExecutionResult { + + private final PerformableOperation operation; + private final Status status; + private final String message; + + public ProfileOperationExecutionResult(PerformableOperation operation, Status status, String message) { + + this.operation = operation; + this.status = status; + this.message = message; + } + + public PerformableOperation getOperation() { + + return operation; + } + + public Status getStatus() { + + return status; + } + + public String getMessage() { + + return message; + } + + @Override + public String toString() { + + return "ProfileOperationExecutionResult{" + + "operation=" + operation + + ", status=" + status + + ", message='" + message + '\'' + + '}'; + } + + /** + * Enum to represent profile operation execution status. + */ + public enum Status { + SUCCESS, + FAILURE + } +} diff --git a/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/test/java/org/wso2/carbon/identity/user/pre/update/profile/action/execution/PreUpdateProfileRequestBuilderTest.java b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/test/java/org/wso2/carbon/identity/user/pre/update/profile/action/execution/PreUpdateProfileRequestBuilderTest.java index 211edb5b1d8b..74d177cc5018 100644 --- a/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/test/java/org/wso2/carbon/identity/user/pre/update/profile/action/execution/PreUpdateProfileRequestBuilderTest.java +++ b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/test/java/org/wso2/carbon/identity/user/pre/update/profile/action/execution/PreUpdateProfileRequestBuilderTest.java @@ -30,7 +30,9 @@ import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequest; import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequestContext; import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.AllowedOperation; import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Operation; import org.wso2.carbon.identity.action.execution.api.model.Organization; import org.wso2.carbon.identity.action.execution.api.model.UserClaim; import org.wso2.carbon.identity.action.execution.internal.component.ActionExecutionServiceComponentHolder; @@ -64,6 +66,8 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import static org.mockito.ArgumentMatchers.any; @@ -316,6 +320,163 @@ public void testBuildActionExecutionRequestWhenUpdatingClaimListAndClaimListToSh assertEquals(event.getUser().getRoles().size(), 0); } + @Test + public void testBuildActionExecutionRequestContainsAllowedOperations() throws Exception { + + IdentityContext.getThreadLocalIdentityContext() + .enterFlow(buildMockedFlow(Flow.Name.PROFILE_UPDATE, Flow.InitiatingPersona.USER)); + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, + getMockUserActionContextWithoutUpdatingClaims()); + + ActionExecutionRequestContext actionExecutionRequestContext = + ActionExecutionRequestContext.create(getMockPreUpdateProfileActionWithNoClaimsConfiguredToShare()); + + ActionExecutionRequest request = preUpdateProfileRequestBuilder.buildActionExecutionRequest( + flowContext, actionExecutionRequestContext); + + assertNotNull(request.getAllowedOperations()); + assertEquals(request.getAllowedOperations().size(), 3); + + assertAllowedOperationExists(request, Operation.ADD); + assertAllowedOperationExists(request, Operation.REMOVE); + assertAllowedOperationExists(request, Operation.REPLACE); + } + + @Test(expectedExceptions = ActionExecutionRequestBuilderException.class, + expectedExceptionsMessageRegExp = "Unknown flow\\.") + public void testBuildActionExecutionRequestFailureWhenFlowIsNotPresent() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, + getMockUserActionContextWithoutUpdatingClaims()); + + ActionExecutionRequestContext actionExecutionRequestContext = + ActionExecutionRequestContext.create(getMockPreUpdateProfileActionWithNoClaimsConfiguredToShare()); + + preUpdateProfileRequestBuilder.buildActionExecutionRequest(flowContext, actionExecutionRequestContext); + } + + @Test(expectedExceptions = ActionExecutionRequestBuilderException.class, + expectedExceptionsMessageRegExp = "Root organization information is not available in Identity Context\\.") + public void testBuildActionExecutionRequestFailureWhenRootOrganizationIsNotPresent() throws Exception { + + IdentityContext.destroyCurrentContext(); + IdentityContext.getThreadLocalIdentityContext().enterFlow( + buildMockedFlow(Flow.Name.PROFILE_UPDATE, Flow.InitiatingPersona.USER)); + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, + getMockUserActionContextWithoutUpdatingClaims()); + + ActionExecutionRequestContext actionExecutionRequestContext = + ActionExecutionRequestContext.create(getMockPreUpdateProfileActionWithNoClaimsConfiguredToShare()); + + preUpdateProfileRequestBuilder.buildActionExecutionRequest(flowContext, actionExecutionRequestContext); + } + + @Test(expectedExceptions = ActionExecutionRequestBuilderException.class, + expectedExceptionsMessageRegExp = "Unknown user claim value format\\. Only String and String\\[] " + + "types expected\\.") + public void testBuildActionExecutionRequestFailureWhenUpdatingClaimHasUnsupportedType() throws Exception { + + IdentityContext.getThreadLocalIdentityContext() + .enterFlow(buildMockedFlow(Flow.Name.PROFILE_UPDATE, Flow.InitiatingPersona.USER)); + + UserActionRequestDTO userActionRequestDTO = mock(UserActionRequestDTO.class); + Map claims = new HashMap<>(); + claims.put(CLAIM1.getClaimURI(), 10); + when(userActionRequestDTO.getUserId()).thenReturn(USER_ID); + when(userActionRequestDTO.getUserStoreDomain()).thenReturn(USER_STORE_DOMAIN); + when(userActionRequestDTO.getClaims()).thenReturn(claims); + when(userActionRequestDTO.getResidentOrganization()).thenReturn(userResidentOrganization); + UserActionContext userActionContext = mock(UserActionContext.class); + when(userActionContext.getUserActionRequestDTO()).thenReturn(userActionRequestDTO); + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, userActionContext); + + ActionExecutionRequestContext actionExecutionRequestContext = + ActionExecutionRequestContext.create(getMockPreUpdateProfileActionWithNoClaimsConfiguredToShare()); + + preUpdateProfileRequestBuilder.buildActionExecutionRequest(flowContext, actionExecutionRequestContext); + } + + @Test(expectedExceptions = ActionExecutionRequestBuilderException.class, + expectedExceptionsMessageRegExp = "Invalid claim value format for multi-valued claim: .*") + public void testBuildActionExecutionRequestFailureWhenMultiValuedClaimHasInvalidUpdatingType() throws Exception { + + IdentityContext.getThreadLocalIdentityContext() + .enterFlow(buildMockedFlow(Flow.Name.PROFILE_UPDATE, Flow.InitiatingPersona.USER)); + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, new UserActionContext( + new UserActionRequestDTO.Builder() + .userId(USER_ID) + .userStoreDomain(USER_STORE_DOMAIN) + .addClaim(CLAIM2.getClaimURI(), "not-an-array") + .residentOrganization(userResidentOrganization) + .build())); + + ActionExecutionRequestContext actionExecutionRequestContext = + ActionExecutionRequestContext.create(new PreUpdateProfileAction.ResponseBuilder() + .attributes(Arrays.asList(CLAIM2.getClaimURI())) + .build()); + + preUpdateProfileRequestBuilder.buildActionExecutionRequest(flowContext, actionExecutionRequestContext); + } + + @Test(expectedExceptions = ActionExecutionRequestBuilderException.class, + expectedExceptionsMessageRegExp = "Claim not found for claim URI: .*") + public void testBuildActionExecutionRequestFailureWhenClaimMetadataNotFound() throws Exception { + + IdentityContext.getThreadLocalIdentityContext() + .enterFlow(buildMockedFlow(Flow.Name.PROFILE_UPDATE, Flow.InitiatingPersona.USER)); + + String missingClaim = "http://wso2.org/claims/missingClaim"; + when(claimMetadataManagementService.getLocalClaim(missingClaim, TENANT_DOMAIN)) + .thenReturn(Optional.empty()); + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, new UserActionContext( + new UserActionRequestDTO.Builder() + .userId(USER_ID) + .userStoreDomain(USER_STORE_DOMAIN) + .addClaim(missingClaim, new String[] {"value1"}) + .residentOrganization(userResidentOrganization) + .build())); + + ActionExecutionRequestContext actionExecutionRequestContext = + ActionExecutionRequestContext.create(new PreUpdateProfileAction.ResponseBuilder() + .attributes(Arrays.asList(missingClaim)) + .build()); + + preUpdateProfileRequestBuilder.buildActionExecutionRequest(flowContext, actionExecutionRequestContext); + } + + @Test + public void testBuildActionExecutionRequestWhenUserStoreDomainNotProvidedInRequest() + throws ActionExecutionRequestBuilderException { + + IdentityContext.getThreadLocalIdentityContext() + .enterFlow(buildMockedFlow(Flow.Name.PROFILE_UPDATE, Flow.InitiatingPersona.USER)); + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, + getMockUserActionContextWithoutUserStoreDomain()); + + ActionExecutionRequestContext actionExecutionRequestContext = + ActionExecutionRequestContext.create(getMockPreUpdateProfileActionWithNoClaimsConfiguredToShare()); + + ActionExecutionRequest request = preUpdateProfileRequestBuilder.buildActionExecutionRequest( + flowContext, actionExecutionRequestContext); + + assertNotNull(request); + PreUpdateProfileEvent event = (PreUpdateProfileEvent) request.getEvent(); + assertEquals(event.getUserStore().getName(), USER_STORE_DOMAIN); + } + // Failure tests. // These tests are updates PreUpdateProfileActionServiceComponentHolder with corrupted mock services. // Thus, these tests are always expected to execute after success tests and they are dependent on each other. @@ -528,6 +689,27 @@ private static UserActionContext getMockUserActionContextWithoutUpdatingClaims() .build()); } + private static UserActionContext getMockUserActionContextWithoutUserStoreDomain() { + + return new UserActionContext(new UserActionRequestDTO.Builder() + .userId(USER_ID) + .build()); + } + + private static void assertAllowedOperationExists(ActionExecutionRequest request, Operation expectedOperation) { + + boolean isFound = false; + for (AllowedOperation allowedOperation : request.getAllowedOperations()) { + if (expectedOperation.equals(allowedOperation.getOp())) { + isFound = true; + assertEquals(allowedOperation.getPaths().size(), 1); + assertEquals(allowedOperation.getPaths().get(0), + PreUpdateProfileRequestBuilder.USER_CLAIMS_FILTERED_PATH_TEMPLATE); + } + } + assertTrue(isFound, "Allowed operation not found for: " + expectedOperation); + } + private static PreUpdateProfileAction getMockPreUpdateProfileActionWithClaimsConfiguredToShare() { return new PreUpdateProfileAction.ResponseBuilder() diff --git a/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/test/java/org/wso2/carbon/identity/user/pre/update/profile/action/execution/PreUpdateProfileResponseProcessorTest.java b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/test/java/org/wso2/carbon/identity/user/pre/update/profile/action/execution/PreUpdateProfileResponseProcessorTest.java index 7deb1a05fb31..b52474e8c050 100644 --- a/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/test/java/org/wso2/carbon/identity/user/pre/update/profile/action/execution/PreUpdateProfileResponseProcessorTest.java +++ b/components/user-mgt/org.wso2.carbon.identity.user.pre.update.profile.action/src/test/java/org/wso2/carbon/identity/user/pre/update/profile/action/execution/PreUpdateProfileResponseProcessorTest.java @@ -18,7 +18,10 @@ package org.wso2.carbon.identity.user.pre.update.profile.action.execution; +import org.mockito.MockedStatic; +import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionResponseContext; @@ -32,21 +35,62 @@ import org.wso2.carbon.identity.action.execution.api.model.Event; import org.wso2.carbon.identity.action.execution.api.model.Failure; import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; import org.wso2.carbon.identity.action.execution.api.model.ResponseData; import org.wso2.carbon.identity.action.execution.api.model.Success; +import org.wso2.carbon.identity.action.execution.api.model.User; +import org.wso2.carbon.identity.action.execution.api.util.RequestBuilderUtil; +import org.wso2.carbon.identity.application.authentication.framework.util.FrameworkUtils; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; +import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; +import org.wso2.carbon.identity.claim.metadata.mgt.model.Claim; +import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; +import org.wso2.carbon.identity.claim.metadata.mgt.util.ClaimConstants; +import org.wso2.carbon.identity.common.testng.WithCarbonHome; +import org.wso2.carbon.identity.core.context.IdentityContext; import org.wso2.carbon.identity.user.action.api.model.UserActionContext; +import org.wso2.carbon.identity.user.pre.update.profile.action.internal.component.PreUpdateProfileActionServiceComponentHolder; import org.wso2.carbon.identity.user.pre.update.profile.action.internal.execution.PreUpdateProfileResponseProcessor; +import org.wso2.carbon.identity.user.pre.update.profile.action.internal.model.PreUpdateProfileEvent; +import org.wso2.carbon.user.core.UniqueIDUserStoreManager; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +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.expectThrows; /** * User Pre Update Password Action Response Processor Test. */ +@WithCarbonHome public class PreUpdateProfileResponseProcessorTest { + private static final String USER_ID = "user1"; + private static final String SINGLE_CLAIM_URI = "http://wso2.org/claims/country"; + private static final String MULTI_CLAIM_URI = "http://wso2.org/claims/mobileNumbers"; + private static final String SCIM_SCHEMA_URI_PREFIX = "urn:ietf:params:scim:schemas"; + private PreUpdateProfileResponseProcessor preUpdateProfileResponseProcessor; + private ClaimMetadataManagementService claimMetadataManagementService; + private UniqueIDUserStoreManager userStoreManager; + private MockedStatic requestBuilderUtil; + private MockedStatic frameworkUtils; + private MockedStatic loggerUtils; @BeforeClass void setUp() { @@ -54,6 +98,51 @@ void setUp() { preUpdateProfileResponseProcessor = new PreUpdateProfileResponseProcessor(); } + @BeforeMethod + void setUpMethod() throws Exception { + + IdentityContext.destroyCurrentContext(); + + claimMetadataManagementService = mock(ClaimMetadataManagementService.class); + userStoreManager = mock(UniqueIDUserStoreManager.class); + requestBuilderUtil = mockStatic(RequestBuilderUtil.class); + frameworkUtils = mockStatic(FrameworkUtils.class); + loggerUtils = mockStatic(LoggerUtils.class); + frameworkUtils.when(FrameworkUtils::getMultiAttributeSeparator).thenReturn(","); + loggerUtils.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); + requestBuilderUtil.when(() -> RequestBuilderUtil.getUserStoreManager(any())) + .thenReturn(userStoreManager); + + when(claimMetadataManagementService.getMappedExternalClaimsForLocalClaim(any(), any())) + .thenReturn(Collections.emptyList()); + when(claimMetadataManagementService.getLocalClaim(any(), any())) + .thenAnswer(invocation -> { + String claimUri = invocation.getArgument(0); + if (MULTI_CLAIM_URI.equals(claimUri)) { + return Optional.of(mockLocalClaim(MULTI_CLAIM_URI, true, false)); + } + return Optional.of(mockLocalClaim(SINGLE_CLAIM_URI, false, false)); + }); + + PreUpdateProfileActionServiceComponentHolder.getInstance() + .setClaimManagementService(claimMetadataManagementService); + } + + @AfterMethod + void tearDownMethod() { + + if (requestBuilderUtil != null) { + requestBuilderUtil.close(); + } + if (frameworkUtils != null) { + frameworkUtils.close(); + } + if (loggerUtils != null) { + loggerUtils.close(); + } + IdentityContext.destroyCurrentContext(); + } + @Test public void testGetSupportedActionType() { @@ -128,4 +217,880 @@ public void testProcessErrorResponse() throws ActionExecutionResponseProcessorEx assertEquals(resultStatus.getResponse().getErrorMessage(), errorResponse.getErrorMessage()); assertEquals(resultStatus.getResponse().getErrorDescription(), errorResponse.getErrorDescription()); } + + @Test + public void testProcessSuccessResponseWhenSingleValuedAddOperation() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(userStoreManager.getUserClaimValuesWithID(anyString(), any(String[].class), anyString())) + .thenReturn(new HashMap<>()); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue("Alex"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertNotNull(resultStatus); + Map addedClaims = (Map) resultStatus.getResponseContext() + .get("userClaimsToBeAdded"); + assertEquals(addedClaims.get(SINGLE_CLAIM_URI), "Alex"); + } + + @Test + public void testProcessSuccessResponseWhenOperationHasCorrectUriPathFormat() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(userStoreManager.getUserClaimValuesWithID(anyString(), any(String[].class), anyString())) + .thenReturn(new HashMap<>()); + + String validPath = "/user/claims[uri=http://wso2.org/claims/country]"; + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath(validPath); + operation.setValue("Sri Lanka"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertNotNull(resultStatus); + Map addedClaims = (Map) resultStatus.getResponseContext() + .get("userClaimsToBeAdded"); + assertEquals(addedClaims.get(SINGLE_CLAIM_URI), "Sri Lanka"); + } + + @Test + public void testProcessSuccessResponseWhenSingleValuedReplaceOperation() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(userStoreManager.getUserClaimValuesWithID(anyString(), any(String[].class), anyString())) + .thenReturn(new HashMap<>()); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue("AlexModified"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertNotNull(resultStatus); + Map modifiedClaims = (Map) resultStatus.getResponseContext() + .get("userClaimsToBeModified"); + assertEquals(modifiedClaims.get(SINGLE_CLAIM_URI), "AlexModified"); + } + + @Test + public void testProcessSuccessResponseWhenSingleValuedRemoveOperation() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REMOVE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertNotNull(resultStatus); + Map removedClaims = (Map) resultStatus.getResponseContext() + .get("userClaimsToBeRemoved"); + assertEquals(removedClaims.get(SINGLE_CLAIM_URI), ""); + } + + @Test + public void testProcessSuccessResponseWhenMultiValuedReplaceOperation() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + Map existingClaims = new HashMap<>(); + existingClaims.put(MULTI_CLAIM_URI, "0771234567,0717654329"); + when(userStoreManager.getUserClaimValuesWithID(anyString(), any(String[].class), anyString())) + .thenReturn(existingClaims); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + MULTI_CLAIM_URI + "]"); + operation.setValue(Arrays.asList("0771234567", "0709998888")); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, + ActionExecutionResponseContext.create(buildEvent(PreUpdateProfileEvent.FlowInitiatorType.USER), + successResponse)); + + Map modifiedClaims = (Map) resultStatus.getResponseContext() + .get("userClaimsToBeModified"); + assertEquals(modifiedClaims.get(MULTI_CLAIM_URI), "0771234567,0709998888"); + } + + @Test + public void testProcessSuccessResponseWhenMultiValuedReplaceOperationByAdmin() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + Map existingClaims = new HashMap<>(); + existingClaims.put(MULTI_CLAIM_URI, "0771234567,0717654329"); + when(userStoreManager.getUserClaimValuesWithID(anyString(), any(String[].class), anyString())) + .thenReturn(existingClaims); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + MULTI_CLAIM_URI + "]"); + operation.setValue(Arrays.asList("0771234567", "0709998888")); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, + ActionExecutionResponseContext.create(buildEvent(PreUpdateProfileEvent.FlowInitiatorType.ADMIN), + successResponse)); + + Map addedClaims = (Map) resultStatus.getResponseContext() + .get("multiValuedClaimsToBeAdded"); + Map removedClaims = (Map) resultStatus.getResponseContext() + .get("multiValuedClaimsToBeRemoved"); + + assertEquals(addedClaims.get(MULTI_CLAIM_URI), Collections.singletonList("0709998888")); + assertEquals(removedClaims.get(MULTI_CLAIM_URI), Collections.singletonList("0717654329")); + } + + @Test + public void testProcessSuccessResponseWhenMultiValuedRemoveOperation() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REMOVE); + operation.setPath("/user/claims[uri=" + MULTI_CLAIM_URI + "]"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertNotNull(resultStatus); + Map removedClaims = (Map) resultStatus.getResponseContext() + .get("userClaimsToBeRemoved"); + assertEquals(removedClaims.get(MULTI_CLAIM_URI), ""); + } + + @Test + public void testProcessSuccessResponseWhenMultiValuedRemoveOperationWithSpecificValue() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REMOVE); + operation.setPath("/user/claims[uri=" + MULTI_CLAIM_URI + "]"); + operation.setValue("0717654329"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertNotNull(resultStatus); + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeRemoved")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenInvalidOperationFormat() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/invalid"); + operation.setValue("value"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertNotNull(resultStatus); + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeAdded")).isEmpty()); + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeRemoved")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenSingleValuedAddOperationWithPathHasQuotedUri() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/user/claims[uri=\"" + SINGLE_CLAIM_URI + "\"]"); + operation.setValue("Alex"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeAdded")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenSingleValuedAddOperationWithPathHasTrailingChars() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]abc"); + operation.setValue("Alex"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeAdded")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenSingleValuedAddOperationWithValueMap() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(userStoreManager.getUserClaimValuesWithID(anyString(), any(String[].class), anyString())) + .thenReturn(new HashMap<>()); + + LinkedHashMap valueMap = new LinkedHashMap<>(); + valueMap.put("uri", SINGLE_CLAIM_URI); + valueMap.put("value", "Colombo"); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/invalid"); + operation.setValue(valueMap); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + Map addedClaims = (Map) resultStatus.getResponseContext() + .get("userClaimsToBeAdded"); + assertEquals(addedClaims.get(SINGLE_CLAIM_URI), "Colombo"); + } + + @Test + public void testProcessSuccessResponseWhenSingleValuedAddOperationWithValueMapWithoutUri() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + LinkedHashMap valueMap = new LinkedHashMap<>(); + valueMap.put("value", "Colombo"); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/invalid"); + operation.setValue(valueMap); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeAdded")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenGroupClaimIsModified() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + String groupClaim = "http://wso2.org/claims/groups"; + LocalClaim localClaim = mockLocalClaim(groupClaim, true, false); + when(claimMetadataManagementService.getLocalClaim(eq(groupClaim), any())) + .thenReturn(Optional.of(localClaim)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + groupClaim + "]"); + operation.setValue(Arrays.asList("g1", "g2")); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + assertTrue(((Map) resultStatus.getResponseContext().get("multiValuedClaimsToBeAdded")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenFlowInitiatorClaimIsModified() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + String flowInitiatorClaim = "http://wso2.org/claims/flowInitiator"; + LocalClaim localClaim = mockLocalClaim(flowInitiatorClaim, false, true); + when(claimMetadataManagementService.getLocalClaim(eq(flowInitiatorClaim), any())) + .thenReturn(Optional.of(localClaim)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + flowInitiatorClaim + "]"); + operation.setValue("true"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenMultiValuedReplaceOperationWithReducedArray() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + Map existingClaims = new HashMap<>(); + existingClaims.put(MULTI_CLAIM_URI, "0771234567,0717654329"); + when(userStoreManager.getUserClaimValuesWithID(anyString(), any(String[].class), anyString())) + .thenReturn(existingClaims); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + MULTI_CLAIM_URI + "]"); + operation.setValue(Collections.singletonList("0771234567")); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, + ActionExecutionResponseContext.create(buildEvent(PreUpdateProfileEvent.FlowInitiatorType.ADMIN), + successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("multiValuedClaimsToBeAdded")).isEmpty()); + assertTrue(((Map) resultStatus.getResponseContext().get("multiValuedClaimsToBeRemoved")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenScimReadOnlyAttributeReplaceOperation() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(claimMetadataManagementService.getMappedExternalClaimsForLocalClaim(eq(SINGLE_CLAIM_URI), any())) + .thenReturn(Collections.singletonList(mockScimClaim(true, false, false))); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue("Updated"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenScimRequiredAttributeRemoveOperation() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(claimMetadataManagementService.getMappedExternalClaimsForLocalClaim(eq(SINGLE_CLAIM_URI), any())) + .thenReturn(Collections.singletonList(mockScimClaim(false, true, false))); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REMOVE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeRemoved")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenScimRequiredAttributeReplaceWithEmptyValue() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(claimMetadataManagementService.getMappedExternalClaimsForLocalClaim(eq(SINGLE_CLAIM_URI), any())) + .thenReturn(Collections.singletonList(mockScimClaim(false, true, false))); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue(" "); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenScimSingleValuedAttributeReplaceWithMultipleValues() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(claimMetadataManagementService.getMappedExternalClaimsForLocalClaim(eq(SINGLE_CLAIM_URI), any())) + .thenReturn(Collections.singletonList(mockScimClaim(false, false, false))); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue(Arrays.asList("value1", "value2")); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenSingleValuedReplaceOperationWithValueMapWithoutValue() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + LinkedHashMap valueMap = new LinkedHashMap<>(); + valueMap.put("uri", SINGLE_CLAIM_URI); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue(valueMap); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenMultiValuedReplaceOperationWithInvalidListValueType() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + LinkedHashMap valueMap = new LinkedHashMap<>(); + valueMap.put("value", Arrays.asList("0771234567", 123)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + MULTI_CLAIM_URI + "]"); + operation.setValue(valueMap); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("multiValuedClaimsToBeAdded")).isEmpty()); + assertTrue(((Map) resultStatus.getResponseContext().get("multiValuedClaimsToBeRemoved")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenMultiValuedAddOperationWithInvalidListValueType() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + LinkedHashMap valueMap = new LinkedHashMap<>(); + valueMap.put("value", Arrays.asList("0771234567", 123)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/user/claims[uri=" + MULTI_CLAIM_URI + "]"); + operation.setValue(valueMap); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("multiValuedClaimsToBeAdded")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenScimRequiredAttributeAddWithEmptyMapValue() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(claimMetadataManagementService.getMappedExternalClaimsForLocalClaim(eq(SINGLE_CLAIM_URI), any())) + .thenReturn(Collections.singletonList(mockScimClaim(false, true, false))); + + LinkedHashMap valueMap = new LinkedHashMap<>(); + valueMap.put("value", " "); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue(valueMap); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeAdded")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenScimSingleValuedAttributeReplaceWithMapListValues() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(claimMetadataManagementService.getMappedExternalClaimsForLocalClaim(eq(SINGLE_CLAIM_URI), any())) + .thenReturn(Collections.singletonList(mockScimClaim(false, false, false))); + + LinkedHashMap valueMap = new LinkedHashMap<>(); + valueMap.put("value", Arrays.asList("value1", "value2")); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue(valueMap); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenScimMappingsAreNotScimDialect() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + Claim nonScimClaim = new Claim("custom:dialect", SINGLE_CLAIM_URI); + when(claimMetadataManagementService.getMappedExternalClaimsForLocalClaim(eq(SINGLE_CLAIM_URI), any())) + .thenReturn(Collections.singletonList(nonScimClaim)); + when(userStoreManager.getUserClaimValuesWithID(anyString(), any(String[].class), anyString())) + .thenReturn(new HashMap<>()); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue("NoScimValidation"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + Map addedClaims = (Map) resultStatus.getResponseContext() + .get("userClaimsToBeAdded"); + assertEquals(addedClaims.get(SINGLE_CLAIM_URI), "NoScimValidation"); + } + + @Test + public void testProcessSuccessResponseFailureWhenUserStoreManagerLoadingFails() { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + requestBuilderUtil.when(() -> RequestBuilderUtil.getUserStoreManager(any())) + .thenThrow(new org.wso2.carbon.identity.action.execution.api.exception + .ActionExecutionRequestBuilderException("User store manager error")); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(new PerformableOperation())) + .responseData(mock(ResponseData.class)) + .build(); + + expectThrows(ActionExecutionResponseProcessorException.class, + () -> preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse))); + } + + @Test + public void testProcessSuccessResponseWhenPathMissingClosingBracket() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI); + operation.setValue("Alex"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenLocalClaimLookupThrowsException() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(claimMetadataManagementService.getLocalClaim(eq(SINGLE_CLAIM_URI), any())) + .thenThrow(new ClaimMetadataException("Local claim lookup error")); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue("Alex"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeAdded")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenScimClaimMappingLookupThrowsException() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(claimMetadataManagementService.getMappedExternalClaimsForLocalClaim(eq(SINGLE_CLAIM_URI), any())) + .thenThrow(new ClaimMetadataException("SCIM mapping lookup error")); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REPLACE); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue("Alex"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeModified")).isEmpty()); + } + + @Test + public void testProcessSuccessResponseWhenGetUserClaimValuesThrowsException() throws Exception { + + FlowContext flowContext = FlowContext.create(); + flowContext.add(UserActionContext.USER_ACTION_CONTEXT_REFERENCE_KEY, mock(UserActionContext.class)); + + when(userStoreManager.getUserClaimValuesWithID(anyString(), any(String[].class), anyString())) + .thenThrow(new org.wso2.carbon.user.core.UserStoreException("user store read error")); + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.ADD); + operation.setPath("/user/claims[uri=" + SINGLE_CLAIM_URI + "]"); + operation.setValue("Alex"); + + ActionInvocationSuccessResponse successResponse = new ActionInvocationSuccessResponse.Builder() + .actionStatus(ActionInvocationResponse.Status.SUCCESS) + .operations(Collections.singletonList(operation)) + .responseData(mock(ResponseData.class)) + .build(); + + ActionExecutionStatus resultStatus = preUpdateProfileResponseProcessor.processSuccessResponse( + flowContext, ActionExecutionResponseContext.create(buildEvent(), successResponse)); + + assertTrue(((Map) resultStatus.getResponseContext().get("userClaimsToBeAdded")).isEmpty()); + } + + private static PreUpdateProfileEvent buildEvent() { + + return buildEvent(PreUpdateProfileEvent.FlowInitiatorType.ADMIN); + } + + private static PreUpdateProfileEvent buildEvent(PreUpdateProfileEvent.FlowInitiatorType initiatorType) { + + return new PreUpdateProfileEvent.Builder() + .initiatorType(initiatorType) + .action(PreUpdateProfileEvent.Action.UPDATE) + .user(new User.Builder(USER_ID).build()) + .build(); + } + + private static LocalClaim mockLocalClaim(String claimUri, boolean isMultiValued, boolean flowInitiatorClaim) { + + LocalClaim localClaim = mock(LocalClaim.class); + when(localClaim.getClaimURI()).thenReturn(claimUri); + when(localClaim.getFlowInitiator()).thenReturn(flowInitiatorClaim); + when(localClaim.getClaimProperty(ClaimConstants.MULTI_VALUED_PROPERTY)) + .thenReturn(String.valueOf(isMultiValued)); + return localClaim; + } + + private static Claim mockScimClaim(boolean readOnly, boolean required, boolean multiValued) { + + Map claimProperties = new HashMap<>(); + claimProperties.put(ClaimConstants.READ_ONLY_PROPERTY, String.valueOf(readOnly)); + claimProperties.put(ClaimConstants.REQUIRED_PROPERTY, String.valueOf(required)); + claimProperties.put(ClaimConstants.MULTI_VALUED_PROPERTY, String.valueOf(multiValued)); + + Claim scimClaim = new Claim(SCIM_SCHEMA_URI_PREFIX, SINGLE_CLAIM_URI, claimProperties); + return scimClaim; + } }