diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e48b8e8..07123077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,27 @@ these, there is no change to make. JAX-B was removed in 5.0.0, but GMT support was kept with a system property for legacy uses. For consistency, we intend to remove this property, as UTC has been in use by default since 6.0.0. +Added support for applying patch operations that a) do not contain a `path`, and b) also include +nested paths within its `value`. These are not defined by the standard, but are still used by some +SCIM provisioning engines. For example: +```json +{ + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "add", + "value": { + "name.givenName": "Melody" + } + } ] +} +``` + +Added a maximum array size threshold for the target of a patch operation. Now, PATCH updates that +would result in over 25,000 elements within an array will throw a `BadRequestException`. This +value is controlled by the `com.unboundid.scim2.common.utils.JsonUtils.maxPatchArraySize` property. + +Added a `Path.stream()` method to allow access to elements of a path with the Java Stream API. + ## 6.0.0 - 2026-May-11 The UnboundID SCIM SDK has been updated to use version 3 of the Jackson library (this release ships with v3.1.3). This change aligns the SCIM SDK with HTTP libraries such as Spring Framework 7/Spring diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/Path.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/Path.java index 3a3f3249..eb887411 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/Path.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/Path.java @@ -46,6 +46,7 @@ import java.util.Iterator; import java.util.List; import java.util.Objects; +import java.util.stream.Stream; import static com.unboundid.scim2.common.utils.StaticUtils.toLowerCase; @@ -112,9 +113,9 @@ *
  • {@code name.givenName}: Targets the "givenName" sub-attribute that is * nested beneath the complex "name" attribute. This is a path with two * elements and no filter values. - *
  • {@code urn:ietf:ext:example:2.0:User.manager.name}: Targets the "name" + *
  • {@code urn:ietf:ext:example:2.0:User:manager.name}: Targets the "name" * sub-attribute stored within the schema extension. Note that schema - * extensions are not accounted for path sizes, so this is a path with + * extensions are considered part of the "root", so this is a path with * two elements ({@code manager} and {@code name}). * *

    @@ -179,7 +180,7 @@ *
  • {@link #getElement(int)}: Fetches a specific element within the path. *
  • {@link #getLastElement()}: Fetches the last element within the path. *
  • {@link #replace}: Updates a specific element within the path. - *
  • {@link #subPath}: Removes elements at and after the provided index. + *
  • {@link #subPath}: Extracts elements up to the provided index. *
  • {@link #withoutFilters()}: Returns the path with all filters removed. * */ @@ -447,6 +448,19 @@ public Iterator iterator() return elements.iterator(); } + /** + * Returns a stream of all elements within this path reference. Note that + * this does not contain the root node. + * + * @return A stream containing all elements within this path. + * @since 6.1.0 + */ + @NotNull + public Stream stream() + { + return elements.stream(); + } + /** * Retrieves the path element at the specified index. * diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/PatchOperation.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/PatchOperation.java index 4e8caa62..2c644aea 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/PatchOperation.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/PatchOperation.java @@ -162,6 +162,9 @@ public abstract class PatchOperation @Nullable private final Path path; + @NotNull + private static final Path VALUE = Path.of("value"); + /** * This class represents a SCIM 2 {@code add} patch operation as defined by @@ -885,7 +888,6 @@ public int hashCode() } - /** * Create a new patch operation. * @@ -894,29 +896,7 @@ public int hashCode() */ PatchOperation(@Nullable final Path path) throws ScimException { - if (path != null) - { - if (path.size() > 2) - { - throw BadRequestException.invalidPath( - "Path cannot target sub-attributes more than one level deep"); - } - - if (path.size() == 2) - { - Filter valueFilter = path.getElement(1).getValueFilter(); - // Allow use of the special case "value" path to reference the value - // itself. Any other value filter is for a sub-attribute, which is not - // permitted. - if (valueFilter != null && - !valueFilter.getAttributePath().getElement(0) - .getAttribute().equals("value")) - { - throw BadRequestException.invalidPath( - "Path cannot include a value filter on sub-attributes"); - } - } - } + validateOperationPath(path); this.path = path; } @@ -2391,6 +2371,36 @@ public static PatchOperation create(@NotNull final PatchOpType opType, } + /** + * Validates an attribute path that targets a piece of JSON data. + * + * @param path The provided path. + * @throws ScimException If the path is invalid. + */ + public static void validateOperationPath(@Nullable final Path path) + throws ScimException + { + if (path == null || path.size() <= 1) + { + return; + } + if (path.size() > 2) + { + throw BadRequestException.invalidPath( + "Path cannot target sub-attributes more than one level deep"); + } + + // Allow the "value" path to reference the value itself. Any other filter + // targets a sub-attribute. See FilterEvaluator#getCandidateNodes. + Filter valueFilter = path.getElement(1).getValueFilter(); + if (valueFilter != null && !VALUE.equals(valueFilter.getAttributePath())) + { + throw BadRequestException.invalidPath( + "Path cannot include a value filter on sub-attributes"); + } + } + + /** * Validates the {@code value} of a patch operation when the operation is * constructed. diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/FilterEvaluator.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/FilterEvaluator.java index d3d51120..29b33ad4 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/FilterEvaluator.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/FilterEvaluator.java @@ -417,10 +417,9 @@ private Iterable getCandidateNodes(@NotNull final Path path, { return jsonNode; } - if (jsonNode.isObject()) + if (jsonNode instanceof ObjectNode objectNode) { - List nodes = - JsonUtils.findMatchingPaths(path, (ObjectNode) jsonNode); + List nodes = JsonUtils.findMatchingPaths(path, objectNode); ArrayList flattenedNodes = new ArrayList<>(nodes.size()); for (JsonNode node : nodes) { diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java index 69f31d29..f06637d6 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java @@ -74,6 +74,17 @@ public class JsonUtils @NotNull private static JsonMapper SDK_OBJECT_MAPPER = createJsonMapper(); + /** + * This property defines the maximum array size that is permitted on a SCIM + * resource when processing a PATCH request. In addition to connection + * timeouts, this property helps avoid excessive time computing uniqueness + * when adding new fields to an array stored on a SCIM resource. + * + * @since 6.1.0 + */ + public static int MAX_PATCH_ARRAY_SIZE = StaticUtils.getIntProperty( + "com.unboundid.scim2.common.utils.JsonUtils.maxPatchArraySize", 25_000); + /** * This represents the base class for handling SCIM JSON data. Subclasses * of this abstract class support actions such as filtering and updating @@ -410,10 +421,10 @@ else if (isDelete && attributeValue.isObject()) // If we have pruned all remaining values on the field, then we should // completely delete the attribute. For example, if a resource only has a // single email that is deleted, the 'emails' field in the JsonNode should - // explicitly be set to null. + // explicitly be removed. if (array.isEmpty()) { - updateNode(parent, field, NullNode.getInstance()); + parent.remove(field); } return nodeUpdated; @@ -426,10 +437,14 @@ else if (isDelete && attributeValue.isObject()) * @param parent The container node. * @param key The key of the field to update. * @param value The update value. + * + * @throws ScimException If an error occurred when processing the update, + * such as an invalid field name being used. */ protected void updateNode(@NotNull final ObjectNode parent, @Nullable final String key, @Nullable final JsonNode value) + throws ScimException { // RFC 7643 section 2.5 states: // "Unassigned attributes, the null value, or empty array (in the case of @@ -450,56 +465,112 @@ protected void updateNode(@NotNull final ObjectNode parent, // When key is null, the node to update is the parent itself. JsonNode node = key == null ? parent : parent.path(key); - if (node instanceof ObjectNode targetObject) + if (node instanceof ObjectNode targetObject + && value instanceof ObjectNode valueObject) { - if (value instanceof ObjectNode valueObject) + // Go through the fields of both objects and merge them. + for (Map.Entry field : valueObject.properties()) { - // Go through the fields of both objects and merge them. - for (Map.Entry field : valueObject.properties()) - { - updateNode(targetObject, field.getKey(), field.getValue()); - } - } - else - { - // Replace the field. - parent.set(key, value); + updateWithNesting(targetObject, field.getKey(), field.getValue()); } } - else if (node instanceof ArrayNode targetArray) + else if (appendValues && node instanceof ArrayNode targetArray + && value instanceof ArrayNode valueArray) { - if (appendValues && value instanceof ArrayNode valueArray) + // Merge the two arrays without a set to avoid GC. Avoid too many + // uniqueness evaluations, even if the valueArray has duplicates. + validatePatchArraySize(targetArray, valueArray); + for (JsonNode valueNode : valueArray) { - // Append the new values to the existing ones. - for (JsonNode valueNode : valueArray) + if (targetArray.valueStream().noneMatch(valueNode::equals)) { - boolean valueFound = false; - for (JsonNode targetNode : targetArray) - { - if (valueNode.equals(targetNode)) - { - valueFound = true; - break; - } - } - if (!valueFound) - { - targetArray.add(valueNode); - } + targetArray.add(valueNode); } } - else - { - // Replace the field. - parent.set(key, value); - } } else { - // Replace the field. + // For all other scenarios, replace the field directly. + validatePatchArraySize(value); parent.set(key, value); } } + + /** + * Updates the target object with the provided field and value. In addition + * to the standard case where the {@code field} is a simple attribute path + * (e.g., {@code userName}), this method handles the case in which the field + * represents a nested path (e.g., {@code name.familyName}). + * + * @param targetObject The JsonNode that should be updated. + * @param field The field name, which represents the attribute path. + * @param value The value that should be assigned to the field. + * + * @throws ScimException If an error occurred when processing the update. + */ + private void updateWithNesting(@NotNull final ObjectNode targetObject, + @NotNull final String field, + @NotNull final JsonNode value) + throws ScimException + { + Path path = Path.fromString(field); + PatchOperation.validateOperationPath(path); + + // When path data comes from 'value', do not attempt to support filters. + if (path.stream().anyMatch(e -> e.getValueFilter() != null)) + { + throw BadRequestException.invalidValue( + "Paths declared in the 'value' field cannot have filters."); + } + + if (path.size() <= 1) + { + // Handle the update normally. + updateNode(targetObject, field, value); + return; + } + + // Create the parent node if it does not exist (e.g., the patch operation + // targets a completely empty resource). + String parentAttr = path.getElement(0).getAttribute(); + if (targetObject.path(parentAttr).isMissingNode()) + { + targetObject.set(parentAttr, getJsonNodeFactory().objectNode()); + } + + // Fetch the parent node (e.g., the "name" from a "name.familyName" path). + JsonNode parentNode = targetObject.path(parentAttr); + if (!(parentNode instanceof ObjectNode parent)) + { + throw BadRequestException.invalidValue( + "The '" + field + "' path led to a nested non-object node."); + } + + updateNode(parent, path.getLastElement().getAttribute(), value); + + // After the update, prune the parent node if it is empty. + if (targetObject.path(parentAttr).isEmpty()) + { + targetObject.remove(parentAttr); + } + } + + private void validatePatchArraySize(@NotNull final JsonNode... nodes) + throws ScimException + { + int arraySize = 0; + for (JsonNode node : nodes) + { + arraySize += (node.isArray()) ? node.size() : 0; + } + + if (arraySize > MAX_PATCH_ARRAY_SIZE) + { + throw BadRequestException.invalidValue( + "The update would have exceeded the max array size: " + + MAX_PATCH_ARRAY_SIZE); + } + } } private static class PathExistsVisitor extends NodeVisitor diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/StaticUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/StaticUtils.java index 9a9faed1..a2c93d44 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/StaticUtils.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/StaticUtils.java @@ -318,4 +318,32 @@ else if ("false".equalsIgnoreCase(property)) return defaultValue; } + + + /** + * Obtains the value of the provided JVM system property name as an integer. + * If the system property is not defined or cannot be obtained (e.g., due to + * security manager protections), the default value will be used. + * + * @param name The name of the system property. + * @param defaultValue The value that should be used if the property is not + * defined or cannot be fetched. + * + * @return The property value as a integer, if it exists and is obtainable. + */ + public static int getIntProperty(@NotNull final String name, + final int defaultValue) + { + Integer property = null; + try + { + property = Integer.getInteger(name); + } + catch (Throwable t) + { + Debug.debugException(t); + } + + return (property == null) ? defaultValue : property; + } } diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DiffTestCase.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DiffTestCase.java index 8064b684..66b5942e 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DiffTestCase.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/DiffTestCase.java @@ -54,7 +54,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.AssertionsForClassTypes.fail; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertTrue; @@ -322,24 +321,7 @@ public void testDiffMultiValuedAttribute() throws Exception // matter. JsonNode sourcePhotos = source.remove("photos"); JsonNode targetPhotos = target.remove("photos"); - assertEquals(sourcePhotos.size(), targetPhotos.size()); - for (JsonNode sourceValue : sourcePhotos) - { - boolean found = false; - for (JsonNode targetValue : targetPhotos) - { - if (sourceValue.equals(targetValue)) - { - found = true; - break; - } - } - if (!found) - { - fail("Source photo value " + sourceValue + - " not in target photo array " + targetPhotos); - } - } + assertThat(sourcePhotos).containsExactlyElementsOf(targetPhotos); assertThat(source).isEqualTo(target); } diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/PatchOpTestCase.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/PatchOpTestCase.java index cdb451ed..646d91ac 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/PatchOpTestCase.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/PatchOpTestCase.java @@ -59,6 +59,7 @@ import java.util.List; import static com.unboundid.scim2.common.exceptions.BadRequestException.INVALID_PATH; +import static com.unboundid.scim2.common.utils.JsonUtils.MAX_PATCH_ARRAY_SIZE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -782,6 +783,7 @@ public void testEmptyValues() throws Exception PatchOperation.create(PatchOpType.REPLACE, "myArray", EMPTY_ARRAY); } + /** * This test validates the behavior of patch operations when the value is an * empty array. When a patch operations sets the {@code value} field to an @@ -990,6 +992,7 @@ public void testVarArgs() throws Exception assertEquals(operation, operation2); } + /** * Tests for {@link PatchOperation#applyToResource} and * {@link PatchRequest#applyToResource}. @@ -1066,4 +1069,256 @@ public void testApplyingScimResources() throws Exception .isEqualTo(complexUser) .isEqualTo(unchangedAgain2); } + + + /** + * Tests the behavior of patch operations that satisfy two pieces of criteria: + * operations that do not contain a path, and also includes nested paths + * within its {@code value} such as {@code name.familyName}. + */ + @Test + public void testNestedFieldNoPath() throws Exception + { + // This test creates many patch requests with complex JSON values. From a + // test readability perspective, it is better to create these with strings. + var patchReader = JsonUtils.getObjectReader().forType(PatchRequest.class); + + PatchRequest basicCase = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "add", + "value": { + "userName": "smile", + "name.familyName": "Memory", + "name.givenName": "Favorite" + } + } ] + }"""); + + // Verify the patch request result with the object node to be certain that + // the values are nested. + var gsr = basicCase.applyToResource(new GenericScimResource()); + assertThat(gsr.getObjectNode().path("name").path("givenName").asString()) + .isEqualTo("Favorite"); + assertThat(gsr.getObjectNode().path("name").path("familyName").asString()) + .isEqualTo("Memory"); + assertThat(gsr.getStringValue("userName")).isEqualTo("smile"); + + // Apply the same patch request against a UserResource object. + UserResource updatedUser = basicCase.applyToResource(new UserResource()); + assertThat(updatedUser.getUserName()).isEqualTo("smile"); + assertThat(updatedUser.getName()) + .isEqualTo(new Name().setGivenName("Favorite").setFamilyName("Memory")); + + // Attempt the same patch request against a user with some pre-filled data. + UserResource existingUser = new UserResource() + .setName(new Name().setFamilyName("Melody")); + UserResource existingUpdated = basicCase.applyToResource(existingUser); + assertThat(existingUpdated.getUserName()).isEqualTo("smile"); + assertThat(existingUpdated.getName()) + .isEqualTo(new Name().setGivenName("Favorite").setFamilyName("Memory")); + + // Create a patch request with multiple fields of nesting. Constructing this + // instance is permitted, but applying it is not. + PatchRequest tooDeep = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "replace", + "value": { + "one.two.three.four": "turn" + } + } ] + }"""); + + // Apply the update to an empty resource. + assertThatThrownBy(() -> tooDeep.applyToResource(new GenericScimResource())) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Path cannot target sub-attributes more than") + .hasMessageContaining("one level deep"); + + // Test the case where the patch request is applied on an invalid resource. + PatchRequest nested = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "replace", + "value": { + "both.know": "this" + } + } ] + }"""); + String resourceJson = """ + { + "both": [ "This field should be an object." ] + }"""; + GenericScimResource invalidResource = JsonUtils.getObjectReader() + .forType(GenericScimResource.class).readValue(resourceJson); + assertThatThrownBy(() -> nested.applyToResource(invalidResource)) + .isInstanceOf(BadRequestException.class) + .hasMessage("The 'both.know' path led to a nested non-object node."); + + // Using a filter for a path within 'value' should not be permitted. + PatchRequest filteredPath = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "add", + "value": { + "on[type eq \\"show\\"].for": "you" + } + } ] + }"""); + assertThatThrownBy(() -> filteredPath.applyToResource(new UserResource())) + .isInstanceOf(BadRequestException.class) + .hasMessage("Paths declared in the 'value' field cannot have filters."); + + // A filter should also not be allowed for the second element in a path. + PatchRequest filteredPath2 = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "replace", + "value": { + "it.goes[value eq \\"hundred\\"]": "four" + } + } ] + }"""); + assertThatThrownBy(() -> filteredPath2.applyToResource(new UserResource())) + .isInstanceOf(BadRequestException.class) + .hasMessage("Paths declared in the 'value' field cannot have filters."); + + // Ensure the previous case is still supported properly on an explicit path. + PatchRequest nestedRequest = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "path": "it.goes[value eq \\"hundred\\"]", + "op": "replace", + "value": "four" + } ] + }"""); + String nestedResourceJson = """ + { + "it": { + "goes": [ "one", "two", "three", "hundred" ] + } + }"""; + GenericScimResource nestedResource = JsonUtils.getObjectReader() + .forType(GenericScimResource.class).readValue(nestedResourceJson); + nestedResource = nestedRequest.applyToResource(nestedResource); + assertThat(nestedResource.getStringValueList("it.goes")) + .contains("four") + .doesNotContain("hundred"); + + // Ensure an extension object is still processed normally. + PatchRequest qualifiedPath = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "add", + "value": { + "urn:ietf:watch:all": { + "extObject": "face" + } + } + } ] + }"""); + UserResource expectedUser = JsonUtils.getObjectReader() + .forType(UserResource.class).readValue(""" + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:watch:all" + ], + "urn:ietf:watch:all": { + "extObject": "face" + } + }"""); + assertThat(qualifiedPath.applyToResource(new UserResource())) + .isEqualTo(expectedUser); + + // Ensure that a replace operation with a 'null' value properly deletes the + // parent node if all subordinate fields were removed. + UserResource existing = new UserResource() + .setName(new Name().setGivenName("Melody")); + PatchRequest replaceNull = patchReader.readValue(""" + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "replace", + "value": { + "name.givenName": null + } + } ] + }"""); + assertThat(replaceNull.applyToResource(existing)) + .isEqualTo(new UserResource()); + } + + + /** + * Tests that adding an excessive number of elements to an array is not + * permitted past a defined threshold. + */ + @Test + public void testAppendLargeArray() throws Exception + { + final int limit = MAX_PATCH_ARRAY_SIZE; + + // Initialize a resource with an existing array almost at max capacity. + String resourceJson = """ + { + "arrayField": [] + }"""; + GenericScimResource resource = JsonUtils.getObjectReader() + .forType(GenericScimResource.class).readValue(resourceJson); + var node = JsonUtils.getJsonNodeFactory().arrayNode(); + for (int i = 0; i < limit - 1; i++) + { + node.add("newValue"); + } + resource.replaceValue("arrayField", node); + assertThat(resource.getValue("arrayField")).hasSize(limit - 1); + + // Apply a patch request that adds one field. This should be permitted. + String json = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], + "Operations": [ { + "op": "add", + "value": { + "arrayField": [ "lastValue" ] + } + } ] + }"""; + PatchRequest request = JsonUtils.getObjectReader() + .forType(PatchRequest.class).readValue(json); + request.apply(resource); + assertThat(resource.getValue("arrayField")).hasSize(limit); + + // Attempt to exceed the threshold by re-attempting the request. This should + // throw an exception even for a value that is already on the resource. + assertThatThrownBy(() -> request.apply(resource)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("update would have exceeded the max array size"); + + // Attempt to exceed the threshold again by using a patch operation that has + // an explicit path value. + var opWithPath = PatchOperation.addStringValues("arrayField", "exceeds"); + assertThatThrownBy(() -> opWithPath.applyToResource(resource)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("update would have exceeded the max array size"); + + // Replace operations should still apply here for consistency across all + // PATCH operations. If a replace operation were always permitted, then any + // subsequent ADD operation to the array would be rejected. + node.add("anotherString1").add("anotherString2"); + assertThat(node).hasSizeGreaterThan(limit); + PatchOperation replace = PatchOperation.replace("arrayField", node); + assertThatThrownBy(() -> replace.applyToResource(new GenericScimResource())) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("update would have exceeded the max array size"); + } }