diff --git a/doc/changelog.rst b/doc/changelog.rst index 275595c..40b142b 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -10,6 +10,15 @@ Added - :meth:`Resource.replace ` returns whether the replacement changes the resource, the order of multi-valued entries aside. A server can keep ``meta.version`` and ``meta.lastModified`` when a PUT changes nothing. +- In the :attr:`~scim2_models.Context.BULK_REQUEST` context, an invalid operation of a + :class:`~scim2_models.BulkRequest` no longer fails the whole request. It becomes its own failed + result, with a ``status`` and an :class:`~scim2_models.Error`, as + :rfc:`RFC7644 §3.7.3 <7644#section-3.7.3>` requires. Under a + :class:`~scim2_models.ScimProvider`, each operation is read as the resource type its ``path`` + targets, and an unknown endpoint fails the operation with ``invalidPath``. +- :attr:`BulkOperation.endpoint ` and + :attr:`BulkOperation.resource_id ` read the target of + an operation from its ``path``. Changed ^^^^^^^ @@ -42,6 +51,11 @@ Security - Under :attr:`RemoveValue.apply `, a PATCH ``remove`` whose ``value`` has a key that is not an attribute name, such as ``"value pr or value"``, is refused with ``invalidValue``. +- In a :class:`~scim2_models.BulkRequest`, the ``data`` of a ``PATCH`` operation must be a + :class:`~scim2_models.PatchOp`, and the ``data`` of a ``POST`` or a ``PUT`` must be a resource. + A full resource sent as the ``data`` of a ``PATCH`` used to be accepted with its read-only + attributes, such as ``id`` and ``groups``. An invalid ``data`` only reports the errors of the + type the method expects. [0.9.0] - 2026-09-27 -------------------- diff --git a/doc/integrations/_examples/django_example.py b/doc/integrations/_examples/django_example.py index e3e5793..96aeee6 100644 --- a/doc/integrations/_examples/django_example.py +++ b/doc/integrations/_examples/django_example.py @@ -406,7 +406,7 @@ class BulkView(SCIMView): def post(self, request): try: bulk_request = BulkRequest[User].model_validate_json( - request.body, scim_ctx=Context.BULK_REQUEST + request.body, scim_ctx=Context.BULK_REQUEST, scim_provider=provider ) except ValidationError as error: return scim_validation_error(error) diff --git a/doc/integrations/_examples/fastapi_example.py b/doc/integrations/_examples/fastapi_example.py index 42bfd16..dc64cef 100644 --- a/doc/integrations/_examples/fastapi_example.py +++ b/doc/integrations/_examples/fastapi_example.py @@ -67,6 +67,16 @@ def __init__(self, content: Any = None, **kwargs: Any) -> None: router = APIRouter(prefix="/scim/v2", default_response_class=SCIMResponse) + +# -- provider-middleware-start -- +@app.middleware("http") +async def scim_provider(request: Request, call_next): + """Validate the payloads under the provider, which knows the resource type of each endpoint.""" + with provider: + return await call_next(request) +# -- provider-middleware-end -- + + def resource_location(request, app_record): """Return the canonical URL for a user record.""" return str(request.url_for("get_user", user_id=app_record["id"])) diff --git a/doc/integrations/_examples/flask_example.py b/doc/integrations/_examples/flask_example.py index 64671b8..c2928ce 100644 --- a/doc/integrations/_examples/flask_example.py +++ b/doc/integrations/_examples/flask_example.py @@ -320,6 +320,7 @@ def bulk(): bulk_request = BulkRequest[User].model_validate_json( request.data, scim_ctx=Context.BULK_REQUEST, + scim_provider=provider, ) bulk_response = execute_bulk(bulk_request, resource_location) return bulk_response.model_dump(scim_ctx=Context.BULK_RESPONSE) diff --git a/doc/integrations/_examples/integrations.py b/doc/integrations/_examples/integrations.py index 5999d92..639db6b 100644 --- a/doc/integrations/_examples/integrations.py +++ b/doc/integrations/_examples/integrations.py @@ -235,14 +235,6 @@ class PayloadTooLargeException(SCIMException): """The status each method answers with when it succeeds.""" -def record_id_of(path): - """Return the resource identifier a bulk operation path designates. - - :param path: The ``path`` of the operation, relative to the SCIM root. - """ - return path.rsplit("/", 1)[-1] - - def apply_operation(operation, record): """Apply one bulk operation to the store and return the record it acted on. @@ -290,16 +282,24 @@ def run_operation(operation, location_for): result = BulkOperation[User](method=operation.method, bulk_id=operation.bulk_id) record = None - if operation.method != BulkOperation.Method.post: - try: - record = get_record(record_id_of(operation.path)) - except KeyError: - result.status = HTTPStatus.NOT_FOUND - result.response = Error( - status=HTTPStatus.NOT_FOUND, detail="Resource does not exist." - ) - return result + try: + record = get_record(operation.resource_id) result.location = location_for(record) + except KeyError: + pass + + if isinstance(operation.response, Error): + # scim2-models could not validate the operation, and tells why. + result.status = operation.status + result.response = operation.response + return result + + if operation.method != BulkOperation.Method.post and record is None: + result.status = HTTPStatus.NOT_FOUND + result.response = Error( + status=HTTPStatus.NOT_FOUND, detail="Resource does not exist." + ) + return result try: acted_record = apply_operation(operation, record) @@ -318,7 +318,8 @@ def run_operation(operation, location_for): def execute_bulk(bulk_request, location_for): """Apply every operation of a bulk job and describe each outcome. - :param bulk_request: The validated bulk request. + :param bulk_request: The bulk request, validated under the provider so that + each operation is read by the resource type of its path. :param location_for: Builds the canonical URL of a record, which only the HTTP layer of a framework knows how to spell. """ diff --git a/doc/integrations/django.rst b/doc/integrations/django.rst index ed4a8c0..1ce97e2 100644 --- a/doc/integrations/django.rst +++ b/doc/integrations/django.rst @@ -254,8 +254,9 @@ so that the resource converter does not read ``.search`` as an identifier. POST /Bulk ^^^^^^^^^^ -Validate the job with :attr:`~scim2_models.Context.BULK_REQUEST`, apply it with ``execute_bulk`` -and serialize the outcome with :attr:`~scim2_models.Context.BULK_RESPONSE`. The view closes over +Validate the job with :attr:`~scim2_models.Context.BULK_REQUEST` under the provider, apply it +with ``execute_bulk`` and serialize the outcome with :attr:`~scim2_models.Context.BULK_RESPONSE`. +The view closes over its request to build each location, and reports a job beyond ``maxOperations`` through the same ``scim_exception_error`` helper as the other views. See :ref:`helpers-bulk` for what the executor does with each operation. diff --git a/doc/integrations/fastapi.rst b/doc/integrations/fastapi.rst index 93c3e9d..efae3a7 100644 --- a/doc/integrations/fastapi.rst +++ b/doc/integrations/fastapi.rst @@ -209,6 +209,14 @@ The job is validated through :class:`~scim2_models.BulkRequestContext`, which ap with :attr:`~scim2_models.Context.BULK_RESPONSE`. The route closes over its request to build each location. +FastAPI validates the job before it calls the route, so the provider is opened by a middleware. +Each operation is then read as the resource type its ``path`` targets. + +.. literalinclude:: _examples/fastapi_example.py + :language: python + :start-after: # -- provider-middleware-start -- + :end-before: # -- provider-middleware-end -- + A job that exceeds ``maxOperations`` raises :class:`~scim2_models.SCIMException`, which the handler registered for it turns into a ``413``. See :ref:`helpers-bulk` for what the executor does with each operation. diff --git a/doc/integrations/flask.rst b/doc/integrations/flask.rst index d51529a..c99240b 100644 --- a/doc/integrations/flask.rst +++ b/doc/integrations/flask.rst @@ -196,8 +196,9 @@ convert to native and persist, then serialize the created resource with POST /Bulk ^^^^^^^^^^ -Validate the job with :attr:`~scim2_models.Context.BULK_REQUEST`, apply it with ``execute_bulk`` -and serialize the outcome with :attr:`~scim2_models.Context.BULK_RESPONSE`. The view hands the +Validate the job with :attr:`~scim2_models.Context.BULK_REQUEST` under the provider, apply it +with ``execute_bulk`` and serialize the outcome with :attr:`~scim2_models.Context.BULK_RESPONSE`. +The view hands the executor its own ``resource_location``, the only part of a result a framework has to spell. A job that exceeds ``maxOperations`` raises :class:`~scim2_models.SCIMException`, which the error diff --git a/doc/integrations/helpers.rst b/doc/integrations/helpers.rst index a0cd87e..05599a2 100644 --- a/doc/integrations/helpers.rst +++ b/doc/integrations/helpers.rst @@ -146,10 +146,18 @@ POST or a PUT therefore hands ``apply_operation`` a :class:`~scim2_models.User`, :class:`~scim2_models.PatchOp`, both already validated. The dispatch reuses the storage and mapping helpers of the resource endpoints, and validates nothing again. +An operation that cannot be validated does not fail the request. It arrives as its own failed +result, with a ``status`` and an :class:`~scim2_models.Error` as ``response``, and the other +operations are validated as usual. Under a :class:`~scim2_models.ScimProvider`, each operation is +read as the resource type its ``path`` targets, and an unknown endpoint fails with +``invalidPath``. Without a provider, the model is picked from the ``data``. A request covering +several resource types should therefore be validated under the provider. + ``execute_bulk`` follows these rules of §3.7: - A job performs as many changes as possible and disregards partial failures. ``failOnErrors`` - caps the failures a client accepts, and the operations past that cap stay undone. + caps the failures a client accepts, invalid operations included, and the operations past that + cap stay undone. - Every result carries the location of the resource its operation acted on, except a creation that failed. ``run_operation`` resolves the target before it applies the operation, so a failure still knows that location. @@ -163,8 +171,5 @@ mapping helpers of the resource endpoints, and validates nothing again. :start-after: # -- bulk-start -- :end-before: # -- bulk-end -- -Some parts of §3.7 stay out of these helpers. Resolving a ``bulkId:`` reference, which lets one -operation point at a resource another operation of the same job creates, is left to the -application. And a payload that no model accepts fails the whole request with a ``400``, where -§3.7.3 reports such an operation with its own ``400`` inside a job that answers ``200``: -scim2-models validates the request in one pass. +Resolving a ``bulkId:`` reference, which lets one operation point at a resource another operation +of the same job creates, stays out of these helpers and is left to the application. diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index f8e511a..bb5edfa 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -1,4 +1,5 @@ from enum import StrEnum +from functools import cache from typing import Annotated from typing import Any from typing import ClassVar @@ -11,6 +12,8 @@ from pydantic import Field from pydantic import PlainSerializer +from pydantic import TypeAdapter +from pydantic import ValidationError from pydantic import ValidationInfo from pydantic import ValidatorFunctionWrapHandler from pydantic import field_validator @@ -21,14 +24,20 @@ from ..annotations import Returned from ..attributes import ComplexAttribute from ..context import Context +from ..exceptions import InvalidPathException from ..exceptions import InvalidValueException +from ..exceptions import SCIMException +from ..provider import _provider from ..resources.resource import Resource from ..urn import URN from ..utils import UNION_TYPES from ..utils import _int_to_str +from ..utils import _normalize_attribute_name from .error import Error from .message import Message +from .message import _parameter_members from .message import _ResourceParameterized +from .message import _type_parameter from .patch_op import PatchOp ResourceT = TypeVar("ResourceT", bound=Resource[Any]) @@ -98,9 +107,13 @@ def _validate_data_as_a_single_operation( envelope carrying it. The envelope keeps BULK_REQUEST, and a flag carries what stays specific to a bulk job, such as a reference to a resource another operation is still creating. + + The method also sets the type of the payload: a PATCH carries a + PatchOp, and a POST or a PUT carries a resource. The data union is not + tried, so a resource cannot pass as the payload of a PATCH. """ context = info.context - if not context or context.get("scim") != Context.BULK_REQUEST: + if value is None or not context or context.get("scim") != Context.BULK_REQUEST: return handler(value) method = info.data.get("method") @@ -108,10 +121,12 @@ def _validate_data_as_a_single_operation( if derived is None: return handler(value) + resource_type: Any = _type_parameter(cls) + model = PatchOp[resource_type] if method == cls.Method.patch else resource_type context["scim"] = derived context["scim_bulk"] = True try: - return handler(value) + return model.model_validate(value, context=context) finally: context["scim"] = Context.BULK_REQUEST del context["scim_bulk"] @@ -135,6 +150,34 @@ def __class_getitem__(cls, item: Any) -> Any: return super().__class_getitem__(item) + @property + def endpoint(self) -> str | None: + """The endpoint of the resource type the :attr:`path` targets, e.g. ``/Users``. + + >>> from scim2_models import BulkOperation, User + >>> BulkOperation[User](path="/Users/2819c223").endpoint + '/Users' + """ + if self.path is None: + return None + return "/" + self.path.lstrip("/").partition("/")[0] + + @property + def resource_id(self) -> str | None: + """The identifier of the resource the :attr:`path` targets. + + A creation targets an endpoint rather than a resource, and has none. + + >>> from scim2_models import BulkOperation, User + >>> BulkOperation[User](path="/Users/2819c223").resource_id + '2819c223' + >>> BulkOperation[User](path="/Users").resource_id is None + True + """ + if self.path is None: + return None + return self.path.lstrip("/").partition("/")[2] or None + @model_validator(mode="after") def _validate_operation_requirements(self, info: ValidationInfo) -> Self: """Validate operation requirements according to RFC 7644.""" @@ -216,6 +259,31 @@ class BulkRequest(_ResourceParameterized, Message, Generic[ResourceT]): scim2-models validates and serializes the message. Applying the operations it carries is left to the application. + + In the :attr:`~scim2_models.Context.BULK_REQUEST` context, an operation that + cannot be validated does not fail the request, as + :rfc:`RFC7644 §3.7.3 <7644#section-3.7.3>` requires. It is kept as its own + failed result, with a ``status`` and an :class:`~scim2_models.Error` as + ``response``: + + >>> request = BulkRequest[User].model_validate( + ... { + ... "Operations": [ + ... {"method": "POST", "bulkId": "x", "path": "/Users", "data": {}} + ... ] + ... }, + ... scim_ctx=Context.BULK_REQUEST, + ... ) + >>> failed = request.operations[0] + >>> failed.bulk_id, failed.status, failed.response.scim_type + ('x', 400, 'invalidValue') + + Under a :class:`~scim2_models.ScimProvider`, each operation is read as the + resource type its ``path`` targets, and an unknown endpoint fails the + operation with ``invalidPath``. Without a provider, the member of the type + parameter is picked from the ``data``, which cannot tell apart two resource + types accepting the same PATCH: validate a request covering several + resource types under the provider. """ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkRequest") @@ -230,6 +298,98 @@ class BulkRequest(_ResourceParameterized, Message, Generic[ResourceT]): ) """Defines operations within a bulk job.""" + @field_validator("operations", mode="wrap") + @classmethod + def _validate_each_operation( + cls, + value: Any, + handler: ValidatorFunctionWrapHandler, + info: ValidationInfo, + ) -> Any: + """Validate the operations of a request one by one, so that an invalid one fails alone. + + RFC 7644 §3.7.3: the service provider reports an operation it cannot + perform in the result of that operation, and goes on with the others. + """ + context = info.context + if ( + not isinstance(value, list) + or not context + or context.get("scim") != Context.BULK_REQUEST + ): + return handler(value) + + return [cls._validate_operation(operation, info) for operation in value] + + @classmethod + def _validate_operation(cls, operation: Any, info: ValidationInfo) -> Any: + """Validate one operation, or return the failed result it stands for.""" + try: + model = cls._model_for_path(_raw_attribute(operation, "path"), info) + return _operation_adapter(model).validate_python( + operation, context=info.context + ) + except ValidationError as exception: + error = Error.from_validation_errors(exception)[0] + except SCIMException as exception: + error = exception.to_error() + + method = _raw_attribute(operation, "method") + bulk_id = _raw_attribute(operation, "bulkId") + path = _raw_attribute(operation, "path") + failed_model = _parameter_members(_type_parameter(cls))[0] + return BulkOperation[failed_model]( # type: ignore[valid-type] + method=method if method in list(BulkOperation.Method) else None, + bulk_id=bulk_id if isinstance(bulk_id, str) else None, + path=path if isinstance(path, str) else None, + status=error.status, + response=error, + ) + + @classmethod + def _model_for_path(cls, path: Any, info: ValidationInfo) -> Any: + """Return the member of the type parameter the endpoint of a path serves. + + Without a provider, nothing tells which resource type an endpoint + serves, and pydantic picks the member from the data. + + :raises InvalidPathException: When no resource type is served at the endpoint. + :raises TypeError: When the type parameter lacks the model of the endpoint. + """ + members = _parameter_members(_type_parameter(cls)) + provider = _provider(info) + if provider is None or not isinstance(path, str): + return Union[members] # noqa: UP007 + + endpoint = "/" + path.lstrip("/").partition("/")[0] + model = provider.model_for_endpoint(endpoint) + if model is None: + raise InvalidPathException( + detail=f"No resource type is served at {endpoint}" + ) + if model not in members: + raise TypeError( + f"{cls.__name__} does not declare {model.__name__}, served at {endpoint}" + ) + return model + + +def _raw_attribute(payload: Any, name: str) -> Any: + """Return an attribute of a raw payload, whose names are case-insensitive.""" + if not isinstance(payload, dict): + return None + key = _normalize_attribute_name(name) + return next( + (value for k, value in payload.items() if _normalize_attribute_name(k) == key), + None, + ) + + +@cache +def _operation_adapter(model: Any) -> TypeAdapter[Any]: + """Return the adapter validating the operations of a model or a union of models.""" + return TypeAdapter(BulkOperation[model]) + class BulkResponse(_ResourceParameterized, Message, Generic[ResourceT]): """Bulk response as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. diff --git a/tests/test_bulk.py b/tests/test_bulk.py index ed66424..ef59b54 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -2,6 +2,7 @@ from pydantic import ValidationError from scim2_models import Error +from scim2_models import ScimProvider from scim2_models.base import Context from scim2_models.messages.bulk import BulkOperation from scim2_models.messages.bulk import BulkRequest @@ -125,7 +126,13 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): "method": BulkOperation.Method.patch, "bulkId": "qwerty", "path": "/Users/2819c223-7f76-453a-919d-413861904646", - "data": User(user_name="John Doe"), + "data": PatchOp[User]( + operations=[ + PatchOperation[User]( + op=PatchOperation.Op.add, path="nickName", value="Babs" + ) + ] + ), }, context={"scim": Context.BULK_REQUEST}, ) @@ -138,7 +145,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): }, context={"scim": Context.BULK_REQUEST}, ) - with pytest.raises(ValidationError): + with pytest.raises(ValidationError, match="data is required"): BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, @@ -148,7 +155,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): }, context={"scim": Context.BULK_REQUEST}, ) - with pytest.raises(ValidationError): + with pytest.raises(ValidationError, match="data is required"): BulkOperation[User].model_validate( { "method": BulkOperation.Method.patch, @@ -443,6 +450,75 @@ def test_post_operation_data_answers_to_the_creation_request_rules(): assert operation.data.user_name == "bjensen" +def test_patch_operation_data_must_be_a_patch(): + """A PATCH carries a PatchOp, so a full resource cannot slip read-only attributes through.""" + with pytest.raises(ValidationError) as exc_info: + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.patch, + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "evil", + "userName": "bjensen", + "groups": [{"value": "admins"}], + }, + }, + scim_ctx=Context.BULK_REQUEST, + ) + assert {error["loc"] for error in exc_info.value.errors()} == { + ("data", "id"), + ("data", "userName"), + ("data", "groups"), + } + + +@pytest.mark.parametrize( + "method,path", + [ + (BulkOperation.Method.post, "/Users"), + (BulkOperation.Method.put, "/Users/2819c223-7f76-453a-919d-413861904646"), + ], +) +def test_post_and_put_operation_data_must_be_a_resource(method, path): + """A POST or a PUT carries a resource, not a PatchOp.""" + with pytest.raises(ValidationError) as exc_info: + BulkOperation[User].model_validate( + { + "method": method, + "bulkId": "qwerty", + "path": path, + "data": { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "add", "path": "userName", "value": "x"}], + }, + }, + scim_ctx=Context.BULK_REQUEST, + ) + assert [error["loc"] for error in exc_info.value.errors()] == [ + ("data", "Operations") + ] + + +def test_operation_data_errors_come_from_the_payload_of_the_method(): + """An invalid PATCH data reports the errors of the patch alone.""" + with pytest.raises(ValidationError) as exc_info: + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.patch, + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "frobnicate", "path": "userName"}], + }, + }, + scim_ctx=Context.BULK_REQUEST, + ) + assert [error["loc"] for error in exc_info.value.errors()] == [ + ("data", "Operations", 0, "op") + ] + + def test_operation_data_keeps_the_bulk_context_when_no_single_request_matches(): """Neither a DELETE nor an unreadable method names a single request to borrow the rules from.""" operation = BulkOperation[User].model_validate( @@ -530,3 +606,219 @@ def test_bulk_rules_do_not_apply_outside_a_bulk_context(): scim_ctx=Context.SEARCH_REQUEST, ) assert operation.path is None + + +def test_a_subclass_of_a_parameterized_operation_reads_its_data(): + """A class deriving from BulkOperation[User] validates its data as a User.""" + + class UserOperation(BulkOperation[User]): + pass + + operation = UserOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": {"userName": "bjensen"}, + }, + scim_ctx=Context.BULK_REQUEST, + ) + assert operation.data.user_name == "bjensen" + + +GROUP_PATH = "/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a" +PATCH_DISPLAY_NAME = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "path": "displayName", "value": "Tour Guides"}], +} + + +def bulk_payload(*operations, **envelope): + """Build a raw bulk request carrying the given operations.""" + return { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"], + **envelope, + "Operations": list(operations), + } + + +def test_an_invalid_operation_fails_alone(): + """RFC 7644 §3.7.3: an operation the service provider cannot perform is reported in its own result.""" + request = BulkRequest[User].model_validate( + bulk_payload( + { + "method": "POST", + "bulkId": "invalid", + "path": "/Users", + "data": {"userName": 42}, + }, + { + "method": "POST", + "bulkId": "valid", + "path": "/Users", + "data": {"userName": "bjensen"}, + }, + failOnErrors=1, + ), + scim_ctx=Context.BULK_REQUEST, + ) + + invalid, valid = request.operations + assert request.fail_on_errors == 1 + assert invalid.method == BulkOperation.Method.post + assert invalid.bulk_id == "invalid" + assert invalid.path == "/Users" + assert invalid.data is None + assert invalid.status == 400 + assert invalid.response.scim_type == "invalidValue" + assert valid.data.user_name == "bjensen" + assert valid.response is None + + +def test_an_invalid_operation_keeps_what_its_result_can_carry(): + """Only the values a result can hold are kept from an operation that cannot be read.""" + request = BulkRequest[User].model_validate( + bulk_payload( + {"METHOD": "FETCH", "BULKID": 42, "PATH": ["/Users"]}, + "not an operation", + ), + scim_ctx=Context.BULK_REQUEST, + ) + + unreadable, not_an_object = request.operations + assert (unreadable.method, unreadable.bulk_id, unreadable.path) == ( + None, + None, + None, + ) + assert unreadable.status == 400 + assert not_an_object.status == 400 + assert isinstance(not_an_object.response, Error) + + +def test_attribute_names_of_an_invalid_operation_are_case_insensitive(): + """The method, bulkId and path of an invalid operation are read whatever their case.""" + request = BulkRequest[User].model_validate( + bulk_payload( + {"METHOD": "POST", "BulkId": "invalid", "Path": "/Users", "data": {}} + ), + scim_ctx=Context.BULK_REQUEST, + ) + + (invalid,) = request.operations + assert (invalid.method, invalid.bulk_id, invalid.path) == ( + BulkOperation.Method.post, + "invalid", + "/Users", + ) + + +@pytest.mark.parametrize( + "envelope", + [ + {"Operations": "not a list"}, + {"failOnErrors": "not a number", "Operations": []}, + ], +) +def test_an_invalid_envelope_fails_the_whole_request(envelope): + """The operations fail one by one, but a request whose envelope is invalid fails whole.""" + with pytest.raises(ValidationError): + BulkRequest[User].model_validate(envelope, scim_ctx=Context.BULK_REQUEST) + + +def test_an_invalid_operation_fails_the_request_outside_a_bulk_request_context(): + """Only a bulk request received by a service provider reports invalid operations one by one.""" + with pytest.raises(ValidationError): + BulkRequest[User].model_validate( + bulk_payload({"method": "POST", "path": "/Users", "data": {"userName": 42}}) + ) + + +def test_the_provider_picks_the_model_from_the_path(): + """Under a provider, an operation is read as the resource type its endpoint serves.""" + provider = ScimProvider(models=[User, Group]) + payload = bulk_payload( + {"method": "PATCH", "path": GROUP_PATH, "data": PATCH_DISPLAY_NAME}, + {"method": "DELETE", "path": GROUP_PATH}, + ) + + with provider: + request = BulkRequest[User | Group].model_validate( + payload, scim_ctx=Context.BULK_REQUEST + ) + + patch, delete = request.operations + assert isinstance(patch.data, PatchOp[Group]) + assert isinstance(delete, BulkOperation[Group]) + + +def test_a_provider_given_to_the_validation_picks_the_model_from_the_path(): + """The provider can be given to the validation rather than opened as a block.""" + request = BulkRequest[User | Group].model_validate( + bulk_payload( + {"method": "PATCH", "path": GROUP_PATH, "data": PATCH_DISPLAY_NAME} + ), + scim_ctx=Context.BULK_REQUEST, + scim_provider=ScimProvider(models=[User, Group]), + ) + + (patch,) = request.operations + assert isinstance(patch.data, PatchOp[Group]) + + +def test_without_provider_the_model_is_picked_from_the_data(): + """Without a provider, nothing tells which resource type an endpoint serves.""" + request = BulkRequest[User | Group].model_validate( + bulk_payload( + {"method": "PATCH", "path": GROUP_PATH, "data": PATCH_DISPLAY_NAME} + ), + scim_ctx=Context.BULK_REQUEST, + ) + + (patch,) = request.operations + assert isinstance(patch.data, PatchOp[User]) + + +def test_an_operation_on_an_unknown_endpoint_fails_alone(): + """Under a provider, an endpoint no resource type is served at fails the operation with invalidPath.""" + request = BulkRequest[User].model_validate( + bulk_payload( + {"method": "DELETE", "path": "/Pets/1"}, + {"method": "DELETE", "path": "/Users/1"}, + ), + scim_ctx=Context.BULK_REQUEST, + scim_provider=ScimProvider(models=[User]), + ) + + unknown, known = request.operations + assert unknown.status == 400 + assert unknown.response.scim_type == "invalidPath" + assert unknown.response.detail == "No resource type is served at /Pets" + assert known.status is None + + +def test_an_endpoint_whose_model_the_request_does_not_declare_is_a_programming_error(): + """The type parameter of the request must cover every resource type the provider serves.""" + with pytest.raises(TypeError, match="Group"): + BulkRequest[User].model_validate( + bulk_payload({"method": "DELETE", "path": GROUP_PATH}), + scim_ctx=Context.BULK_REQUEST, + scim_provider=ScimProvider(models=[User, Group]), + ) + + +@pytest.mark.parametrize( + "path,endpoint,resource_id", + [ + ("/Users", "/Users", None), + ("/Users/2819c223", "/Users", "2819c223"), + ("Users/2819c223", "/Users", "2819c223"), + ("/Users/", "/Users", None), + (None, None, None), + ], +) +def test_the_target_of_an_operation_is_read_from_its_path(path, endpoint, resource_id): + """The endpoint and the resource identifier come from the path, a creation having no identifier.""" + operation = BulkOperation[User](path=path) + assert operation.endpoint == endpoint + assert operation.resource_id == resource_id diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py index 619d6f4..19e345f 100644 --- a/tests/test_doc_examples.py +++ b/tests/test_doc_examples.py @@ -1212,3 +1212,165 @@ def test_bulk_write_operations_reach_the_store(): assert integrations.get_record(replaced["id"])["user_name"] == "renamed@example.com" assert integrations.get_record(patched["id"])["display_name"] == "Babs" + + +INVALID_AND_UNKNOWN_OPERATIONS = [ + { + "method": "POST", + "bulkId": "invalid", + "path": "/Users", + "data": {"schemas": [USER_SCHEMA], "userName": 42}, + }, + {"method": "DELETE", "path": "/Pets/1"}, + { + "method": "POST", + "bulkId": "valid", + "path": "/Users", + "data": {"schemas": [USER_SCHEMA], "userName": "bjensen@example.com"}, + }, +] + + +def test_bulk_reports_an_invalid_operation_and_carries_on(): + """An operation scim2-models cannot validate fails on its own, and the job goes on.""" + from doc.integrations._examples import integrations + + integrations.records.clear() + + request = BulkRequest[User].model_validate( + { + "schemas": [BULK_REQUEST_SCHEMA], + "Operations": INVALID_AND_UNKNOWN_OPERATIONS, + }, + scim_ctx=Context.BULK_REQUEST, + scim_provider=integrations.provider, + ) + + invalid, unknown, valid = execute_bulk(request, bulk_location).operations + + assert (invalid.bulk_id, invalid.status) == ("invalid", 400) + assert invalid.response.scim_type == "invalidValue" + assert unknown.status == 400 + assert unknown.response.scim_type == "invalidPath" + assert (valid.bulk_id, valid.status) == ("valid", 201) + + +def test_bulk_counts_an_invalid_operation_among_the_errors_the_client_accepts(): + """FailOnErrors counts the operations that could not be validated.""" + from doc.integrations._examples import integrations + + integrations.records.clear() + + request = BulkRequest[User].model_validate( + { + "schemas": [BULK_REQUEST_SCHEMA], + "failOnErrors": 1, + "Operations": INVALID_AND_UNKNOWN_OPERATIONS, + }, + scim_ctx=Context.BULK_REQUEST, + ) + + response = execute_bulk(request, bulk_location) + + assert [operation.bulk_id for operation in response.operations] == ["invalid"] + assert integrations.list_records() == [] + + +def test_bulk_locates_an_invalid_operation_on_a_stored_resource(): + """An invalid operation that is not a creation keeps the location a bulk response requires.""" + from doc.integrations._examples import integrations + + integrations.records.clear() + replaced = stored_user("replaced@example.com") + + request = bulk_request( + { + "method": "PUT", + "path": f"/Users/{replaced['id']}", + "data": {"schemas": [USER_SCHEMA], "userName": 42}, + } + ) + + (invalid,) = execute_bulk(request, bulk_location).operations + + assert invalid.status == 400 + assert invalid.location == bulk_location(replaced) + + +def test_flask_reports_each_invalid_bulk_operation(): + """The Flask bulk endpoint validates under the provider, so an unknown endpoint fails alone.""" + from doc.integrations._examples import integrations + + integrations.records.clear() + client = create_flask_app().test_client() + + response = client.post( + "/scim/v2/Bulk", + json={ + "schemas": [BULK_REQUEST_SCHEMA], + "Operations": INVALID_AND_UNKNOWN_OPERATIONS, + }, + ) + + assert response.status_code == 200 + assert [operation["status"] for operation in response.get_json()["Operations"]] == [ + "400", + "400", + "201", + ] + + +def test_django_reports_each_invalid_bulk_operation(): + """The Django bulk endpoint validates under the provider, so an unknown endpoint fails alone.""" + configure_django() + + from django.test import Client + from django.test import override_settings + + from doc.integrations._examples import integrations + + integrations.records.clear() + + with override_settings(ROOT_URLCONF="doc.integrations._examples.django_example"): + response = Client().post( + "/scim/v2/Bulk", + data=json.dumps( + { + "schemas": [BULK_REQUEST_SCHEMA], + "Operations": INVALID_AND_UNKNOWN_OPERATIONS, + } + ), + content_type="application/scim+json", + ) + + assert response.status_code == 200 + assert [operation["status"] for operation in response.json()["Operations"]] == [ + "400", + "400", + "201", + ] + + +def test_fastapi_reports_each_invalid_bulk_operation(): + """The FastAPI middleware opens the provider, so the annotated bulk request fails an unknown endpoint alone.""" + from starlette.testclient import TestClient + + from doc.integrations._examples import integrations + from doc.integrations._examples.fastapi_example import app + + integrations.records.clear() + + response = TestClient(app).post( + "/scim/v2/Bulk", + json={ + "schemas": [BULK_REQUEST_SCHEMA], + "Operations": INVALID_AND_UNKNOWN_OPERATIONS, + }, + ) + + assert response.status_code == 200 + assert [operation["status"] for operation in response.json()["Operations"]] == [ + "400", + "400", + "201", + ]