diff --git a/doc/changelog.rst b/doc/changelog.rst index 7c2dae2..93d7a3c 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,13 @@ Changelog ========= +[0.4.3] - Unreleased +-------------------- + +Fixed +^^^^^ +- Only allow one primary complex attribute value to be true. :issue:`10` + [0.4.2] - 2025-08-05 -------------------- diff --git a/scim2_models/base.py b/scim2_models/base.py index 53c5d98..218cfb9 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -355,6 +355,55 @@ def check_replacement_request_mutability( cls._check_mutability_issues(original, obj) return obj + @model_validator(mode="after") + def check_primary_attribute_uniqueness(self, info: ValidationInfo) -> Self: + """Validate that only one attribute can be marked as primary in multi-valued lists. + + Per RFC 7643 Section 2.4: The primary attribute value 'true' MUST appear no more than once. + """ + from scim2_models.attributes import MultiValuedComplexAttribute + + scim_context = info.context.get("scim") if info.context else None + if not scim_context: + return self + + for field_name in self.__class__.model_fields: + # Check if field is multi-valued (list type) + if not self.get_field_multiplicity(field_name): + continue + + field_value = getattr(self, field_name) + if field_value is None: + continue + + # Check if items in the list have a 'primary' attribute + element_type = self.get_field_root_type(field_name) + if ( + element_type is None + or not isclass(element_type) + or not issubclass(element_type, MultiValuedComplexAttribute) + ): + continue + + primary_count = sum( + 1 + for item in field_value + if isinstance(item, PydanticBaseModel) + and getattr(item, "primary", None) is True + ) + + if primary_count > 1: + raise PydanticCustomError( + "primary_uniqueness_error", + "Field '{field_name}' has {count} items marked as primary, but only one is allowed per RFC 7643", + { + "field_name": field_name, + "count": primary_count, + }, + ) + + return self + @classmethod def _check_mutability_issues( cls, original: "BaseModel", replacement: "BaseModel" diff --git a/tests/test_user.py b/tests/test_user.py index 656f26d..aa1b602 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -124,3 +124,65 @@ def test_full_user(load_sample): obj.meta.location == "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" ) + + +def test_primary_attribute_validation_valid_cases(): + """Test primary attribute validation for valid cases (0 or 1 primary).""" + from scim2_models.context import Context + + # Case 1: No primary attributes + user_data = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "testuser", + "emails": [ + {"value": "test1@example.com", "type": "work"}, + {"value": "test2@example.com", "type": "home"}, + ], + } + user = User.model_validate( + user_data, context={"scim": Context.RESOURCE_CREATION_REQUEST} + ) + assert user.user_name == "testuser" + + # Case 2: Exactly one primary attribute + user_data_with_primary = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "testuser2", + "emails": [ + {"value": "primary@example.com", "type": "work", "primary": True}, + {"value": "secondary@example.com", "type": "home", "primary": False}, + ], + } + user_with_primary = User.model_validate( + user_data_with_primary, context={"scim": Context.RESOURCE_CREATION_REQUEST} + ) + assert user_with_primary.emails[0].primary is True + assert user_with_primary.emails[1].primary is False + + +def test_primary_attribute_validation_invalid_case(): + """Test primary attribute validation for invalid case (multiple primary).""" + import pytest + from pydantic import ValidationError + + from scim2_models.context import Context + + # Case: Multiple primary attributes (should fail) + user_data_invalid = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "testuser3", + "emails": [ + {"value": "primary1@example.com", "type": "work", "primary": True}, + {"value": "primary2@example.com", "type": "home", "primary": True}, + ], + } + + with pytest.raises(ValidationError) as exc_info: + User.model_validate( + user_data_invalid, context={"scim": Context.RESOURCE_CREATION_REQUEST} + ) + + error = exc_info.value.errors()[0] + assert error["type"] == "primary_uniqueness_error" + assert "emails" in error["ctx"]["field_name"] + assert error["ctx"]["count"] == 2