diff --git a/doc/changelog.rst b/doc/changelog.rst index 2df154c..dbace4c 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,16 @@ Changelog ========= +[0.5.2] - Unreleased +-------------------- + +Fixed +^^^^^ +- The ``search_with_attributes`` check is skipped when the server answers 501 to a search at + the root. A server that does not search several resource types at once answers 501 + (:rfc:`7644` ยง3.12), and it used to fail this check. +- The generated group members have a ``type`` that matches the resource of their ``$ref``. + [0.5.1] - 2026-10-03 -------------------- diff --git a/scim2_tester/checkers/resource_query_attributes.py b/scim2_tester/checkers/resource_query_attributes.py index 5180f79..12d7096 100644 --- a/scim2_tester/checkers/resource_query_attributes.py +++ b/scim2_tester/checkers/resource_query_attributes.py @@ -2,6 +2,7 @@ from scim2_models import ListResponse from scim2_models import Mutability +from scim2_models import NotImplementedException from scim2_models import Path from scim2_models import Required from scim2_models import Resource @@ -284,12 +285,21 @@ def search_with_attributes( - :attr:`~scim2_tester.Status.SUCCESS`: Server correctly filters search response attributes - :attr:`~scim2_tester.Status.ERROR`: Server ignores attribute filtering on search endpoint - - :attr:`~scim2_tester.Status.SKIPPED`: Model has no suitable attributes to test + - :attr:`~scim2_tester.Status.SKIPPED`: Model has no suitable attributes to test, + or the server does not search several resource types at once .. pull-quote:: :rfc:`RFC 7644 Section 3.4.3 <7644#section-3.4.3>` "Clients MAY execute queries without passing parameters on the URL by using the HTTP POST verb combined with the ``/.search`` path extension." + + .. pull-quote:: :rfc:`RFC 7644 Section 3.12 <7644#section-3.12>` + + "501 (Not Implemented): Service provider does not support the request + operation, e.g., PATCH." + + A server that does not search several resource types at once answers 501. + The service provider configuration has no attribute to announce it. """ included, excluded = _pick_attribute_names(model) if included is None and excluded is None: @@ -310,8 +320,19 @@ def query_fn(query_parameters: ResponseParameters[Any]) -> Any: excluded_attributes=query_parameters.excluded_attributes, ), expected_status_codes=context.conf.expected_status_codes or [200], + raise_scim_errors=True, ) - return _run_attribute_checks( - context, model, test_obj, included, excluded, query_fn, "POST /.search" - ) + try: + return _run_attribute_checks( + context, model, test_obj, included, excluded, query_fn, "POST /.search" + ) + except NotImplementedException: + return [ + check_result( + context, + status=Status.SKIPPED, + reason="POST /.search: searching several resource types at once " + "is not supported by the server", + ) + ] diff --git a/scim2_tester/filling.py b/scim2_tester/filling.py index 39f4ba0..83eab2d 100644 --- a/scim2_tester/filling.py +++ b/scim2_tester/filling.py @@ -191,19 +191,19 @@ def fill_with_random_values( path.set(obj, value, strict=False) fix_primary_attributes(obj) - fix_reference_values(obj) + fix_reference_values(context, obj) return obj -def fix_reference_values(obj: BaseModel) -> None: - """Recursively fix ref/value consistency on an object and its children. +def fix_reference_values(context: "CheckContext", obj: BaseModel) -> None: + """Recursively fix ref/value/type consistency on an object and its children. - Walks the object tree and ensures that for any object with both - ``ref`` and ``value`` attributes, ``value`` matches the last segment - of the ``ref`` URL. + Walks the object tree. For any object with a ``ref`` attribute, ``value`` + is set to the last segment of the ``ref`` URL, and ``type`` is set to the + resource type of the referenced resource when it is one of the ``type`` examples. """ - _fix_ref_value(obj) + _fix_ref_value(context, obj) for field_name in type(obj).model_fields: child = getattr(obj, field_name, None) if child is None: @@ -211,18 +211,38 @@ def fix_reference_values(obj: BaseModel) -> None: if isinstance(child, list): for item in child: - _fix_ref_value(item) + _fix_ref_value(context, item) if isinstance(item, BaseModel): - fix_reference_values(item) + fix_reference_values(context, item) elif isinstance(child, BaseModel): - _fix_ref_value(child) - fix_reference_values(child) + _fix_ref_value(context, child) + fix_reference_values(context, child) -def _fix_ref_value(obj: Any) -> None: - """Set ``value`` to the last segment of ``ref`` if both attributes exist.""" - if hasattr(obj, "ref") and hasattr(obj, "value") and getattr(obj, "ref", None): - obj.value = obj.ref.rsplit("/", 1)[-1] +def _fix_ref_value(context: "CheckContext", obj: Any) -> None: + """Make ``value`` and ``type`` match the resource that ``ref`` points to.""" + ref = getattr(obj, "ref", None) + if not ref: + return + + if hasattr(obj, "value"): + obj.value = ref.rsplit("/", 1)[-1] + + type_field = type(obj).model_fields.get("type") + if type_field is None or not type_field.examples: + return + + resource_type = _referenced_resource_type(context, ref) + if resource_type in type_field.examples: + obj.type = resource_type + + +def _referenced_resource_type(context: "CheckContext", ref: str) -> str | None: + """Return the resource type of the created resource located at ``ref``.""" + for resource in context.resource_manager.resources: + if resource.meta and resource.meta.location == ref: + return resource.meta.resource_type + return None def fix_primary_attributes(obj: BaseModel) -> None: diff --git a/tests/test_filling.py b/tests/test_filling.py index 72d2368..5e70184 100644 --- a/tests/test_filling.py +++ b/tests/test_filling.py @@ -6,10 +6,13 @@ from unittest.mock import patch import pytest +from pydantic import Field from scim2_models import Email from scim2_models import EnterpriseUser from scim2_models import Group from scim2_models import GroupMember +from scim2_models import GroupMembership +from scim2_models import Meta from scim2_models import Mutability from scim2_models import PhoneNumber from scim2_models import Reference @@ -328,7 +331,7 @@ def test_fix_reference_values_in_nested_complex_attribute(testing_context, https assert manager.value == manager.ref.rsplit("/", 1)[-1] -def test_fix_ref_value_on_object_with_ref_and_value(): +def test_fix_ref_value_on_object_with_ref_and_value(testing_context): """Ensures _fix_ref_value corrects value from ref URL.""" from scim2_models.resources.enterprise_user import Manager @@ -336,23 +339,23 @@ def test_fix_ref_value_on_object_with_ref_and_value(): ref="http://example.com/Users/abc123", value="wrong-value", ) - _fix_ref_value(manager) + _fix_ref_value(testing_context, manager) assert manager.value == "abc123" -def test_fix_reference_values_on_root_complex_attribute(): +def test_fix_reference_values_on_root_complex_attribute(testing_context): """Ensures fix_reference_values fixes ref/value on the root object itself.""" member = Group.Members( ref="http://example.com/Users/real-user-id", value="random-wrong-value", ) - fix_reference_values(member) + fix_reference_values(testing_context, member) assert member.value == "real-user-id" -def test_fix_reference_values_on_list_of_members(): +def test_fix_reference_values_on_list_of_members(testing_context): """Ensures fix_reference_values fixes ref/value in list attributes.""" group = Group(display_name="test") group.members = [ @@ -365,7 +368,89 @@ def test_fix_reference_values_on_list_of_members(): value="wrong", ), ] - fix_reference_values(group) + fix_reference_values(testing_context, group) assert group.members[0].value == "user1" assert group.members[1].value == "group2" + + +def _register_created_resource(context, resource_type, location): + """Register a resource as if the server had created it at ``location``.""" + model = Group if resource_type == "Group" else User + context.resource_manager.resources.append( + model(id="target", meta=Meta(resource_type=resource_type, location=location)) + ) + + +def test_fix_ref_value_sets_member_type_to_the_referenced_resource_type( + testing_context, +): + """A group member type matches the resource type that its ref points to.""" + location = "http://example.com/Groups/target" + _register_created_resource(testing_context, "Group", location) + member = Group.Members(ref=location, value="wrong", type="User") + + _fix_ref_value(testing_context, member) + + assert member.type == "Group" + + +def test_fix_ref_value_keeps_type_that_is_not_a_resource_type(testing_context): + """The direct or indirect type of a group membership is left unchanged.""" + location = "http://example.com/Groups/target" + _register_created_resource(testing_context, "Group", location) + membership = GroupMembership(ref=location, value="wrong", type="direct") + + _fix_ref_value(testing_context, membership) + + assert membership.type == "direct" + + +def test_fix_ref_value_keeps_type_when_ref_points_to_an_unknown_resource( + testing_context, +): + """The type is left unchanged when no created resource is located at ref.""" + member = Group.Members(ref="http://example.com/Users/unknown", type="Group") + + _fix_ref_value(testing_context, member) + + assert member.type == "Group" + + +def test_fill_group_members_type_matches_the_referenced_resource( + testing_context, httpserver +): + """The members generated for a group have a type consistent with their ref.""" + httpserver.expect_request("/Users", method="POST").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "member-id", + "userName": "member", + "meta": { + "resourceType": "User", + "location": f"http://localhost:{httpserver.port}/Users/member-id", + }, + }, + status=201, + ) + + group = fill_with_random_values(testing_context, Group(display_name="test")) + + assert group.members[0].type == "User" + + +def test_fix_ref_value_on_object_with_ref_and_no_value(testing_context): + """The type is fixed on an object that has a ref but no value.""" + + class Link(ComplexAttribute): + ref: str | None = None + type: Annotated[str | None, Field(examples=["User", "Group"])] = None + + location = "http://example.com/Groups/target" + _register_created_resource(testing_context, "Group", location) + link = Link(ref=location, type="User") + + _fix_ref_value(testing_context, link) + + assert link.ref == location + assert link.type == "Group" diff --git a/tests/test_resource_query_attributes.py b/tests/test_resource_query_attributes.py index d94c839..062ca8d 100644 --- a/tests/test_resource_query_attributes.py +++ b/tests/test_resource_query_attributes.py @@ -324,3 +324,25 @@ def test_search_skipped_when_no_suitable_attributes(httpserver, testing_context) results = search_with_attributes(testing_context, User) assert len(results) == 1 assert results[0].status == Status.SKIPPED + + +def test_search_skipped_when_the_server_does_not_search_at_the_root( + httpserver, testing_context +): + """POST /.search is skipped when the server answers 501 Not Implemented.""" + _setup_creation_and_deletion(httpserver) + httpserver.expect_request(uri="/.search", method="POST").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "501", + "detail": "Searching several resource types at once is not supported", + }, + status=501, + content_type="application/scim+json", + ) + + results = search_with_attributes(testing_context, User) + + assert len(results) == 1 + assert results[0].status == Status.SKIPPED + assert "several resource types" in results[0].reason