Skip to content

Commit aaf947e

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

File tree

7 files changed

+766
-106
lines changed

7 files changed

+766
-106
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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ module-root = ""
4242

4343
[project.optional-dependencies]
4444
httpx = [
45-
"scim2-client[httpx]>=0.4.0",
45+
"scim2-client[httpx]>=0.7.4",
4646
]
4747

4848
[dependency-groups]

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

0 commit comments

Comments
 (0)