Skip to content

Commit a151b27

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

5 files changed

Lines changed: 889 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: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
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
49

510
from scim2_tester.filling import fill_with_random_values
611
from scim2_tester.utils import CheckConfig
712
from scim2_tester.utils import CheckResult
813
from scim2_tester.utils import Status
914
from scim2_tester.utils import checker
15+
from scim2_tester.utils import should_test_patch
1016

1117

1218
def model_from_resource_type(
@@ -125,6 +131,282 @@ def check_object_deletion(conf: CheckConfig, obj: type[Resource]) -> CheckResult
125131
)
126132

127133

134+
@checker
135+
def check_object_modification(conf: CheckConfig, obj: Resource) -> CheckResult:
136+
"""Test basic PATCH operations (add, remove, replace) on SCIM resources.
137+
138+
As described in RFC7644 §3.5.2 <https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2>`,
139+
PATCH operations allow clients to modify existing resources by adding, removing,
140+
or replacing values.
141+
142+
This test verifies:
143+
- Successful PATCH operations return 200 or 204 as per RFC7644 §3.5.2
144+
- Replace operation works on existing attributes
145+
146+
:param conf: The check configuration containing the SCIM client
147+
:param obj: The resource object to test PATCH operations on
148+
:returns: The result of the check operation
149+
"""
150+
if not should_test_patch(conf):
151+
return CheckResult(
152+
conf,
153+
status=Status.SKIPPED,
154+
reason="PATCH operations not supported by server (patch.supported=false)",
155+
)
156+
157+
# Find writable fields for testing
158+
writable_fields = [
159+
field_name
160+
for field_name in type(obj).model_fields.keys()
161+
if obj.get_field_annotation(field_name, Mutability) == Mutability.read_write
162+
and obj.get_field_annotation(field_name, Returned) != Returned.never
163+
]
164+
165+
if not writable_fields:
166+
return CheckResult(
167+
conf,
168+
status=Status.SKIPPED,
169+
reason="No writable fields available for PATCH testing",
170+
)
171+
172+
# TODO -- use the mechanism from fill_with_random_values
173+
# TODO -- check all the fields
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+
# TODO: check all the other operations
188+
# TODO: use PatchOperation.Op
189+
patch_ops = PatchOp[type(obj)](
190+
operations=[PatchOperation(op="replace", path=field_to_test, value=test_value)]
191+
)
192+
193+
try:
194+
response = conf.client.modify(
195+
type(obj),
196+
obj.id,
197+
patch_ops,
198+
expected_status_codes=conf.expected_status_codes or [200, 204, 400],
199+
raise_scim_errors=False,
200+
)
201+
202+
if hasattr(response, "status") and response.status == "400":
203+
return CheckResult(
204+
conf,
205+
status=Status.ERROR,
206+
reason=f"PATCH operation returned error: {response.detail}",
207+
data=response,
208+
)
209+
210+
return CheckResult(
211+
conf,
212+
status=Status.SUCCESS,
213+
reason=f"Successful PATCH operation on {type(obj).__name__} with id {obj.id}",
214+
data=response,
215+
)
216+
except Exception as e:
217+
return CheckResult(
218+
conf,
219+
status=Status.ERROR,
220+
reason=f"PATCH operation failed: {e}",
221+
data=e,
222+
)
223+
224+
225+
@checker
226+
def check_patch_mutability_errors(conf: CheckConfig, obj: Resource) -> CheckResult:
227+
"""Test PATCH operations respect attribute mutability constraints.
228+
229+
According to RFC7643 §2.2 <https://datatracker.ietf.org/doc/html/rfc7643#section-2.2>`,
230+
attributes have mutability characteristics (readOnly, readWrite, immutable, writeOnly).
231+
RFC7644 §3.5.2 states that PATCH operations must respect these constraints.
232+
233+
This test verifies that:
234+
- Modifying readOnly attributes returns 400 with scimType='mutability' per RFC7644 §3.12
235+
236+
:param conf: The check configuration containing the SCIM client
237+
:param obj: The resource object to test mutability constraints on
238+
:returns: The result of the check operation
239+
"""
240+
if not should_test_patch(conf):
241+
return CheckResult(
242+
conf,
243+
status=Status.SUCCESS,
244+
reason="PATCH mutability tests skipped (patch.supported=false)",
245+
)
246+
247+
# Test modifying readOnly attribute (like 'id')
248+
readonly_fields = [
249+
field_name
250+
for field_name in type(obj).model_fields.keys()
251+
if obj.get_field_annotation(field_name, Mutability) == Mutability.read_only
252+
]
253+
254+
if not readonly_fields:
255+
return CheckResult(
256+
conf,
257+
status=Status.SUCCESS,
258+
reason="No readOnly fields available for mutability testing",
259+
)
260+
261+
readonly_field = readonly_fields[0] # Usually 'id'
262+
# Create patch operations without client-side validation to test server behavior
263+
patch_ops = {
264+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
265+
"Operations": [
266+
{"op": "replace", "path": readonly_field, "value": "SHOULD_FAIL"}
267+
],
268+
}
269+
270+
try:
271+
response = conf.client.modify(
272+
type(obj),
273+
obj.id,
274+
patch_ops,
275+
expected_status_codes=[400],
276+
raise_scim_errors=False,
277+
)
278+
279+
# TODO: check if isinstance(Error)
280+
if hasattr(response, "status") and response.status == "400":
281+
return CheckResult(
282+
conf,
283+
status=Status.SUCCESS,
284+
reason=f"Correctly rejected modification of readOnly attribute '{readonly_field}'",
285+
data=response,
286+
)
287+
elif hasattr(response, "scim_type") and response.scim_type == "mutability":
288+
return CheckResult(
289+
conf,
290+
status=Status.SUCCESS,
291+
reason=f"Correctly rejected modification of readOnly attribute '{readonly_field}' with mutability error",
292+
data=response,
293+
)
294+
elif hasattr(response, "scim_type") and response.scim_type == "invalidValue":
295+
return CheckResult(
296+
conf,
297+
status=Status.SUCCESS,
298+
reason=f"Correctly rejected modification of readOnly attribute '{readonly_field}' with invalidValue error",
299+
data=response,
300+
)
301+
elif response.__class__.__name__ == "Error":
302+
return CheckResult(
303+
conf,
304+
status=Status.SUCCESS,
305+
reason=f"Correctly rejected modification of readOnly attribute '{readonly_field}' (Error response)",
306+
data=response,
307+
)
308+
else:
309+
return CheckResult(
310+
conf,
311+
status=Status.ERROR,
312+
reason=f"Expected 400 error for readOnly attribute '{readonly_field}', got: {type(response).__name__}",
313+
data=response,
314+
)
315+
except Exception as e:
316+
# ValidationError from Pydantic is also a valid rejection of the mutability violation
317+
if "mutability" in str(e).lower() or "modification is not compatible" in str(e):
318+
return CheckResult(
319+
conf,
320+
status=Status.SUCCESS,
321+
reason=f"Correctly rejected modification of readOnly attribute '{readonly_field}' (client-side validation)",
322+
data=e,
323+
)
324+
else:
325+
return CheckResult(
326+
conf,
327+
status=Status.ERROR,
328+
reason=f"Expected rejection of readOnly attribute '{readonly_field}', got unexpected error: {e}",
329+
data=e,
330+
)
331+
332+
333+
@checker
334+
def check_patch_syntax_errors(conf: CheckConfig, obj: Resource) -> CheckResult:
335+
"""Test PATCH operations with invalid syntax return proper errors.
336+
337+
As specified in RFC7644 §3.5.2 <https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2>`,
338+
PATCH operations must include proper syntax and required attributes.
339+
RFC7644 §3.12 defines specific error codes for various PATCH failures.
340+
341+
This test verifies:
342+
- Missing path for remove operation returns 400 with scimType='noTarget' per RFC7644 §3.12
343+
344+
:param conf: The check configuration containing the SCIM client
345+
:param obj: The resource object to test syntax errors on
346+
:returns: The result of the check operation
347+
"""
348+
if not should_test_patch(conf):
349+
return CheckResult(
350+
conf,
351+
status=Status.SKIPPED,
352+
reason="PATCH syntax tests skipped (patch.supported=false)",
353+
)
354+
355+
# Test: Remove operation without path - use raw dict to bypass client validation
356+
patch_ops = {
357+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
358+
"Operations": [
359+
{"op": "remove"} # Missing path
360+
],
361+
}
362+
363+
try:
364+
response = conf.client.modify(
365+
type(obj),
366+
obj.id,
367+
patch_ops,
368+
expected_status_codes=[400],
369+
raise_scim_errors=False,
370+
)
371+
372+
if hasattr(response, "status") and response.status == "400":
373+
return CheckResult(
374+
conf,
375+
status=Status.SUCCESS,
376+
reason="Correctly rejected remove operation without path",
377+
data=response,
378+
)
379+
elif hasattr(response, "scim_type") and response.scim_type == "noTarget":
380+
return CheckResult(
381+
conf,
382+
status=Status.SUCCESS,
383+
reason="Correctly rejected remove operation without path (noTarget error)",
384+
data=response,
385+
)
386+
elif response.__class__.__name__ == "Error":
387+
return CheckResult(
388+
conf,
389+
status=Status.SUCCESS,
390+
reason="Correctly rejected remove operation without path (Error response)",
391+
data=response,
392+
)
393+
else:
394+
return CheckResult(
395+
conf,
396+
status=Status.ERROR,
397+
reason=f"Expected 400 error for remove without path, got: {type(response).__name__}",
398+
data=response,
399+
)
400+
except Exception as e:
401+
return CheckResult(
402+
conf,
403+
status=Status.ERROR,
404+
reason=f"Expected rejection of remove without path, got: {e}",
405+
data=e,
406+
)
407+
408+
409+
@checker
128410
def check_resource_type(
129411
conf: CheckConfig,
130412
resource_type: ResourceType,
@@ -172,6 +454,17 @@ def check_resource_type(
172454
result = check_object_replacement(conf, created_obj)
173455
results.append(result)
174456

457+
# Test PATCH operations if supported (RFC7644 §3.5.2)
458+
if should_test_patch(conf):
459+
result = check_object_modification(conf, created_obj)
460+
results.append(result)
461+
462+
result = check_patch_mutability_errors(conf, created_obj)
463+
results.append(result)
464+
465+
result = check_patch_syntax_errors(conf, created_obj)
466+
results.append(result)
467+
175468
result = check_object_deletion(conf, created_obj)
176469
results.append(result)
177470

scim2_tester/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
class Status(Enum):
1212
SUCCESS = auto()
1313
ERROR = auto()
14+
SKIPPED = auto()
1415

1516

1617
@dataclass
@@ -63,6 +64,19 @@ def __post_init__(self):
6364
raise SCIMTesterError(self.reason, self)
6465

6566

67+
def should_test_patch(conf: CheckConfig) -> bool:
68+
"""Check if PATCH operations should be tested based on ServiceProviderConfig.
69+
70+
:param conf: The check configuration containing the SCIM client
71+
:returns: True if PATCH is supported by the server, False otherwise
72+
"""
73+
return (
74+
conf.client.service_provider_config is not None
75+
and conf.client.service_provider_config.patch is not None
76+
and conf.client.service_provider_config.patch.supported
77+
)
78+
79+
6680
def checker(func):
6781
"""Decorate checker methods.
6882

0 commit comments

Comments
 (0)