Skip to content

Commit f456737

Browse files
committed
feat: attribute inclusion/exclusions checks
1 parent 50fd5f0 commit f456737

File tree

5 files changed

+631
-0
lines changed

5 files changed

+631
-0
lines changed

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.2.7] - Unreleased
5+
--------------------
6+
7+
Added
8+
^^^^^
9+
- Attribute filtering compliance checkers for ``attributes`` and ``excludedAttributes`` on single resource, list, and ``.search`` endpoints (:rfc:`7644` §3.4). :issue:`20`
10+
411
[0.2.6] - 2026-02-19
512
--------------------
613

scim2_tester/checkers/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
from .resource_get import object_query_without_id
2121
from .resource_post import object_creation
2222
from .resource_put import object_replacement
23+
from .resource_query_attributes import object_list_with_attributes
24+
from .resource_query_attributes import object_query_with_attributes
25+
from .resource_query_attributes import search_with_attributes
2326
from .resource_types import access_invalid_resource_type
2427
from .resource_types import query_all_resource_types
2528
from .resource_types import query_resource_type_by_id
@@ -49,6 +52,9 @@
4952
"object_creation",
5053
"object_query",
5154
"object_query_without_id",
55+
"object_query_with_attributes",
56+
"object_list_with_attributes",
57+
"search_with_attributes",
5258
"object_replacement",
5359
"object_deletion",
5460
"resource_type_tests",

scim2_tester/checkers/resource.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
from .resource_get import object_query_without_id
1414
from .resource_post import object_creation
1515
from .resource_put import object_replacement
16+
from .resource_query_attributes import object_list_with_attributes
17+
from .resource_query_attributes import object_query_with_attributes
18+
from .resource_query_attributes import search_with_attributes
1619

1720

1821
def resource_type_tests(
@@ -51,6 +54,9 @@ def resource_type_tests(
5154
results.extend(object_creation(context, model))
5255
results.extend(object_query(context, model))
5356
results.extend(object_query_without_id(context, model))
57+
results.extend(object_query_with_attributes(context, model))
58+
results.extend(object_list_with_attributes(context, model))
59+
results.extend(search_with_attributes(context, model))
5460
results.extend(object_replacement(context, model))
5561
results.extend(object_deletion(context, model))
5662

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
from typing import Any
2+
3+
from scim2_models import ListResponse
4+
from scim2_models import Mutability
5+
from scim2_models import Required
6+
from scim2_models import Resource
7+
from scim2_models import Returned
8+
from scim2_models import SearchRequest
9+
10+
from ..utils import CheckContext
11+
from ..utils import CheckResult
12+
from ..utils import Status
13+
from ..utils import check_result
14+
from ..utils import checker
15+
16+
17+
def _pick_attribute_names(
18+
model: type[Resource[Any]],
19+
) -> tuple[str | None, str | None]:
20+
"""Pick two default-returned, non-required attribute names for testing.
21+
22+
Returns a tuple ``(included, excluded)`` of serialization aliases.
23+
Either may be :data:`None` if the model does not have enough suitable attributes.
24+
"""
25+
candidates: list[str] = []
26+
for field_name, field_info in model.model_fields.items():
27+
returnability = model.get_field_annotation(field_name, Returned)
28+
mutability = model.get_field_annotation(field_name, Mutability)
29+
required = model.get_field_annotation(field_name, Required)
30+
if (
31+
returnability == Returned.default
32+
and mutability != Mutability.read_only
33+
and required != Required.true
34+
and field_name not in ("schemas", "meta", "id")
35+
):
36+
alias = field_info.serialization_alias or field_name
37+
candidates.append(alias)
38+
39+
included = candidates[0] if len(candidates) >= 1 else None
40+
excluded = candidates[1] if len(candidates) >= 2 else None
41+
return included, excluded
42+
43+
44+
def _check_attribute_filtering(
45+
response_data: dict[str, Any],
46+
included: str | None,
47+
excluded: str | None,
48+
model_name: str,
49+
endpoint: str,
50+
) -> tuple[Status, str]:
51+
"""Verify that the response honours ``attributes`` or ``excludedAttributes``.
52+
53+
Returns a ``(status, reason)`` pair.
54+
"""
55+
if included is not None and included not in response_data:
56+
return (
57+
Status.ERROR,
58+
f"{endpoint}: requested attribute '{included}' missing "
59+
f"from {model_name} response",
60+
)
61+
62+
if excluded is not None and excluded in response_data:
63+
return (
64+
Status.ERROR,
65+
f"{endpoint}: excluded attribute '{excluded}' still present "
66+
f"in {model_name} response",
67+
)
68+
69+
return Status.SUCCESS, f"{endpoint}: attribute filtering honoured for {model_name}"
70+
71+
72+
def _find_resource_in_list(
73+
response: ListResponse[Resource[Any]] | Any,
74+
resource_id: str,
75+
) -> dict[str, Any] | None:
76+
"""Find a resource by id in a list response and return its dumped dict."""
77+
if isinstance(response, ListResponse):
78+
for r in response.resources:
79+
if r.id == resource_id:
80+
return r.model_dump()
81+
return None
82+
83+
84+
def _run_attribute_checks(
85+
context: CheckContext,
86+
model: type[Resource[Any]],
87+
test_obj: Resource[Any],
88+
included: str | None,
89+
excluded: str | None,
90+
query_fn: Any,
91+
endpoint: str,
92+
) -> list[CheckResult]:
93+
"""Run inclusion and exclusion checks using the given query function.
94+
95+
:param query_fn: callable that takes a :class:`SearchRequest` and returns
96+
a single :class:`Resource` or a :class:`ListResponse`.
97+
:param endpoint: human-readable endpoint label for messages.
98+
"""
99+
results: list[CheckResult] = []
100+
101+
for attr, is_inclusion in [(included, True), (excluded, False)]:
102+
if attr is None:
103+
continue
104+
105+
if is_inclusion:
106+
search_request = SearchRequest(attributes=[attr])
107+
else:
108+
search_request = SearchRequest(excluded_attributes=[attr])
109+
110+
response = query_fn(search_request)
111+
112+
if isinstance(response, Resource) and not isinstance(response, ListResponse):
113+
response_data = response.model_dump()
114+
elif isinstance(response, ListResponse):
115+
response_data = _find_resource_in_list(response, test_obj.id)
116+
if response_data is None:
117+
results.append(
118+
check_result(
119+
context,
120+
status=Status.ERROR,
121+
reason=f"{endpoint}: could not find {model.__name__} "
122+
f"with id {test_obj.id} in filtered results",
123+
data=response,
124+
)
125+
)
126+
continue
127+
else:
128+
results.append(
129+
check_result(
130+
context,
131+
status=Status.ERROR,
132+
reason=f"{endpoint}: unexpected response type "
133+
f"{type(response).__name__}",
134+
data=response,
135+
)
136+
)
137+
continue
138+
139+
inc = attr if is_inclusion else None
140+
exc = attr if not is_inclusion else None
141+
status, reason = _check_attribute_filtering(
142+
response_data, inc, exc, model.__name__, endpoint
143+
)
144+
results.append(
145+
check_result(context, status=status, reason=reason, data=response)
146+
)
147+
148+
return results
149+
150+
151+
@checker("crud:read:attributes")
152+
def object_query_with_attributes(
153+
context: CheckContext, model: type[Resource[Any]]
154+
) -> list[CheckResult]:
155+
"""Validate that GET on a single resource honours ``attributes`` and ``excludedAttributes``.
156+
157+
Creates a resource with all writable fields populated, then retrieves it
158+
twice: once with ``attributes`` restricting the response to a single
159+
attribute, and once with ``excludedAttributes`` hiding another attribute.
160+
161+
**Status:**
162+
163+
- :attr:`~scim2_tester.Status.SUCCESS`: Server correctly filters response attributes
164+
- :attr:`~scim2_tester.Status.ERROR`: Server ignores attribute filtering parameters
165+
- :attr:`~scim2_tester.Status.SKIPPED`: Model has no suitable attributes to test
166+
167+
.. pull-quote:: :rfc:`RFC 7644 Section 3.4.1 <7644#section-3.4.1>`
168+
169+
"Clients MAY request a partial resource representation on any
170+
operation that returns a resource within the response by specifying
171+
either of the mutually exclusive URL query parameters ``attributes``
172+
or ``excludedAttributes``."
173+
"""
174+
included, excluded = _pick_attribute_names(model)
175+
if included is None and excluded is None:
176+
return [
177+
check_result(
178+
context,
179+
status=Status.SKIPPED,
180+
reason=f"No suitable attributes to test filtering on {model.__name__}",
181+
)
182+
]
183+
184+
test_obj = context.resource_manager.create_and_register(model, fill_all=True)
185+
186+
def query_fn(search_request: SearchRequest) -> Any:
187+
return context.client.query(
188+
model,
189+
test_obj.id,
190+
search_request=search_request,
191+
expected_status_codes=context.conf.expected_status_codes or [200],
192+
)
193+
194+
return _run_attribute_checks(
195+
context, model, test_obj, included, excluded, query_fn, "GET /Resource/{id}"
196+
)
197+
198+
199+
@checker("crud:read:attributes")
200+
def object_list_with_attributes(
201+
context: CheckContext, model: type[Resource[Any]]
202+
) -> list[CheckResult]:
203+
"""Validate that GET on the collection endpoint honours ``attributes`` and ``excludedAttributes``.
204+
205+
Creates a resource with all writable fields populated, then lists the
206+
collection twice: once with ``attributes`` and once with
207+
``excludedAttributes``. Verifies that the created resource appears in
208+
the list and that its serialized form respects the filtering parameters.
209+
210+
**Status:**
211+
212+
- :attr:`~scim2_tester.Status.SUCCESS`: Server correctly filters list response attributes
213+
- :attr:`~scim2_tester.Status.ERROR`: Server ignores attribute filtering on list endpoint
214+
- :attr:`~scim2_tester.Status.SKIPPED`: Model has no suitable attributes to test
215+
216+
.. pull-quote:: :rfc:`RFC 7644 Section 3.4.2 <7644#section-3.4.2>`
217+
218+
"Clients MAY use the ``attributes`` query parameter to request
219+
particular attributes be included in a query response."
220+
"""
221+
included, excluded = _pick_attribute_names(model)
222+
if included is None and excluded is None:
223+
return [
224+
check_result(
225+
context,
226+
status=Status.SKIPPED,
227+
reason=f"No suitable attributes to test filtering on {model.__name__}",
228+
)
229+
]
230+
231+
test_obj = context.resource_manager.create_and_register(model, fill_all=True)
232+
233+
def query_fn(search_request: SearchRequest) -> Any:
234+
return context.client.query(
235+
model,
236+
search_request=search_request,
237+
expected_status_codes=context.conf.expected_status_codes or [200],
238+
)
239+
240+
return _run_attribute_checks(
241+
context, model, test_obj, included, excluded, query_fn, "GET /Resource"
242+
)
243+
244+
245+
@checker("crud:read:attributes")
246+
def search_with_attributes(
247+
context: CheckContext, model: type[Resource[Any]]
248+
) -> list[CheckResult]:
249+
"""Validate that POST ``/.search`` honours ``attributes`` and ``excludedAttributes``.
250+
251+
Creates a resource with all writable fields populated, then issues
252+
``/.search`` requests with attribute filtering. Verifies that the
253+
created resource appears in the results and respects the filtering.
254+
255+
**Status:**
256+
257+
- :attr:`~scim2_tester.Status.SUCCESS`: Server correctly filters search response attributes
258+
- :attr:`~scim2_tester.Status.ERROR`: Server ignores attribute filtering on search endpoint
259+
- :attr:`~scim2_tester.Status.SKIPPED`: Model has no suitable attributes to test
260+
261+
.. pull-quote:: :rfc:`RFC 7644 Section 3.4.3 <7644#section-3.4.3>`
262+
263+
"Clients MAY execute queries without passing parameters on the URL by
264+
using the HTTP POST verb combined with the ``/.search`` path extension."
265+
"""
266+
included, excluded = _pick_attribute_names(model)
267+
if included is None and excluded is None:
268+
return [
269+
check_result(
270+
context,
271+
status=Status.SKIPPED,
272+
reason=f"No suitable attributes to test filtering on {model.__name__}",
273+
)
274+
]
275+
276+
test_obj = context.resource_manager.create_and_register(model, fill_all=True)
277+
278+
def query_fn(search_request: SearchRequest) -> Any:
279+
return context.client.search(
280+
search_request=search_request,
281+
expected_status_codes=context.conf.expected_status_codes or [200],
282+
)
283+
284+
return _run_attribute_checks(
285+
context, model, test_obj, included, excluded, query_fn, "POST /.search"
286+
)

0 commit comments

Comments
 (0)