1+ from typing import Annotated
12from typing import TypeVar
23
34import pytest
67from scim2_models import Group
78from scim2_models import InvalidPathException
89from scim2_models import InvalidValueException
10+ from scim2_models import Mutability
11+ from scim2_models import MutabilityException
912from scim2_models import PatchOp
1013from scim2_models import PatchOperation
1114from scim2_models import User
1215from scim2_models .base import Context
1316from scim2_models .resources .resource import Resource
1417
1518
19+ class ImmutableFieldResource (Resource ):
20+ locked : Annotated [str | None , Mutability .immutable ] = None
21+
22+
1623def test_patch_op_add_invalid_extension_path ():
1724 user = User (user_name = "john" )
1825 patch_op = PatchOp [User ](
@@ -257,9 +264,8 @@ def test_validate_mutability_readonly_error():
257264 )
258265
259266
260- def test_validate_mutability_immutable_error ():
261- """Test mutability validation error for immutable attributes."""
262- # Test replace operation on immutable field within groups complex attribute
267+ def test_validate_mutability_readonly_replace_via_complex_path ():
268+ """Replacing a readOnly complex attribute path is rejected."""
263269 with pytest .raises (ValidationError , match = "mutability" ):
264270 PatchOp [User ].model_validate (
265271 {
@@ -275,9 +281,83 @@ def test_validate_mutability_immutable_error():
275281 )
276282
277283
284+ def test_patch_remove_on_immutable_field_with_value_is_rejected ():
285+ """Removing an existing immutable attribute via PATCH is rejected."""
286+ resource = ImmutableFieldResource .model_construct (locked = "existing" )
287+ patch_op = PatchOp [ImmutableFieldResource ].model_validate (
288+ {"operations" : [{"op" : "remove" , "path" : "locked" }]},
289+ context = {"scim" : Context .RESOURCE_PATCH_REQUEST },
290+ )
291+ with pytest .raises (MutabilityException ):
292+ patch_op .patch (resource )
293+
294+
295+ def test_patch_remove_on_immutable_field_without_value_is_allowed ():
296+ """Removing an unset immutable attribute is a no-op and is allowed."""
297+ resource = ImmutableFieldResource .model_construct ()
298+ patch_op = PatchOp [ImmutableFieldResource ].model_validate (
299+ {"operations" : [{"op" : "remove" , "path" : "locked" }]},
300+ context = {"scim" : Context .RESOURCE_PATCH_REQUEST },
301+ )
302+ patch_op .patch (resource )
303+ assert resource .locked is None
304+
305+
306+ def test_patch_add_on_immutable_field_with_existing_value_is_rejected ():
307+ """Adding to an immutable attribute that already has a value is rejected."""
308+ resource = ImmutableFieldResource .model_construct (locked = "existing" )
309+ patch_op = PatchOp [ImmutableFieldResource ].model_validate (
310+ {"operations" : [{"op" : "add" , "path" : "locked" , "value" : "new" }]},
311+ context = {"scim" : Context .RESOURCE_PATCH_REQUEST },
312+ )
313+ with pytest .raises (MutabilityException ):
314+ patch_op .patch (resource )
315+
316+
317+ def test_patch_add_on_immutable_field_without_value_is_allowed ():
318+ """Adding to an immutable attribute with no previous value is allowed per RFC 7644."""
319+ resource = ImmutableFieldResource .model_construct ()
320+ patch_op = PatchOp [ImmutableFieldResource ].model_validate (
321+ {"operations" : [{"op" : "add" , "path" : "locked" , "value" : "initial" }]},
322+ context = {"scim" : Context .RESOURCE_PATCH_REQUEST },
323+ )
324+ patch_op .patch (resource )
325+ assert resource .locked == "initial"
326+
327+
328+ def test_patch_replace_on_immutable_field_with_different_value_is_rejected ():
329+ """Replacing an immutable attribute with a different value is rejected."""
330+ resource = ImmutableFieldResource .model_construct (locked = "existing" )
331+ patch_op = PatchOp [ImmutableFieldResource ].model_validate (
332+ {"operations" : [{"op" : "replace" , "path" : "locked" , "value" : "other" }]},
333+ context = {"scim" : Context .RESOURCE_PATCH_REQUEST },
334+ )
335+ with pytest .raises (MutabilityException ):
336+ patch_op .patch (resource )
337+
338+
339+ def test_patch_replace_on_immutable_field_with_same_value_is_allowed ():
340+ """Replacing an immutable attribute with its current value is a no-op and is allowed."""
341+ resource = ImmutableFieldResource .model_construct (locked = "existing" )
342+ patch_op = PatchOp [ImmutableFieldResource ].model_validate (
343+ {"operations" : [{"op" : "replace" , "path" : "locked" , "value" : "existing" }]},
344+ context = {"scim" : Context .RESOURCE_PATCH_REQUEST },
345+ )
346+ patch_op .patch (resource )
347+ assert resource .locked == "existing"
348+
349+
350+ def test_patch_remove_on_readonly_field_is_rejected ():
351+ """Removing a readOnly attribute via PATCH is rejected per RFC 7643 §7."""
352+ with pytest .raises (ValidationError , match = "mutability" ):
353+ PatchOp [User ].model_validate (
354+ {"operations" : [{"op" : "remove" , "path" : "id" }]},
355+ context = {"scim" : Context .RESOURCE_PATCH_REQUEST },
356+ )
357+
358+
278359def test_patch_validation_allows_unknown_fields ():
279- """Test that patch validation allows unknown fields in operations."""
280- # This should not raise an error even though 'unknownField' doesn't exist on User
360+ """Patch operations on unknown fields pass without mutability checks."""
281361 patch_op = PatchOp [User ].model_validate (
282362 {
283363 "operations" : [
@@ -290,9 +370,8 @@ def test_patch_validation_allows_unknown_fields():
290370 assert patch_op .operations [0 ].path == "unknownField"
291371
292372
293- def test_non_replace_operations_on_immutable_fields_allowed ():
294- """Test that non-replace operations on immutable fields are allowed."""
295- # Test with non-immutable fields since groups.value is immutable
373+ def test_patch_operations_on_readwrite_fields_allowed ():
374+ """All patch operations are allowed on readWrite fields."""
296375 patch_op = PatchOp [User ].model_validate (
297376 {
298377 "operations" : [
0 commit comments