diff --git a/doc/changelog.rst b/doc/changelog.rst index c039473..d15f284 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -21,6 +21,10 @@ Deprecated - Defining ``schemas`` with a default value is deprecated. Use ``__schema__ = URN("...")`` instead. - ``Error.make_*_error()`` methods are deprecated. Use ``.to_error()`` instead. +Fixed +^^^^^ +- Only allow one primary complex attribute value to be true. :issue:`10` + [0.5.2] - 2026-01-22 -------------------- diff --git a/scim2_models/base.py b/scim2_models/base.py index 621fd80..c6a0fa8 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -364,6 +364,49 @@ 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. + """ + scim_context = info.context.get("scim") if info.context else None + if not scim_context or scim_context == Context.DEFAULT: + return self + + for field_name in self.__class__.model_fields: + if not self.get_field_multiplicity(field_name): + continue + + field_value = getattr(self, field_name) + if field_value is None: + continue + + element_type = self.get_field_root_type(field_name) + if ( + element_type is None + or not isclass(element_type) + or not issubclass(element_type, PydanticBaseModel) + or "primary" not in element_type.model_fields + ): + continue + + primary_count = sum( + 1 for item in field_value if 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..6c6fe23 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,5 +1,8 @@ import datetime +import pytest +from pydantic import ValidationError + from scim2_models import Address from scim2_models import Email from scim2_models import Im @@ -7,6 +10,7 @@ from scim2_models import Photo from scim2_models import Reference from scim2_models import User +from scim2_models.context import Context def test_minimal_user(load_sample): @@ -124,3 +128,67 @@ def test_full_user(load_sample): obj.meta.location == "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" ) + + +def test_multiple_emails_without_primary_is_valid(): + """Test that multiple emails without any primary attribute is valid.""" + 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, scim_ctx=Context.RESOURCE_CREATION_REQUEST) + assert user.user_name == "testuser" + + +def test_single_primary_email_is_valid(): + """Test that exactly one primary email is valid per RFC 7643.""" + user_data = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "testuser", + "emails": [ + {"value": "primary@example.com", "type": "work", "primary": True}, + {"value": "secondary@example.com", "type": "home", "primary": False}, + ], + } + user = User.model_validate(user_data, scim_ctx=Context.RESOURCE_CREATION_REQUEST) + assert user.emails[0].primary is True + assert user.emails[1].primary is False + + +def test_multiple_primary_emails_rejected(): + """Test that multiple primary emails are rejected per RFC 7643.""" + user_data = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "testuser", + "emails": [ + {"value": "primary1@example.com", "primary": True}, + {"value": "primary2@example.com", "primary": True}, + ], + } + + with pytest.raises(ValidationError) as exc_info: + User.model_validate(user_data, scim_ctx=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 + + +@pytest.mark.parametrize("scim_ctx", [None, Context.DEFAULT]) +def test_multiple_primary_validation_skipped_without_strict_context(scim_ctx): + """Test that primary validation is skipped when no strict SCIM context is provided.""" + user_data = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "testuser", + "emails": [ + {"value": "primary1@example.com", "primary": True}, + {"value": "primary2@example.com", "primary": True}, + ], + } + user = User.model_validate(user_data, scim_ctx=scim_ctx) + assert len(user.emails) == 2