diff --git a/doc/changelog.rst b/doc/changelog.rst index ce23e88..db6bf44 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -41,6 +41,22 @@ Changed case-insensitive: ``Email(type="WORK").type`` is ``Email.Type.work``. - The JSON schema of those attributes advertises the canonical values as ``examples`` instead of a restrictive ``enum``. +- The ``schemas`` attribute of SCIM payloads is built from the model definition on + serialization, as it describes the serialized document rather than the object. It holds what + a peer asserted, and is empty when a payload omitted it, so the omission stays visible in + ``model_fields_set``. Objects built by the caller are still filled, as the model they are + built from asserts their type. +- Resources omitting their ``schemas`` attribute are read instead of being rejected, which + covers the partial responses of :rfc:`7644` §3.4.3. Their type comes from the + :class:`~scim2_models.ListResponse` parameter, so a response holding several resource types + still cannot decide the type of an unlabelled resource. :issue:`20` +- A ``schemas`` attribute that does not contain the model base schema is rejected whatever the + validation context, as an object cannot contradict the model it is an instance of. It used to + be accepted without a SCIM context. +- The ``schemas`` attribute is not subject to attribute filtering anymore, as :rfc:`7643` §3 + requires it in every representation. It lost its :attr:`~scim2_models.Returned.always` + annotation, which :rfc:`7643` does not define for it, and which the filtering exemption + replaces. Fixed ^^^^^ diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 1ac7487..b2d72d0 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -584,7 +584,10 @@ modified. >>> from scim2_models import User, Context >>> existing = User(user_name="bjensen") >>> replacement = User.model_validate( - ... {"userName": "bjensen"}, + ... { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + ... "userName": "bjensen", + ... }, ... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, ... ) >>> replacement.replace(existing) diff --git a/scim2_models/base.py b/scim2_models/base.py index c318043..1369231 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -657,6 +657,10 @@ def _scim_response_serializer( ) -> None: """Serialize the fields according to returnability indications passed in the serialization context.""" for alias in set(serialized): + # RFC7643 §3 requires 'schemas' in every representation + if alias == "schemas": + continue + field_name = self.__scim_info__.alias_to_field.get(alias, alias) returnability = self.get_field_annotation(field_name, Returned) attribute_urn = self.get_attribute_urn(field_name) diff --git a/scim2_models/messages/message.py b/scim2_models/messages/message.py index 024ae52..9ed66f0 100644 --- a/scim2_models/messages/message.py +++ b/scim2_models/messages/message.py @@ -46,9 +46,12 @@ def get_schema_from_payload(payload: Any) -> str | None: if not payload: return None - payload_schemas = ( - payload.get("schemas", []) if isinstance(payload, dict) else payload.schemas - ) + if isinstance(payload, dict): + payload_schemas = payload.get("schemas", []) + else: + # An instance asserts its type by its class. + schema = getattr(type(payload), "__schema__", None) + payload_schemas = ([str(schema)] if schema else []) + list(payload.schemas) common_schemas = [ schema for schema in payload_schemas if schema in resource_types_schemas diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index e8ab614..87d138b 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -17,7 +17,6 @@ from pydantic import ValidationInfo from pydantic import ValidatorFunctionWrapHandler from pydantic import WrapSerializer -from pydantic import field_serializer from pydantic import model_validator from pydantic_core import PydanticCustomError from typing_extensions import Self @@ -360,16 +359,9 @@ def get_by_payload( ) return get_model_by_payload(resource_types, payload, **kwargs) - @field_serializer("schemas") - def set_extension_schemas( - self, schemas: Annotated[list[str], Required.true] - ) -> list[str]: - """Add model extension ids to the 'schemas' attribute.""" - extension_schemas = self.get_extension_models().keys() - schemas = self.schemas + [ - schema for schema in extension_schemas if schema not in self.schemas - ] - return schemas + def _model_schemas(self) -> list[str]: + """List the base schema and the schemas of the declared extensions.""" + return super()._model_schemas() + list(self.get_extension_models()) @model_validator(mode="wrap") @classmethod diff --git a/scim2_models/scim_object.py b/scim2_models/scim_object.py index b83d5c5..6b1ec4c 100644 --- a/scim2_models/scim_object.py +++ b/scim2_models/scim_object.py @@ -5,14 +5,15 @@ from typing import ClassVar from typing import TypeVar +from pydantic import Field from pydantic import ValidationInfo from pydantic import ValidatorFunctionWrapHandler +from pydantic import field_serializer from pydantic import model_validator from pydantic_core import PydanticCustomError from typing_extensions import Self from .annotations import Required -from .annotations import Returned from .base import BaseModel from .context import Context from .path import URN @@ -21,36 +22,70 @@ class ScimObject(BaseModel): __schema__: ClassVar[URN | None] = None - schemas: Annotated[list[str], Required.true, Returned.always] + schemas: Annotated[list[str], Required.true] = Field(default_factory=list) """The "schemas" attribute is a REQUIRED attribute and is an array of Strings containing URIs that are used to indicate the namespaces of the SCIM schemas that define the attributes present in the current JSON - structure.""" + structure. + + It only holds what a peer asserted, and is empty when a payload omitted + it: SCIM dumps build it from the model definition. + """ + + def _model_schemas(self) -> list[str]: + """List the schemas asserted by the model definition.""" + schema = getattr(self.__class__, "__schema__", None) + return [str(schema)] if schema else [] + + @field_serializer("schemas") + def _serialize_schemas(self, schemas: list[str]) -> list[str]: + """Build the 'schemas' attribute from the model definition. + + Unknown schemas a peer sent are kept, as :rfc:`RFC7643 §3 + <7643#section-3>` does not restrict the array to known schemas. + """ + serialized = self._model_schemas() + for schema in schemas: + if schema not in serialized: + serialized.append(schema) + return serialized @model_validator(mode="before") @classmethod - def _populate_schemas_default(cls, data: Any) -> Any: - """Auto-generate schemas from __schema__ if not provided.""" - if isinstance(data, dict) and "schemas" not in data: - schema = getattr(cls, "__schema__", None) - if schema: - data = {**data, "schemas": [schema]} - return data + def _populate_schemas_default(cls, data: Any, info: ValidationInfo) -> Any: + """Fill the schemas of objects built by the caller. + + The model they are built from asserts their type. Payloads validated + in a SCIM context come from a peer, so what they omitted stays + omitted. + """ + if not isinstance(data, dict) or "schemas" in data: + return data + + schema = getattr(cls, "__schema__", None) + if not schema: + return data + + scim_ctx = info.context.get("scim") if info.context else None + if scim_ctx and scim_ctx != Context.DEFAULT: + return data + + return {**data, "schemas": [schema]} @model_validator(mode="wrap") @classmethod def _validate_schemas_attribute( cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> Self: - """Validate that the base schema is present in schemas attribute.""" - obj: Self = handler(value) + """Validate that the schemas a payload asserts match the model. - scim_ctx = info.context.get("scim") if info.context else None - if scim_ctx is None or scim_ctx == Context.DEFAULT: - return obj + An object cannot contradict the model it is an instance of, whatever + the validation context. An omitted attribute asserts nothing. + """ + obj: Self = handler(value) schema = getattr(cls, "__schema__", None) - if schema and schema not in obj.schemas: + if schema and obj.schemas and schema not in obj.schemas: raise PydanticCustomError( "schema_error", "schemas must contain the base schema '{schema}'", diff --git a/tests/test_list_response.py b/tests/test_list_response.py index acc8dee..d2d78cc 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -164,6 +164,51 @@ def test_missing_resource_schema(load_sample): ListResponse[User].model_validate(payload, strict=True) +def test_resources_without_schemas_are_read(load_sample): + """Resources omitting their 'schemas' attribute are read against the ListResponse parameter. + + :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>` displays partial responses + where resources bear no 'schemas' attribute. A single-typed ListResponse + knows their type, so they are read as-is and the attribute is rebuilt on + serialization. + """ + payload = load_sample("rfc7644-3.4.3-list_response-post_query.json") + + response = ListResponse[User].model_validate( + payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + + assert [resource.id for resource in response.resources] == [ + "2819c223-7f76-413861904646", + "c8596b90-7539-4f20968d1908", + ] + assert all(resource.schemas == [] for resource in response.resources) + assert all( + resource["schemas"] == ["urn:ietf:params:scim:schemas:core:2.0:User"] + for resource in response.model_dump()["Resources"] + ) + + +def test_resources_without_schemas_need_a_single_type(load_sample): + """A ListResponse holding several types cannot guess the type of an unlabelled resource. + + The :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>` example is undecidable: + its second resource only bears 'id' and 'displayName', which both + :class:`~scim2_models.User` and :class:`~scim2_models.Group` define. + """ + payload = load_sample("rfc7644-3.4.3-list_response-post_query.json") + + with pytest.raises(ValidationError) as exc_info: + ListResponse[User | Group].model_validate( + payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + + assert [error["type"] for error in exc_info.value.errors()] == [ + "union_tag_not_found", + "union_tag_not_found", + ] + + def test_zero_results(): """:rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>` indicates that ListResponse.Resources is required when ListResponse.totalResults is non- zero. @@ -172,6 +217,7 @@ def test_zero_results(): zero. """ payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "totalResults": 1, "Resources": [ { @@ -183,10 +229,17 @@ def test_zero_results(): } ListResponse[User].model_validate(payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE) - payload = {"totalResults": 1, "Resources": []} + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 1, + "Resources": [], + } ListResponse[User].model_validate(payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE) - payload = {"totalResults": 1} + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 1, + } with pytest.raises(ValidationError): ListResponse[User].model_validate( payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE @@ -328,6 +381,7 @@ def test_model_dump_without_scim_context(): def test_total_results_required(): """ListResponse.total_results is required.""" payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "Resources": [ { "schemas": [ diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py index 91efbbd..30493a8 100644 --- a/tests/test_model_validation.py +++ b/tests/test_model_validation.py @@ -9,7 +9,9 @@ from scim2_models.attributes import ComplexAttribute from scim2_models.context import Context from scim2_models.path import URN +from scim2_models.resources.enterprise_user import EnterpriseUser from scim2_models.resources.resource import Resource +from scim2_models.resources.user import User class RetResource(Resource): @@ -41,6 +43,7 @@ def test_validate_default_mutability(): """Test query validation for resource creation request.""" assert MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "readOnly": "x", "readWrite": "x", "immutable": "x", @@ -56,6 +59,7 @@ def test_validate_default_mutability(): assert MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "readOnly": "x", "readWrite": "x", "immutable": "x", @@ -72,6 +76,7 @@ def test_validate_default_mutability(): assert MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "readOnly": "x", "readWrite": "x", "immutable": "x", @@ -95,6 +100,7 @@ def test_validate_creation_request_mutability(): """ assert MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "readWrite": "x", "immutable": "x", "writeOnly": "x", @@ -117,6 +123,7 @@ def test_validate_query_request_mutability(): """ assert MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "readOnly": "x", "readWrite": "x", "immutable": "x", @@ -135,6 +142,7 @@ def test_validate_query_request_mutability(): ): MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "writeOnly": "x", }, scim_ctx=Context.RESOURCE_QUERY_REQUEST, @@ -145,7 +153,8 @@ def test_mutability_error_is_located_on_the_field(): """Mutability errors carry the location of the offending field.""" with pytest.raises(ValidationError) as exc_info: MutResource.model_validate( - {"writeOnly": "x"}, scim_ctx=Context.RESOURCE_QUERY_REQUEST + {"schemas": ["urn:example:MutResource"], "writeOnly": "x"}, + scim_ctx=Context.RESOURCE_QUERY_REQUEST, ) assert [error["loc"] for error in exc_info.value.errors()] == [("write_only",)] @@ -164,7 +173,8 @@ class SubResource(Resource): with pytest.raises(ValidationError) as exc_info: SubResource.model_validate( - {"subs": [{"writeOnly": "x"}]}, scim_ctx=Context.RESOURCE_QUERY_REQUEST + {"schemas": ["urn:example:SubResource"], "subs": [{"writeOnly": "x"}]}, + scim_ctx=Context.RESOURCE_QUERY_REQUEST, ) assert [error["loc"] for error in exc_info.value.errors()] == [ @@ -183,6 +193,7 @@ def test_validate_replacement_request_mutability(): with pytest.warns(DeprecationWarning, match="original"): assert MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "readOnly": "x", "readWrite": "x", "writeOnly": "x", @@ -201,6 +212,7 @@ def test_validate_replacement_request_mutability(): with pytest.warns(DeprecationWarning, match="original"): MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "immutable": "y", }, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, @@ -214,6 +226,7 @@ def test_validate_replacement_request_mutability(): ): MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "immutable": "x", }, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, @@ -240,9 +253,10 @@ class Super(Resource): with pytest.warns(DeprecationWarning, match="original"): assert Super.model_validate( { + "schemas": ["urn:example:Super"], "sub": { "immutable": "y", - } + }, }, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, original=original, @@ -256,9 +270,10 @@ class Super(Resource): with pytest.warns(DeprecationWarning, match="original"): Super.model_validate( { + "schemas": ["urn:example:Super"], "sub": { "immutable": "y", - } + }, }, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, original=original, @@ -271,9 +286,10 @@ class Super(Resource): ): Super.model_validate( { + "schemas": ["urn:example:Super"], "sub": { "immutable": "x", - } + }, }, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, original=original, @@ -430,7 +446,7 @@ def test_original_parameter_emits_deprecation_warning(): original = MutResource(immutable="y") with pytest.warns(DeprecationWarning, match="original"): MutResource.model_validate( - {"immutable": "y"}, + {"schemas": ["urn:example:MutResource"], "immutable": "y"}, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, original=original, ) @@ -442,7 +458,7 @@ def test_replacement_request_without_original_parameter(): original = MutResource(immutable="y") replacement = MutResource.model_validate( - {"immutable": "x"}, + {"schemas": ["urn:example:MutResource"], "immutable": "x"}, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, ) with pytest.raises(MutabilityException): @@ -453,7 +469,7 @@ def test_replacement_request_without_original_allows_matching_values(): """Replacement requests validate and replace succeeds with identical immutable values.""" original = MutResource(immutable="y") replacement = MutResource.model_validate( - {"immutable": "y"}, + {"schemas": ["urn:example:MutResource"], "immutable": "y"}, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, ) replacement.replace(original) @@ -467,6 +483,7 @@ def test_validate_search_request_mutability(): """ assert MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "readOnly": "x", "readWrite": "x", "immutable": "x", @@ -485,6 +502,7 @@ def test_validate_search_request_mutability(): ): MutResource.model_validate( { + "schemas": ["urn:example:MutResource"], "writeOnly": "x", }, scim_ctx=Context.SEARCH_REQUEST, @@ -588,7 +606,9 @@ def test_validate_response_returnability(context): ValidationError, match="Field 'always_returned' has returnability 'always' but value is missing or null", ): - RetResource.model_validate({"id": "id"}, scim_ctx=context) + RetResource.model_validate( + {"schemas": ["urn:example:RetResource"], "id": "id"}, scim_ctx=context + ) # always is None with pytest.raises( @@ -596,7 +616,12 @@ def test_validate_response_returnability(context): match="Field 'always_returned' has returnability 'always' but value is missing or null", ): RetResource.model_validate( - {"id": "id", "alwaysReturned": None}, scim_ctx=context + { + "schemas": ["urn:example:RetResource"], + "id": "id", + "alwaysReturned": None, + }, + scim_ctx=context, ) # never is not None @@ -606,6 +631,7 @@ def test_validate_response_returnability(context): ): RetResource.model_validate( { + "schemas": ["urn:example:RetResource"], "id": "id", "alwaysReturned": "x", "neverReturned": "x", @@ -618,6 +644,7 @@ def test_validate_default_necessity(): """Test query validation for resource creation request.""" assert ReqResource.model_validate( { + "schemas": ["urn:example:ReqResource"], "required": "x", "optional": "x", }, @@ -629,6 +656,7 @@ def test_validate_default_necessity(): assert ReqResource.model_validate( { + "schemas": ["urn:example:ReqResource"], "required": "x", "optional": "x", }, @@ -641,6 +669,7 @@ def test_validate_default_necessity(): assert ReqResource.model_validate( { + "schemas": ["urn:example:ReqResource"], "required": "x", "optional": "x", }, @@ -667,6 +696,7 @@ def test_validate_creation_and_replacement_request_necessity(context): """ assert ReqResource.model_validate( { + "schemas": ["urn:example:ReqResource"], "required": "x", "optional": "x", }, @@ -679,6 +709,7 @@ def test_validate_creation_and_replacement_request_necessity(context): assert ReqResource.model_validate( { + "schemas": ["urn:example:ReqResource"], "required": "x", }, scim_ctx=context, @@ -693,6 +724,7 @@ def test_validate_creation_and_replacement_request_necessity(context): ): ReqResource.model_validate( { + "schemas": ["urn:example:ReqResource"], "optional": "x", }, scim_ctx=context, @@ -714,6 +746,7 @@ def test_validate_query_and_search_request_necessity(context): """ assert ReqResource.model_validate( { + "schemas": ["urn:example:ReqResource"], "id": "x", "required": "x", "optional": "x", @@ -728,6 +761,7 @@ def test_validate_query_and_search_request_necessity(context): assert ReqResource.model_validate( { + "schemas": ["urn:example:ReqResource"], "id": "x", "required": "x", }, @@ -740,6 +774,7 @@ def test_validate_query_and_search_request_necessity(context): assert ReqResource.model_validate( { + "schemas": ["urn:example:ReqResource"], "id": "x", "optional": "x", }, @@ -754,16 +789,14 @@ def test_validate_query_and_search_request_necessity(context): def test_validate_json_payload(): """JSON payloads are validated like already decoded payloads.""" assert MutResource.model_validate_json('{"readWrite": "x"}') == MutResource( - schemas=["urn:example:MutResource"], - readWrite="x", + readWrite="x" ) def test_validate_json_bytes_payload(): """JSON payloads can be passed as bytes.""" assert MutResource.model_validate_json(b'{"readWrite": "x"}') == MutResource( - schemas=["urn:example:MutResource"], - readWrite="x", + readWrite="x" ) @@ -774,7 +807,7 @@ def test_validate_json_applies_the_scim_context(): match="Field 'write_only' has mutability 'writeOnly' but this in not valid in resource query request context", ): MutResource.model_validate_json( - '{"writeOnly": "x"}', + '{"schemas": ["urn:example:MutResource"], "writeOnly": "x"}', scim_ctx=Context.RESOURCE_QUERY_REQUEST, ) @@ -786,7 +819,7 @@ def test_validate_json_with_an_explicit_validation_context(): match="Field 'write_only' has mutability 'writeOnly' but this in not valid in resource query request context", ): MutResource.model_validate_json( - '{"writeOnly": "x"}', + '{"schemas": ["urn:example:MutResource"], "writeOnly": "x"}', context={"scim": Context.RESOURCE_QUERY_REQUEST}, ) @@ -795,3 +828,118 @@ def test_validate_json_rejects_malformed_payloads(): """Malformed JSON payloads raise a ValidationError like any invalid payload.""" with pytest.raises(ValidationError, match="Invalid JSON"): MutResource.model_validate_json("{invalid") + + +@pytest.mark.parametrize( + "context", + [Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_QUERY_RESPONSE], +) +def test_missing_schemas_is_tolerated(context): + """A payload omitting 'schemas' asserts nothing, so it contradicts nothing. + + :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>` displays partial responses + where resources bear no 'schemas' attribute. Their type comes from the + model they are validated against, so they are read as-is, and the + omission stays visible. + """ + obj = User.model_validate({"id": "id", "userName": "foobar"}, scim_ctx=context) + + assert obj.schemas == [] + assert "schemas" not in obj.model_fields_set + + +def test_serialized_schemas_come_from_the_model(): + """The 'schemas' attribute of SCIM payloads is built from the model definition. + + It describes the serialized document rather than the object, so it is + written even when the validated payload omitted it. + """ + obj = User.model_validate( + {"id": "id", "userName": "foobar"}, scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + assert obj.schemas == [] + assert obj.model_dump()["schemas"] == ["urn:ietf:params:scim:schemas:core:2.0:User"] + + assert User[EnterpriseUser](user_name="foobar").model_dump()["schemas"] == [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ] + + +def test_serialized_schemas_keep_unknown_schemas(): + """Schemas a peer sent that the model does not know of are kept. + + :rfc:`RFC7643 §3 <7643#section-3>` does not restrict the array to the + schemas a given implementation knows about. + """ + obj = User.model_validate( + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:example:unknown", + ], + "userName": "foobar", + } + ) + + assert obj.model_dump()["schemas"] == [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:example:unknown", + ] + + +@pytest.mark.parametrize( + "context", + [ + Context.DEFAULT, + Context.RESOURCE_CREATION_REQUEST, + Context.RESOURCE_QUERY_RESPONSE, + ], +) +def test_contradicting_schemas_are_reported(context): + """A 'schemas' attribute that does not contain the model base schema contradicts it.""" + with pytest.raises(ValidationError, match="schemas must contain the base schema"): + User.model_validate( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": "id", + "userName": "foobar", + }, + scim_ctx=context, + ) + + +def test_extension_schemas_are_left_to_the_resource(): + """Extensions are not standalone representations, and bear no 'schemas' attribute of their own.""" + payload = { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ], + "id": "id", + "userName": "foobar", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "employeeNumber": "701984", + }, + } + obj = User[EnterpriseUser].model_validate( + payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + + assert obj[EnterpriseUser].employee_number == "701984" + assert obj[EnterpriseUser].schemas == [] + assert ( + "schemas" + not in obj.model_dump()[ + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + ] + ) + + +def test_serialized_schemas_can_be_excluded(): + """An explicitly excluded 'schemas' attribute is not built back. + + :class:`~scim2_models.SearchRequest` is dumped that way to build query + strings, which bear no 'schemas' parameter. + """ + assert "schemas" not in User(user_name="foobar").model_dump(exclude={"schemas"}) diff --git a/tests/test_models.py b/tests/test_models.py index aa25bba..8cdff70 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -49,8 +49,9 @@ def test_parse_and_serialize_examples(load_sample): model = SAMPLE_MODELS[model_name] skipped = [ - # resources without schemas are not yet supported - # https://github.com/python-scim/scim2-models/issues/20 + # Those resources bear no schemas, and the model they are validated + # against holds several types, so their type cannot be decided. + # tests/test_list_response.py covers the single-typed case. "rfc7644-3.4.2-list_response-partial_attributes.json", "rfc7644-3.4.3-list_response-post_query.json", # BulkOperation.data PatchOperation.value should be of type resource diff --git a/tests/test_patch_op_validation.py b/tests/test_patch_op_validation.py index 9f9ea8d..0283cc2 100644 --- a/tests/test_patch_op_validation.py +++ b/tests/test_patch_op_validation.py @@ -119,6 +119,7 @@ def test_validate_patchop_case_insensitivity(): """ assert PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "Replace", "path": "userName", "value": "Rivard"}, {"op": "ADD", "path": "userName", "value": "Rivard"}, @@ -126,6 +127,7 @@ def test_validate_patchop_case_insensitivity(): ], }, ) == PatchOp[User]( + schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"], operations=[ PatchOperation[User]( op=PatchOperation.Op.replace_, path="userName", value="Rivard" @@ -136,7 +138,7 @@ def test_validate_patchop_case_insensitivity(): PatchOperation[User]( op=PatchOperation.Op.remove, path="userName", value="Rivard" ), - ] + ], ) with pytest.raises( ValidationError, @@ -144,6 +146,7 @@ def test_validate_patchop_case_insensitivity(): ): PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [{"op": 42, "path": "userName", "value": "Rivard"}], }, ) @@ -157,6 +160,7 @@ def test_path_required_for_remove_operations(): """ PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "replace", "value": "foobar"}, ], @@ -165,6 +169,7 @@ def test_path_required_for_remove_operations(): ) PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "add", "value": "foobar"}, ], @@ -176,6 +181,7 @@ def test_path_required_for_remove_operations(): with pytest.raises(ValidationError, match="Remove operation requires a path"): PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "remove", "value": "foobar"}, ], @@ -192,6 +198,7 @@ def test_value_required_for_add_operations(): """ PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "replace", "path": "foobar"}, ], @@ -201,6 +208,7 @@ def test_value_required_for_add_operations(): with pytest.raises(ValidationError): PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "add", "path": "foobar"}, ], @@ -210,6 +218,7 @@ def test_value_required_for_add_operations(): PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "remove", "path": "foobar"}, ], @@ -256,7 +265,10 @@ def test_validate_mutability_readonly_error(): ]: with pytest.raises(ValidationError, match="mutability"): PatchOp[User].model_validate( - {"operations": [{"op": op, "path": "id", **extra}]}, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{"op": op, "path": "id", **extra}], + }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) @@ -266,13 +278,14 @@ def test_validate_mutability_readonly_via_complex_path(): with pytest.raises(ValidationError, match="mutability"): PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ { "op": "replace", "path": "groups.value", "value": "new-group-id", } - ] + ], }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) @@ -282,7 +295,10 @@ def test_patch_remove_on_immutable_field_with_value_is_rejected(): """Removing an existing immutable attribute via PATCH is rejected at runtime.""" resource = ImmutableFieldResource.model_construct(locked="existing") patch_op = PatchOp[ImmutableFieldResource].model_validate( - {"operations": [{"op": "remove", "path": "locked"}]}, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{"op": "remove", "path": "locked"}], + }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) with pytest.raises(MutabilityException): @@ -293,7 +309,10 @@ def test_patch_remove_on_immutable_field_without_value_is_allowed(): """Removing an unset immutable attribute is a no-op and is allowed.""" resource = ImmutableFieldResource.model_construct() patch_op = PatchOp[ImmutableFieldResource].model_validate( - {"operations": [{"op": "remove", "path": "locked"}]}, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{"op": "remove", "path": "locked"}], + }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) patch_op.patch(resource) @@ -304,7 +323,10 @@ def test_patch_add_on_immutable_field_with_existing_value_is_rejected(): """Adding to an immutable attribute that already has a value is rejected.""" resource = ImmutableFieldResource.model_construct(locked="existing") patch_op = PatchOp[ImmutableFieldResource].model_validate( - {"operations": [{"op": "add", "path": "locked", "value": "new"}]}, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{"op": "add", "path": "locked", "value": "new"}], + }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) with pytest.raises(MutabilityException): @@ -315,7 +337,10 @@ def test_patch_add_on_immutable_field_without_value_is_allowed(): """Adding to an immutable attribute with no previous value is allowed per RFC 7644.""" resource = ImmutableFieldResource.model_construct() patch_op = PatchOp[ImmutableFieldResource].model_validate( - {"operations": [{"op": "add", "path": "locked", "value": "initial"}]}, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{"op": "add", "path": "locked", "value": "initial"}], + }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) patch_op.patch(resource) @@ -326,7 +351,10 @@ def test_patch_replace_on_immutable_field_with_different_value_is_rejected(): """Replacing an immutable attribute with a different value is rejected.""" resource = ImmutableFieldResource.model_construct(locked="existing") patch_op = PatchOp[ImmutableFieldResource].model_validate( - {"operations": [{"op": "replace", "path": "locked", "value": "other"}]}, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{"op": "replace", "path": "locked", "value": "other"}], + }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) with pytest.raises(MutabilityException): @@ -337,7 +365,10 @@ def test_patch_replace_on_immutable_field_with_same_value_is_allowed(): """Replacing an immutable attribute with its current value is a no-op and is allowed.""" resource = ImmutableFieldResource.model_construct(locked="existing") patch_op = PatchOp[ImmutableFieldResource].model_validate( - {"operations": [{"op": "replace", "path": "locked", "value": "existing"}]}, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{"op": "replace", "path": "locked", "value": "existing"}], + }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) patch_op.patch(resource) @@ -348,7 +379,10 @@ def test_patch_remove_on_readonly_field_is_rejected(): """Removing a readOnly attribute via PATCH is rejected per RFC 7643 §7.""" with pytest.raises(ValidationError, match="mutability"): PatchOp[User].model_validate( - {"operations": [{"op": "remove", "path": "id"}]}, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{"op": "remove", "path": "id"}], + }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) @@ -357,9 +391,10 @@ def test_patch_validation_allows_unknown_fields(): """Patch operations on unknown fields pass without mutability checks.""" patch_op = PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "add", "path": "unknownField", "value": "some-value"}, - ] + ], }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) @@ -371,10 +406,11 @@ def test_patch_operations_on_readwrite_fields_allowed(): """All patch operations are allowed on readWrite fields.""" patch_op = PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "add", "path": "nickName", "value": "test-nick"}, {"op": "remove", "path": "nickName"}, - ] + ], }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) @@ -385,9 +421,10 @@ def test_remove_operation_on_unknown_field_validates(): """Test remove operation on unknown field validates successfully.""" patch_op = PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "remove", "path": "unknownField"}, - ] + ], }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) @@ -400,9 +437,10 @@ def test_remove_operation_on_non_required_field_allowed(): # nickName is not required, so remove should be allowed PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "remove", "path": "nickName"}, - ] + ], }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) @@ -452,6 +490,7 @@ def test_add_remove_operations_on_group_members_allowed(): # Test operations on group collection (not the immutable value field) patch_op = PatchOp[User].model_validate( { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "operations": [ {"op": "add", "path": "emails", "value": {"value": "test@example.com"}}, { @@ -459,7 +498,7 @@ def test_add_remove_operations_on_group_members_allowed(): "path": "emails", "value": {"value": "test@example.com"}, }, - ] + ], }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) @@ -556,7 +595,10 @@ def test_validate_required_field_removal(): # Test removing schemas (required field) should raise validation error with pytest.raises(ValidationError, match="required attribute cannot be removed"): PatchOp[User].model_validate( - {"operations": [{"op": "remove", "path": "schemas"}]}, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{"op": "remove", "path": "schemas"}], + }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py index 4098fc3..3ec3dd5 100644 --- a/tests/test_schema_validation.py +++ b/tests/test_schema_validation.py @@ -51,25 +51,34 @@ def test_validation_valid_extension_schema(): assert len(user.schemas) == 2 -def test_schemas_auto_populated(): - """Schemas is auto-populated from __schema__ when not provided.""" - user = User(user_name="foo") - assert user.schemas == ["urn:ietf:params:scim:schemas:core:2.0:User"] +def test_schemas_built_from_the_model(): + """The serialized 'schemas' attribute comes from __schema__.""" + user = User.model_validate( + {"id": "id", "userName": "foo"}, scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + assert user.schemas == [] + assert user.model_dump()["schemas"] == [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ] -def test_no_validation_without_context(): - """No schema validation without SCIM context.""" - user = User.model_validate({"schemas": ["wrong:schema"], "userName": "foo"}) - assert user.schemas == ["wrong:schema"] +def test_validation_without_context(): + """A payload contradicting the model is rejected without a SCIM context. + An object cannot contradict the model it is an instance of, so the check + does not depend on the validation context. + """ + with pytest.raises(ValidationError, match="schemas must contain"): + User.model_validate({"schemas": ["wrong:schema"], "userName": "foo"}) -def test_no_validation_with_default_context(): - """No schema validation with DEFAULT context.""" - user = User.model_validate( - {"schemas": ["wrong:schema"], "userName": "foo"}, - context={"scim": Context.DEFAULT}, - ) - assert user.schemas == ["wrong:schema"] + +def test_validation_with_default_context(): + """A payload contradicting the model is rejected in the DEFAULT context.""" + with pytest.raises(ValidationError, match="schemas must contain"): + User.model_validate( + {"schemas": ["wrong:schema"], "userName": "foo"}, + context={"scim": Context.DEFAULT}, + ) def test_schema_classvar_defined(): @@ -194,3 +203,14 @@ class NoSchemaResource(Resource): scim_ctx=Context.RESOURCE_QUERY_RESPONSE, ) assert resource.schemas == ["urn:example:whatever"] + + +def test_serialized_schemas_of_a_model_without_schema(): + """A model with no schema of its own has no schemas to build.""" + + class NoSchemaResource(Resource): + pass + + NoSchemaResource.__schema__ = None # type: ignore[assignment] + + assert NoSchemaResource(id="id").model_dump()["schemas"] == []