Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -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
--------------------

Expand Down
29 changes: 25 additions & 4 deletions scim2_tester/checkers/resource_query_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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",
)
]
50 changes: 35 additions & 15 deletions scim2_tester/filling.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,38 +191,58 @@ 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:
continue

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:
Expand Down
97 changes: 91 additions & 6 deletions tests/test_filling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -328,31 +331,31 @@ 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

manager = Manager(
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 = [
Expand All @@ -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"
22 changes: 22 additions & 0 deletions tests/test_resource_query_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading