Skip to content

Commit fd6aa88

Browse files
committed
feat: implement PATCH checks
1 parent c7d6ef8 commit fd6aa88

5 files changed

Lines changed: 1116 additions & 514 deletions

File tree

doc/changelog.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
[0.1.15] - Unreleased
5+
---------------------
6+
7+
Added
8+
^^^^^
9+
- Implement PATCH checks.
10+
411
[0.1.14] - 2025-03-28
512
---------------------
613

scim2_tester/resource.py

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1+
from typing import Any
2+
13
from scim2_models import Mutability
4+
from scim2_models import PatchOp
5+
from scim2_models import PatchOperation
26
from scim2_models import Resource
37
from scim2_models import ResourceType
8+
from scim2_models import Returned
9+
from scim2_models import SearchRequest
410

511
from scim2_tester.filling import fill_with_random_values
612
from scim2_tester.utils import CheckConfig
713
from scim2_tester.utils import CheckResult
814
from scim2_tester.utils import Status
915
from 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

1220
def 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+
128458
def 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

Comments
 (0)