diff --git a/doc/changelog.rst b/doc/changelog.rst index 378dd13c..d24f0ba8 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -6,12 +6,18 @@ Changelog Added ^^^^^ +- Support for :rfc:`RFC9865 <9865>` - :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. Changed ^^^^^^^ +- :meth:`SCIMException.from_error ` reconstructs + :class:`~scim2_models.InvalidCursorException`, :class:`~scim2_models.ExpiredCursorException` and + :class:`~scim2_models.InvalidCountException` from an :class:`~scim2_models.Error` carrying the + matching ``scimType``, as :rfc:`RFC9865 §2.1 <9865#section-2.1>` defines them. They used to fall + back to the base :class:`~scim2_models.SCIMException`. - :attr:`AttributeBinding.urn ` and the error messages that quote it spell the attribute as the schema declares it, such as ``userName``, whatever case the path used. diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 51b22bca..7f03b922 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -22,6 +22,9 @@ from .attributes import MultiValuedComplexAttribute from .base import BaseModel from .context import Context +from .exceptions import ExpiredCursorException +from .exceptions import InvalidCountException +from .exceptions import InvalidCursorException from .exceptions import InvalidFilterException from .exceptions import InvalidPathException from .exceptions import InvalidSyntaxException @@ -74,6 +77,7 @@ from .resources.service_provider_config import ChangePassword from .resources.service_provider_config import ETag from .resources.service_provider_config import Filter +from .resources.service_provider_config import Pagination from .resources.service_provider_config import Patch from .resources.service_provider_config import ServiceProviderConfig from .resources.service_provider_config import Sort @@ -120,6 +124,7 @@ "Entitlement", "Error", "ExtensibleStringEnum", + "ExpiredCursorException", "Extension", "External", "Filter", @@ -127,6 +132,8 @@ "GroupMember", "GroupMembership", "Im", + "InvalidCountException", + "InvalidCursorException", "InvalidFilterException", "InvalidPathException", "InvalidSyntaxException", @@ -141,6 +148,7 @@ "MultiValuedComplexAttribute", "Name", "NoTargetException", + "Pagination", "Patch", "PatchOp", "PatchOperation", diff --git a/scim2_models/exceptions.py b/scim2_models/exceptions.py index 3627bfd0..0e3463d2 100644 --- a/scim2_models/exceptions.py +++ b/scim2_models/exceptions.py @@ -314,6 +314,45 @@ class SensitiveException(SCIMException): ) +class InvalidCursorException(SCIMException): + """Cursor value is invalid. + + Corresponds to scimType ``invalidCursor`` with HTTP status 400. + + :rfc:`RFC 9865 Section 2.1 <9865#section-2.1>` + """ + + status = 400 + scim_type = "invalidCursor" + _default_detail = "Cursor value is invalid. Cursor value SHOULD be empty to request the first page and set to the nextCursor or previousCursor value for subsequent queries." + + +class ExpiredCursorException(SCIMException): + """Cursor has expired. + + Corresponds to scimType ``expiredCursor`` with HTTP status 400. + + :rfc:`RFC 9865 Section 2.3 <9865#section-2.3>` + """ + + status = 400 + scim_type = "expiredCursor" + _default_detail = "Cursor has expired. Do not wait longer than service provider's cursorTimeout to request additional pages." + + +class InvalidCountException(SCIMException): + """Count value is invalid. + + Corresponds to scimType ``invalidCount`` with HTTP status 400. + + :rfc:`RFC 9865 Section 2.4 <9865#section-2.4>` + """ + + status = 400 + scim_type = "invalidCount" + _default_detail = "Count value is invalid. Count value must be between 0 and service provider's maxPageSize and must be equal to the count value of the initial query." + + _SCIM_TYPE_TO_EXCEPTION: dict[str, type[SCIMException]] = { "invalidFilter": InvalidFilterException, "tooMany": TooManyException, @@ -325,4 +364,7 @@ class SensitiveException(SCIMException): "invalidValue": InvalidValueException, "invalidVers": InvalidVersionException, "sensitive": SensitiveException, + "invalidCursor": InvalidCursorException, + "expiredCursor": ExpiredCursorException, + "invalidCount": InvalidCountException, } diff --git a/scim2_models/messages/list_response.py b/scim2_models/messages/list_response.py index fb566bfe..9f3294eb 100644 --- a/scim2_models/messages/list_response.py +++ b/scim2_models/messages/list_response.py @@ -1,3 +1,4 @@ +import re from typing import Any from typing import Generic from typing import Self @@ -5,10 +6,12 @@ from pydantic import Field from pydantic import ValidationInfo from pydantic import ValidatorFunctionWrapHandler +from pydantic import field_validator from pydantic import model_validator from pydantic_core import PydanticCustomError from ..context import Context +from ..exceptions import InvalidCursorException from ..resources.resource import AnyResource from ..urn import URN from .message import Message @@ -53,6 +56,25 @@ class ListResponse( items_per_page: int | None = None """The number of resources returned in a list response page.""" + next_cursor: str | None = None + """A string value that can be used to retrieve the next page of list + results.""" + + previous_cursor: str | None = None + """A string value that can be used to retrieve the previous page of list + results.""" + + @field_validator("next_cursor", "previous_cursor") + @classmethod + def validate_cursor_chars(cls, value: str | None) -> str | None: + """According to :rfc:`RFC9865 §2 <9865#section-2>`, cursor values may only contain unreserved characters as defined in :rfc:`RFC3986 §2.3 <3986#section-2.3>`. + + An empty cursor requests the first page, so a response cannot carry one. + """ + if value is not None and not re.fullmatch(r"[A-Za-z0-9\-._~]+", value): + raise InvalidCursorException().as_pydantic_error() + return value + resources: list[AnyResource] | None = Field(None, serialization_alias="Resources") """A multi-valued list of complex objects containing the requested resources.""" @@ -68,6 +90,10 @@ def _check_results_number( - 'totalResults' is required - 'resources' must be set if 'totalResults' is non-zero. + + RFC9865 §2 makes 'totalResults' optional with cursor pagination. + A response uses cursor pagination if the service provider supports it, + or if the response carries a cursor. """ obj = handler(value) assert isinstance(obj, cls) @@ -79,13 +105,23 @@ def _check_results_number( ): return obj - if obj.total_results is None: + config = info.context.get("scim_spc") + cursor_pagination = bool( + (config and config.pagination and config.pagination.cursor) + or obj.next_cursor is not None + or obj.previous_cursor is not None + ) + if not cursor_pagination and obj.total_results is None: raise PydanticCustomError( "required_error", "Field 'totalResults' is required but value is missing or null", ) - if obj.total_results > 0 and obj.resources is None: + if ( + obj.total_results is not None + and obj.total_results > 0 + and obj.resources is None + ): raise PydanticCustomError( "no_resource_error", "Field 'Resources' is missing or null but 'totalResults' is non-zero.", diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index 9fea853f..dd5a3f28 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -1,3 +1,4 @@ +import re from collections.abc import Iterable from enum import StrEnum from inspect import isclass @@ -9,6 +10,7 @@ from ..annotations import CaseExact from ..annotations import Mutability from ..base import BaseModel +from ..exceptions import InvalidCursorException from ..exceptions import InvalidFilterException from ..exceptions import InvalidPathException from ..path import Path @@ -222,6 +224,21 @@ def _start_index_floor(cls, value: int | None) -> int | None: """ return None if value is None else max(1, value) + cursor: str | None = None + """A string value that can be used to retrieve the next page of results. + The cursor value is defined in :rfc:`RFC9865 §2 <9865#section-2>`.""" + + @field_validator("cursor") + @classmethod + def validate_cursor_chars(cls, value: str | None) -> str | None: + """According to :rfc:`RFC9865 §2 <9865#section-2>`, cursor values may only contain unreserved characters as defined in :rfc:`RFC3986 §2.3 <3986#section-2.3>`. + + unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + """ + if value is not None and not re.fullmatch(r"[A-Za-z0-9\-._~]*", value): + raise InvalidCursorException().as_pydantic_error() + return value + count: int | None = None """An integer indicating the desired maximum number of query results per page.""" diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index d834dc2b..7add9453 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -1,12 +1,15 @@ from typing import Annotated from typing import Any +from pydantic import field_validator + from ..annotations import Mutability from ..annotations import Required from ..annotations import Returned from ..annotations import Uniqueness from ..attributes import ComplexAttribute from ..attributes import ExtensibleStringEnum +from ..exceptions import InvalidValueException from ..reference import External from ..reference import Reference from ..urn import URN @@ -52,6 +55,41 @@ class ETag(ComplexAttribute): """A Boolean value specifying whether or not the operation is supported.""" +class Pagination(ComplexAttribute): + class DefaultPaginationMethod(ExtensibleStringEnum): + cursor = "cursor" + index = "index" # type: ignore[assignment] + + cursor: Annotated[bool | None, Mutability.read_only, Required.true] = None + """A Boolean value specifying support of cursor-based pagination.""" + + index: Annotated[bool | None, Mutability.read_only, Required.true] = None + """A Boolean value specifying support of index-based pagination.""" + + default_pagination_method: Annotated[ + DefaultPaginationMethod | None, Mutability.read_only + ] = None + """A string value specifying the type of pagination that the service provider defaults to when the client has not specified which method it wishes to use. Possible values are "cursor" and "index".""" + + default_page_size: Annotated[int | None, Mutability.read_only] = None + """Positive integer value specifying the default number of results returned in a page when a count is not specified in the query.""" + + max_page_size: Annotated[int | None, Mutability.read_only] = None + """Positive integer specifying the maximum number of results returned in a page regardless of what is specified for the count in a query. The maximum number of results returned may be further restricted by other criteria.""" + + cursor_timeout: Annotated[int | None, Mutability.read_only] = None + """Positive integer specifying the minimum number of seconds that a cursor is valid between page requests. Clients waiting too long between cursor pagination requests may receive an invalid cursor error response. No value being specified may mean that there is no cursor timeout or that the cursor timeout is not a static duration.""" + + @field_validator("default_page_size", "max_page_size", "cursor_timeout") + @classmethod + def validate_positive_integers(cls, value: int | None) -> int | None: + if value is not None and value <= 0: + raise InvalidValueException( + detail=f"{value} is not a positive integer" + ).as_pydantic_error() + return value + + class AuthenticationScheme(ComplexAttribute): class Type(ExtensibleStringEnum): oauth = "oauth" @@ -130,3 +168,6 @@ class ServiceProviderConfig(Resource[Any]): ] = None """A complex type that specifies supported authentication scheme properties.""" + + pagination: Annotated[Pagination | None, Mutability.read_only] = None + """A complex type that specifies pagination configuration options.""" diff --git a/tests/test_dynamic_schemas.py b/tests/test_dynamic_schemas.py index b428ebd0..0d495cfb 100644 --- a/tests/test_dynamic_schemas.py +++ b/tests/test_dynamic_schemas.py @@ -99,7 +99,9 @@ def test_dynamic_service_provider_config_schema(load_sample): canonic_schema(sample) schema["attributes"] = [ - attr for attr in schema["attributes"] if attr["name"] != "id" + attr + for attr in schema["attributes"] + if attr["name"] not in ("id", "pagination") ] assert sample == schema diff --git a/tests/test_errors.py b/tests/test_errors.py index 9f4dc42e..1b73dc7a 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,3 +1,6 @@ +from scim2_models.exceptions import ExpiredCursorException +from scim2_models.exceptions import InvalidCountException +from scim2_models.exceptions import InvalidCursorException from scim2_models.exceptions import InvalidFilterException from scim2_models.exceptions import InvalidPathException from scim2_models.exceptions import InvalidSyntaxException @@ -23,5 +26,8 @@ def test_predefined_errors(): InvalidValueException(), InvalidVersionException(), SensitiveException(), + InvalidCursorException(), + ExpiredCursorException(), + InvalidCountException(), ): assert isinstance(exc.to_error(), Error) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 3651a1e0..33cebe4b 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -8,6 +8,9 @@ from scim2_models import Context from scim2_models import Error +from scim2_models import ExpiredCursorException +from scim2_models import InvalidCountException +from scim2_models import InvalidCursorException from scim2_models import InvalidFilterException from scim2_models import InvalidPathException from scim2_models import InvalidSyntaxException @@ -80,6 +83,27 @@ def test_too_many_exception(): assert exc.scim_type == "tooMany" +def test_invalid_cursor_exception(): + """InvalidCursorException has correct status and scim_type.""" + exc = InvalidCursorException() + assert exc.status == 400 + assert exc.scim_type == "invalidCursor" + + +def test_expired_cursor_exception(): + """ExpiredCursorException has correct status and scim_type.""" + exc = ExpiredCursorException() + assert exc.status == 400 + assert exc.scim_type == "expiredCursor" + + +def test_invalid_count_exception(): + """InvalidCountException has correct status and scim_type.""" + exc = InvalidCountException() + assert exc.status == 400 + assert exc.scim_type == "invalidCount" + + def test_uniqueness_exception(): """UniquenessException has status 409 and stores attribute/value.""" exc = UniquenessException(attribute="userName", value="john") @@ -380,6 +404,9 @@ def test_all_exceptions_inherit_from_scim_exception(): InvalidValueException(), InvalidVersionException(), SensitiveException(), + InvalidCursorException(), + ExpiredCursorException(), + InvalidCountException(), ] for exc in exceptions: assert isinstance(exc, SCIMException) @@ -474,6 +501,30 @@ def test_from_error_sensitive(): assert exc.detail == "Sensitive data in URI" +def test_from_error_invalid_cursor(): + """from_error() creates InvalidCursorException from Error with scim_type invalidCursor.""" + error = Error(status=400, scim_type="invalidCursor", detail="Bad cursor") + exc = SCIMException.from_error(error) + assert isinstance(exc, InvalidCursorException) + assert exc.detail == "Bad cursor" + + +def test_from_error_expired_cursor(): + """from_error() creates ExpiredCursorException from Error with scim_type expiredCursor.""" + error = Error(status=400, scim_type="expiredCursor", detail="Cursor expired") + exc = SCIMException.from_error(error) + assert isinstance(exc, ExpiredCursorException) + assert exc.detail == "Cursor expired" + + +def test_from_error_invalid_count(): + """from_error() creates InvalidCountException from Error with scim_type invalidCount.""" + error = Error(status=400, scim_type="invalidCount", detail="Bad count") + exc = SCIMException.from_error(error) + assert isinstance(exc, InvalidCountException) + assert exc.detail == "Bad count" + + def test_from_error_unknown_scim_type(): """from_error() creates base SCIMException for unknown scim_type.""" error = Error(status=400, scim_type="unknownType", detail="Unknown error") diff --git a/tests/test_list_response.py b/tests/test_list_response.py index 908d47f7..afdb411f 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -5,11 +5,13 @@ from scim2_models import EnterpriseUser from scim2_models import Group from scim2_models import ListResponse +from scim2_models import Pagination from scim2_models import Resource from scim2_models import ResourceType from scim2_models import ResponseParameters from scim2_models import ServiceProviderConfig from scim2_models import User +from scim2_models.exceptions import InvalidCursorException from scim2_models.urn import URN @@ -396,8 +398,108 @@ def test_model_dump_without_scim_context(): assert payload["resources"][0]["user_name"] == "user-name" +def test_cursor_pagination(): + payload = { + "totalResults": 3, + "itemsPerPage": 1, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "nextCursor": "cursor-abc", + "previousCursor": "cursor-xyz", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "user-1", + "userName": "bjensen", + } + ], + } + response = ListResponse[User].model_validate(payload) + assert response.next_cursor == "cursor-abc" + assert response.previous_cursor == "cursor-xyz" + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert dumped["nextCursor"] == "cursor-abc" + assert dumped["previousCursor"] == "cursor-xyz" + + +def test_cursor_pagination_first_page(): + payload = { + "totalResults": 5, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "nextCursor": "cursor-abc", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "user-1", + "userName": "bjensen", + } + ], + } + response = ListResponse[User].model_validate(payload) + assert response.next_cursor == "cursor-abc" + assert response.previous_cursor is None + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert "nextCursor" in dumped + assert "previousCursor" not in dumped + + +def test_invalid_cursor_exception(): + """An invalid cursor value raises InvalidCursorException.""" + payload = { + "totalResults": 1, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "nextCursor": "not a valid cursor!", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "user-1", + "userName": "bjensen", + } + ], + } + with pytest.raises(ValidationError) as exc_info: + ListResponse[User].model_validate(payload) + + error = exc_info.value.errors()[0] + assert error["type"] == "scim_invalidCursor" + assert error["ctx"]["scim_type"] == InvalidCursorException.scim_type + assert error["ctx"]["status"] == InvalidCursorException.status + + +@pytest.mark.parametrize("cursor_field", ["nextCursor", "previousCursor"]) +def test_empty_cursor_in_response_is_rejected(cursor_field): + """An empty cursor requests the first page, so a client following it would loop forever.""" + payload = { + "totalResults": 1, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + cursor_field: "", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "user-1", + "userName": "bjensen", + } + ], + } + with pytest.raises(ValidationError) as exc_info: + ListResponse[User].model_validate(payload) + + assert exc_info.value.errors()[0]["type"] == "scim_invalidCursor" + + +def test_cursor_absent_when_none(): + response = ListResponse[User]( + total_results=1, + resources=[User(id="user-1", user_name="bjensen")], + ) + assert response.next_cursor is None + assert response.previous_cursor is None + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert "nextCursor" not in dumped + assert "previousCursor" not in dumped + + def test_total_results_required(): - """ListResponse.total_results is required.""" + """ListResponse.total_results is required if the provider does not specify cursor based pagination support.""" payload = { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "Resources": [ @@ -418,3 +520,51 @@ def test_total_results_required(): ListResponse[User].model_validate( payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE ) + + +def test_total_results_not_required_for_cursor_pagination(): + """ListResponse.total_results is not required when the service provider supports cursor-based pagination.""" + spc = ServiceProviderConfig(pagination=Pagination(cursor=True)) + payload = { + "itemsPerPage": 1, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "nextCursor": "cursor-abc", + "previousCursor": "cursor-xyz", + "Resources": [ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + ], + "userName": "bjensen@example.com", + "id": "foobar", + } + ], + } + response = ListResponse[User].model_validate( + payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE, scim_spc=spc + ) + assert response.next_cursor == "cursor-abc" + assert response.previous_cursor == "cursor-xyz" + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert dumped["nextCursor"] == "cursor-abc" + assert dumped["previousCursor"] == "cursor-xyz" + + +@pytest.mark.parametrize("cursor_field", ["nextCursor", "previousCursor"]) +def test_total_results_not_required_when_response_has_a_cursor(cursor_field): + """A response carrying a cursor uses cursor pagination, so it may omit totalResults without a service provider config.""" + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + cursor_field: "cursor-abc", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen@example.com", + "id": "foobar", + } + ], + } + response = ListResponse[User].model_validate( + payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + assert response.total_results is None diff --git a/tests/test_search_request.py b/tests/test_search_request.py index 6ce81543..a6b359d3 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -11,6 +11,7 @@ from scim2_models import Resource from scim2_models import Returned from scim2_models import User +from scim2_models.exceptions import InvalidCursorException from scim2_models.messages.search_request import SearchRequest @@ -87,6 +88,15 @@ def test_index_0_properties(): req = SearchRequest(start_index=1, count=10) assert req.start_index_0 == 0 assert req.stop_index_0 == 10 + assert not req.cursor + + +def test_pagination_does_not_default_if_cursor(): + req = SearchRequest(count=10, cursor="") + assert not req.start_index + assert req.cursor == "" + assert not req.start_index_0 + assert not req.stop_index_0 def test_search_request_valid_attributes(): @@ -239,6 +249,35 @@ def test_comma_separated_empty_string(): assert req.attributes == [] +def test_cursor_model_validate(): + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], + "cursor": "cursor-xyz", + "count": 10, + } + sr = SearchRequest.model_validate(payload) + assert sr.cursor == "cursor-xyz" + assert sr.count == 10 + + +def test_invalid_cursor_exception(): + """An invalid cursor value raises InvalidCursorException.""" + with pytest.raises(ValidationError) as exc_info: + SearchRequest(cursor="not a valid cursor!") + + error = exc_info.value.errors()[0] + assert error["type"] == "scim_invalidCursor" + assert error["ctx"]["scim_type"] == InvalidCursorException.scim_type + assert error["ctx"]["status"] == InvalidCursorException.status + + +def test_cursor_with_count(): + """Count is valid alongside cursor per RFC 9875.""" + sr = SearchRequest(cursor="cursor-abc", count=25) + assert sr.cursor == "cursor-abc" + assert sr.count == 25 + + def test_search_request_empty_lists(): """Test that empty attribute lists are handled correctly.""" valid_data = { diff --git a/tests/test_service_provider_configuration.py b/tests/test_service_provider_configuration.py index 4401f76c..00a6f6ca 100644 --- a/tests/test_service_provider_configuration.py +++ b/tests/test_service_provider_configuration.py @@ -1,6 +1,11 @@ import datetime +import pytest +from pydantic import ValidationError + from scim2_models import AuthenticationScheme +from scim2_models import InvalidValueException +from scim2_models import Pagination from scim2_models import Reference from scim2_models import ServiceProviderConfig @@ -88,3 +93,29 @@ def test_authentication_scheme_type_accepts_unknown_schemes(): ) assert str(scheme.type) == "oauth2bearer" assert scheme.model_dump()["type"] == "oauth2bearer" + + +@pytest.mark.parametrize("field", ["defaultPageSize", "maxPageSize", "cursorTimeout"]) +@pytest.mark.parametrize("value", [0, -1]) +def test_positive_integer_validator_rejects_invalid_values(field, value): + """Test that pagination integer fields reject zero and negative values.""" + with pytest.raises( + ValidationError, match=f"{value} is not a positive integer" + ) as exc_info: + Pagination.model_validate({field: value}) + + error = exc_info.value.errors()[0] + assert error["type"] == "scim_invalidValue" + assert error["loc"] == (field,) + assert error["ctx"] == { + "scim_type": InvalidValueException.scim_type, + "status": InvalidValueException.status, + } + + +@pytest.mark.parametrize("field", ["defaultPageSize", "maxPageSize", "cursorTimeout"]) +@pytest.mark.parametrize("value", [1, 100, None]) +def test_positive_integer_validator_accepts_valid_values(field, value): + """Test that pagination integer fields accept positive integers and None.""" + pagination = Pagination.model_validate({field: value}) + assert pagination.model_dump().get(field) == value