diff --git a/doc/changelog.rst b/doc/changelog.rst index a498a1d..9b31878 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -4,6 +4,10 @@ Changelog [0.2.7] - Unreleased -------------------- +Fixed +^^^^^ +- Fix ``ref``/``value`` consistency for nested complex attributes like ``Manager``. :pr:`47` + Added ^^^^^ - Attribute filtering compliance checkers for ``attributes`` and ``excludedAttributes`` on single resource, list, and ``.search`` endpoints (:rfc:`7644` ยง3.4). :issue:`20` diff --git a/scim2_tester/filling.py b/scim2_tester/filling.py index beb0e2d..8e5a29a 100644 --- a/scim2_tester/filling.py +++ b/scim2_tester/filling.py @@ -7,6 +7,7 @@ from typing import Any from pydantic import Base64Bytes +from pydantic import BaseModel from scim2_models import ComplexAttribute from scim2_models import Extension from scim2_models import Mutability @@ -134,7 +135,7 @@ def generate_random_value( if path.is_multivalued: value = [value] - return fix_reference_values_in_value(value) + return value def fill_with_random_values( @@ -174,33 +175,37 @@ def fill_with_random_values( path.set(obj, value, strict=False) fix_primary_attributes(obj) + fix_reference_values(obj) return obj -def fix_reference_values_in_value(value: Any) -> Any: - """Fix reference values in any value to extract IDs from reference URLs. +def fix_reference_values(obj: BaseModel) -> None: + """Recursively fix ref/value consistency on an object and its children. - For SCIM reference fields, correctly sets the value field to match - the ID extracted from the reference URL. Works with both single values - and lists containing reference objects. + 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. """ - if isinstance(value, list): - for item in value: - if ( - hasattr(item, "ref") - and hasattr(item, "value") - and getattr(item, "ref", None) - ): - item.value = item.ref.rsplit("/", 1)[-1] - elif ( - hasattr(value, "ref") - and hasattr(value, "value") - and getattr(value, "ref", None) - ): - value.value = value.ref.rsplit("/", 1)[-1] + for field_name in type(obj).model_fields: + child = getattr(obj, field_name, None) + if child is None: + continue - return value + if isinstance(child, list): + for item in child: + _fix_ref_value(item) + if isinstance(item, BaseModel): + fix_reference_values(item) + elif isinstance(child, BaseModel): + _fix_ref_value(child) + fix_reference_values(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_primary_attributes(obj: Resource[Any]) -> None: diff --git a/tests/test_filling.py b/tests/test_filling.py index ae30e20..a0634e9 100644 --- a/tests/test_filling.py +++ b/tests/test_filling.py @@ -17,8 +17,10 @@ from scim2_models.resources.resource import Resource from scim2_models.resources.user import X509Certificate +from scim2_tester.filling import _fix_ref_value from scim2_tester.filling import fill_with_random_values from scim2_tester.filling import fix_primary_attributes +from scim2_tester.filling import fix_reference_values from scim2_tester.filling import generate_random_value from scim2_tester.filling import get_model_from_ref_type from scim2_tester.filling import get_random_example_value @@ -282,3 +284,68 @@ def test_generate_random_value_required_filter(testing_context): testing_context, Path[User]("userName"), required=[Required.true] ) assert result + + +def test_fix_reference_values_in_nested_complex_attribute(testing_context, httpserver): + """Ensures ref and value are consistent for nested complex attributes like Manager.""" + user_data = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "manager-id", + "userName": "manager-user", + "meta": { + "resourceType": "User", + "location": f"http://localhost:{httpserver.port}/Users/manager-id", + }, + } + httpserver.expect_request("/Users", method="POST").respond_with_json( + user_data, status=201 + ) + + manager_urn = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager" + enterprise_user = User[EnterpriseUser](user_name="test") + filled = fill_with_random_values( + testing_context, + enterprise_user, + [ + Path[User[EnterpriseUser]](manager_urn), + Path[User[EnterpriseUser]](f"{manager_urn}.value"), + Path[User[EnterpriseUser]](f"{manager_urn}.ref"), + ], + ) + + manager = filled[EnterpriseUser].manager + assert manager is not None + assert manager.ref is not None + assert manager.value == manager.ref.rsplit("/", 1)[-1] + + +def test_fix_ref_value_on_object_with_ref_and_value(): + """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) + + assert manager.value == "abc123" + + +def test_fix_reference_values_on_list_of_members(): + """Ensures fix_reference_values fixes ref/value in list attributes.""" + group = Group(display_name="test") + group.members = [ + Group.Members( + ref="http://example.com/Users/user1", + value="wrong", + ), + Group.Members( + ref="http://example.com/Groups/group2", + value="wrong", + ), + ] + fix_reference_values(group) + + assert group.members[0].value == "user1" + assert group.members[1].value == "group2"