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");
+ }
}