1+ from typing import Any
2+
13from scim2_models import Mutability
4+ from scim2_models import PatchOp
5+ from scim2_models import PatchOperation
26from scim2_models import Resource
37from scim2_models import ResourceType
8+ from scim2_models import Returned
9+ from scim2_models import SearchRequest
410
511from scim2_tester .filling import fill_with_random_values
612from scim2_tester .utils import CheckConfig
713from scim2_tester .utils import CheckResult
814from scim2_tester .utils import Status
915from scim2_tester .utils import checker
16+ from scim2_tester .utils import should_test_filters
17+ from scim2_tester .utils import should_test_patch
1018
1119
1220def model_from_resource_type (
@@ -125,6 +133,328 @@ def check_object_deletion(conf: CheckConfig, obj: type[Resource]) -> CheckResult
125133 )
126134
127135
136+ @checker
137+ def check_object_modification (conf : CheckConfig , obj : Resource ) -> CheckResult :
138+ """Test basic PATCH operations (add, remove, replace) on SCIM resources.
139+
140+ As described in RFC7644 §3.5.2 <https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2>`,
141+ PATCH operations allow clients to modify existing resources by adding, removing,
142+ or replacing values.
143+
144+ This test verifies:
145+ - Successful PATCH operations return 200 or 204 as per RFC7644 §3.5.2
146+ - Replace operation works on existing attributes
147+
148+ :param conf: The check configuration containing the SCIM client
149+ :param obj: The resource object to test PATCH operations on
150+ :returns: The result of the check operation
151+ """
152+ if not should_test_patch (conf ):
153+ return CheckResult (
154+ conf ,
155+ status = Status .SUCCESS ,
156+ reason = "PATCH operations not supported by server (patch.supported=false)" ,
157+ )
158+
159+ # Find writable fields for testing
160+ writable_fields = [
161+ field_name
162+ for field_name in type (obj ).model_fields .keys ()
163+ if obj .get_field_annotation (field_name , Mutability ) == Mutability .read_write
164+ and obj .get_field_annotation (field_name , Returned ) != Returned .never
165+ ]
166+
167+ if not writable_fields :
168+ return CheckResult (
169+ conf ,
170+ status = Status .SUCCESS ,
171+ reason = "No writable fields available for PATCH testing" ,
172+ )
173+
174+ # Simple replace operation on first writable field
175+ # Use a more appropriate test value based on the field name
176+ field_to_test = writable_fields [0 ]
177+ test_value : Any
178+ if field_to_test == "displayName" :
179+ test_value = "PATCH Test User"
180+ elif field_to_test == "userName" :
181+ test_value = "patch_test_user"
182+ elif field_to_test == "active" :
183+ test_value = True
184+ else :
185+ test_value = "PATCH_TEST_VALUE"
186+
187+ patch_ops = PatchOp [type (obj )](
188+ operations = [PatchOperation (op = "replace" , path = field_to_test , value = test_value )]
189+ )
190+
191+ try :
192+ response = conf .client .modify (
193+ type (obj ),
194+ obj .id ,
195+ patch_ops ,
196+ expected_status_codes = conf .expected_status_codes or [200 , 204 , 400 ],
197+ raise_scim_errors = False ,
198+ )
199+
200+ if hasattr (response , "status" ) and response .status == "400" :
201+ return CheckResult (
202+ conf ,
203+ status = Status .ERROR ,
204+ reason = f"PATCH operation returned error: { response .detail } " ,
205+ data = response ,
206+ )
207+
208+ return CheckResult (
209+ conf ,
210+ status = Status .SUCCESS ,
211+ reason = f"Successful PATCH operation on { type (obj ).__name__ } with id { obj .id } " ,
212+ data = response ,
213+ )
214+ except Exception as e :
215+ return CheckResult (
216+ conf ,
217+ status = Status .ERROR ,
218+ reason = f"PATCH operation failed: { e } " ,
219+ data = e ,
220+ )
221+
222+
223+ @checker
224+ def check_patch_mutability_errors (conf : CheckConfig , obj : Resource ) -> CheckResult :
225+ """Test PATCH operations respect attribute mutability constraints.
226+
227+ According to RFC7643 §2.2 <https://datatracker.ietf.org/doc/html/rfc7643#section-2.2>`,
228+ attributes have mutability characteristics (readOnly, readWrite, immutable, writeOnly).
229+ RFC7644 §3.5.2 states that PATCH operations must respect these constraints.
230+
231+ This test verifies that:
232+ - Modifying readOnly attributes returns 400 with scimType='mutability' per RFC7644 §3.12
233+
234+ :param conf: The check configuration containing the SCIM client
235+ :param obj: The resource object to test mutability constraints on
236+ :returns: The result of the check operation
237+ """
238+ if not should_test_patch (conf ):
239+ return CheckResult (
240+ conf ,
241+ status = Status .SUCCESS ,
242+ reason = "PATCH mutability tests skipped (patch.supported=false)" ,
243+ )
244+
245+ # Test modifying readOnly attribute (like 'id')
246+ readonly_fields = [
247+ field_name
248+ for field_name in type (obj ).model_fields .keys ()
249+ if obj .get_field_annotation (field_name , Mutability ) == Mutability .read_only
250+ ]
251+
252+ if not readonly_fields :
253+ return CheckResult (
254+ conf ,
255+ status = Status .SUCCESS ,
256+ reason = "No readOnly fields available for mutability testing" ,
257+ )
258+
259+ readonly_field = readonly_fields [0 ] # Usually 'id'
260+ # Create patch operations without client-side validation to test server behavior
261+ patch_ops = {
262+ "schemas" : ["urn:ietf:params:scim:api:messages:2.0:PatchOp" ],
263+ "Operations" : [
264+ {"op" : "replace" , "path" : readonly_field , "value" : "SHOULD_FAIL" }
265+ ],
266+ }
267+
268+ try :
269+ response = conf .client .modify (
270+ type (obj ),
271+ obj .id ,
272+ patch_ops ,
273+ expected_status_codes = [400 ],
274+ raise_scim_errors = False ,
275+ )
276+
277+ if hasattr (response , "status" ) and response .status == "400" :
278+ return CheckResult (
279+ conf ,
280+ status = Status .SUCCESS ,
281+ reason = f"Correctly rejected modification of readOnly attribute '{ readonly_field } '" ,
282+ data = response ,
283+ )
284+ elif hasattr (response , "scim_type" ) and response .scim_type == "mutability" :
285+ return CheckResult (
286+ conf ,
287+ status = Status .SUCCESS ,
288+ reason = f"Correctly rejected modification of readOnly attribute '{ readonly_field } ' with mutability error" ,
289+ data = response ,
290+ )
291+ elif hasattr (response , "scim_type" ) and response .scim_type == "invalidValue" :
292+ return CheckResult (
293+ conf ,
294+ status = Status .SUCCESS ,
295+ reason = f"Correctly rejected modification of readOnly attribute '{ readonly_field } ' with invalidValue error" ,
296+ data = response ,
297+ )
298+ elif response .__class__ .__name__ == "Error" :
299+ return CheckResult (
300+ conf ,
301+ status = Status .SUCCESS ,
302+ reason = f"Correctly rejected modification of readOnly attribute '{ readonly_field } ' (Error response)" ,
303+ data = response ,
304+ )
305+ else :
306+ return CheckResult (
307+ conf ,
308+ status = Status .ERROR ,
309+ reason = f"Expected 400 error for readOnly attribute '{ readonly_field } ', got: { type (response ).__name__ } " ,
310+ data = response ,
311+ )
312+ except Exception as e :
313+ # ValidationError from Pydantic is also a valid rejection of the mutability violation
314+ if "mutability" in str (e ).lower () or "modification is not compatible" in str (e ):
315+ return CheckResult (
316+ conf ,
317+ status = Status .SUCCESS ,
318+ reason = f"Correctly rejected modification of readOnly attribute '{ readonly_field } ' (client-side validation)" ,
319+ data = e ,
320+ )
321+ else :
322+ return CheckResult (
323+ conf ,
324+ status = Status .ERROR ,
325+ reason = f"Expected rejection of readOnly attribute '{ readonly_field } ', got unexpected error: { e } " ,
326+ data = e ,
327+ )
328+
329+
330+ @checker
331+ def check_patch_syntax_errors (conf : CheckConfig , obj : Resource ) -> CheckResult :
332+ """Test PATCH operations with invalid syntax return proper errors.
333+
334+ As specified in RFC7644 §3.5.2 <https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2>`,
335+ PATCH operations must include proper syntax and required attributes.
336+ RFC7644 §3.12 defines specific error codes for various PATCH failures.
337+
338+ This test verifies:
339+ - Missing path for remove operation returns 400 with scimType='noTarget' per RFC7644 §3.12
340+
341+ :param conf: The check configuration containing the SCIM client
342+ :param obj: The resource object to test syntax errors on
343+ :returns: The result of the check operation
344+ """
345+ if not should_test_patch (conf ):
346+ return CheckResult (
347+ conf ,
348+ status = Status .SUCCESS ,
349+ reason = "PATCH syntax tests skipped (patch.supported=false)" ,
350+ )
351+
352+ # Test: Remove operation without path - use raw dict to bypass client validation
353+ patch_ops = {
354+ "schemas" : ["urn:ietf:params:scim:api:messages:2.0:PatchOp" ],
355+ "Operations" : [
356+ {"op" : "remove" } # Missing path
357+ ],
358+ }
359+
360+ try :
361+ response = conf .client .modify (
362+ type (obj ),
363+ obj .id ,
364+ patch_ops ,
365+ expected_status_codes = [400 ],
366+ raise_scim_errors = False ,
367+ )
368+
369+ if hasattr (response , "status" ) and response .status == "400" :
370+ return CheckResult (
371+ conf ,
372+ status = Status .SUCCESS ,
373+ reason = "Correctly rejected remove operation without path" ,
374+ data = response ,
375+ )
376+ elif hasattr (response , "scim_type" ) and response .scim_type == "noTarget" :
377+ return CheckResult (
378+ conf ,
379+ status = Status .SUCCESS ,
380+ reason = "Correctly rejected remove operation without path (noTarget error)" ,
381+ data = response ,
382+ )
383+ elif response .__class__ .__name__ == "Error" :
384+ return CheckResult (
385+ conf ,
386+ status = Status .SUCCESS ,
387+ reason = "Correctly rejected remove operation without path (Error response)" ,
388+ data = response ,
389+ )
390+ else :
391+ return CheckResult (
392+ conf ,
393+ status = Status .ERROR ,
394+ reason = f"Expected 400 error for remove without path, got: { type (response ).__name__ } " ,
395+ data = response ,
396+ )
397+ except Exception as e :
398+ return CheckResult (
399+ conf ,
400+ status = Status .ERROR ,
401+ reason = f"Expected rejection of remove without path, got: { e } " ,
402+ data = e ,
403+ )
404+
405+
406+ @checker
407+ def check_filter_operations (conf : CheckConfig , obj : Resource ) -> CheckResult :
408+ """Test filter operations if supported by the server.
409+
410+ As described in RFC7644 §3.4.2 <https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2>`,
411+ filtering allows clients to request a subset of resources by specifying filter criteria.
412+ Filters use the syntax defined in RFC7644 §3.4.2.2.
413+
414+ This test verifies:
415+ - Basic equality filters work as per RFC7644 §3.4.2.2
416+
417+ :param conf: The check configuration containing the SCIM client
418+ :param obj: The resource object to test filter operations on
419+ :returns: The result of the check operation
420+ """
421+ if not should_test_filters (conf ):
422+ return CheckResult (
423+ conf ,
424+ status = Status .SUCCESS ,
425+ reason = "Filter operations not supported by server (filter.supported=false)" ,
426+ )
427+
428+ # Test simple equality filter using query with SearchRequest
429+ try :
430+ search_request = SearchRequest (filter = f'id eq "{ obj .id } "' )
431+ response = conf .client .query (
432+ type (obj ), search_request = search_request , expected_status_codes = [200 ]
433+ )
434+
435+ if len (response .resources ) != 1 :
436+ return CheckResult (
437+ conf ,
438+ status = Status .ERROR ,
439+ reason = f"Equality filter returned { len (response .resources )} results, expected 1" ,
440+ data = response ,
441+ )
442+ except Exception as e :
443+ return CheckResult (
444+ conf ,
445+ status = Status .ERROR ,
446+ reason = f"Filter operation failed: { e } " ,
447+ data = e ,
448+ )
449+
450+ return CheckResult (
451+ conf ,
452+ status = Status .SUCCESS ,
453+ reason = "Filter operations working correctly" ,
454+ data = response ,
455+ )
456+
457+
128458def check_resource_type (
129459 conf : CheckConfig ,
130460 resource_type : ResourceType ,
@@ -161,6 +491,11 @@ def check_resource_type(
161491 result = check_object_query_without_id (conf , created_obj )
162492 results .append (result )
163493
494+ # Test filter operations if supported (RFC7644 §3.4.2)
495+ if should_test_filters (conf ):
496+ result = check_filter_operations (conf , created_obj )
497+ results .append (result )
498+
164499 field_names = [
165500 field_name
166501 for field_name in model .model_fields .keys ()
@@ -172,6 +507,17 @@ def check_resource_type(
172507 result = check_object_replacement (conf , created_obj )
173508 results .append (result )
174509
510+ # Test PATCH operations if supported (RFC7644 §3.5.2)
511+ if should_test_patch (conf ):
512+ result = check_object_modification (conf , created_obj )
513+ results .append (result )
514+
515+ result = check_patch_mutability_errors (conf , created_obj )
516+ results .append (result )
517+
518+ result = check_patch_syntax_errors (conf , created_obj )
519+ results .append (result )
520+
175521 result = check_object_deletion (conf , created_obj )
176522 results .append (result )
177523
0 commit comments