From 82d56fb8b232a47fe1aa3be092e8f31c8445733c Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 25 Jun 2026 11:13:05 +0200 Subject: [PATCH 01/10] feat: add cursor pagination related exceptions --- scim2_client/errors.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scim2_client/errors.py b/scim2_client/errors.py index deb02a5..f4f6b32 100644 --- a/scim2_client/errors.py +++ b/scim2_client/errors.py @@ -65,6 +65,30 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(message, *args, **kwargs) +class InvalidCursorError(SCIMRequestError): + """Error raised when an invalid cursor has been passed to SCIMClient.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + message = kwargs.pop( + "message", + "Cursor value is invalid.", + ) + super().__init__(message, *args, **kwargs) + +class InvalidCountError(SCIMRequestError): + """Cursor has expired.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + message = kwargs.pop("message", "Invalid count") + super().__init__(message, *args, **kwargs) + +class ExpiredCursorError(SCIMRequestError): + """Cursor has expired.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + message = kwargs.pop("message", "Expired cursor") + super().__init__(message, *args, **kwargs) + class SCIMResponseError(SCIMClientError): """Base exception for errors happening during response payload validation.""" From a4d588a27267395ff6b02f5701618a8199e2c3aa Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 29 Jun 2026 10:10:13 +0200 Subject: [PATCH 02/10] feat: raise errors for invalid cursor and cursor/index exclusivity --- scim2_client/client.py | 11 +++++++++++ tests/test_query.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/scim2_client/client.py b/scim2_client/client.py index 76bb2be..0b384bf 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -20,6 +20,7 @@ from scim2_models import SearchRequest from scim2_models import ServiceProviderConfig +from scim2_client.errors import InvalidCursorError from scim2_client.errors import RequestPayloadValidationError from scim2_client.errors import ResponsePayloadValidationError from scim2_client.errors import SCIMClientError @@ -345,6 +346,12 @@ def check_response( try: return actual_type.model_validate(response_payload, scim_ctx=scim_ctx) except ValidationError as exc: + cursor_errors = [e for e in exc.errors() if e["type"] == "scim_invalidCursor"] + if cursor_errors: + scim_exc = InvalidCursorError() + if sys.version_info >= (3, 11): # pragma: no cover + scim_exc.add_note(str(exc)) + raise scim_exc from exc scim_exc = ResponsePayloadValidationError() if sys.version_info >= (3, 11): # pragma: no cover scim_exc.add_note(str(exc)) @@ -444,6 +451,10 @@ def _prepare_query_request( payload = query_parameters elif isinstance(query_parameters, SearchRequest): + if query_parameters.cursor and query_parameters.start_index: + raise InvalidCursorError( + message="cursor and startIndex are mutually exclusive" + ) payload = query_parameters.model_dump( exclude_unset=True, exclude={"schemas"}, diff --git a/tests/test_query.py b/tests/test_query.py index dceb548..694bb69 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -13,6 +13,7 @@ from scim2_models import User from scim2_client import SCIMRequestError +from scim2_client.errors import InvalidCursorError from scim2_client.errors import RequestNetworkError from scim2_client.errors import ResponsePayloadValidationError from scim2_client.errors import SCIMClientError @@ -663,6 +664,40 @@ def test_invalid_resource_model(sync_client): sync_client.query(Group) +def test_cursor_and_start_index_mutually_exclusive(sync_client): + """cursor and startIndex MUST NOT be used together per RFC 9865.""" + + req = SearchRequest(cursor="abc123", start_index=1) + with pytest.raises(InvalidCursorError, match="mutually exclusive"): + sync_client.query(User, query_parameters=req) + + +def test_response_invalid_cursor_chars(sync_client): + """Server returning a nextCursor with reserved characters raises InvalidCursorError.""" + from scim2_models import Context + + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 1, + "nextCursor": "invalid%cursor", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + } + ], + } + with pytest.raises(InvalidCursorError): + sync_client.check_response( + payload=payload, + status_code=200, + headers={"content-type": "application/scim+json"}, + expected_types=[ListResponse[User]], + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + def test_service_provider_config_endpoint(sync_client): """Test that querying the /ServiceProviderConfig enpdoint correctly returns a ServiceProviderConfig (and not a ListResponse).""" response = sync_client.query(ServiceProviderConfig) From 366a0e5041ba1833db0516081568af2b07738676 Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 9 Jul 2026 11:47:51 +0200 Subject: [PATCH 03/10] refactor: index and cursor exclusivity should not be enforced in client --- scim2_client/client.py | 4 ---- tests/test_query.py | 8 -------- 2 files changed, 12 deletions(-) diff --git a/scim2_client/client.py b/scim2_client/client.py index 0b384bf..82d2d91 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -451,10 +451,6 @@ def _prepare_query_request( payload = query_parameters elif isinstance(query_parameters, SearchRequest): - if query_parameters.cursor and query_parameters.start_index: - raise InvalidCursorError( - message="cursor and startIndex are mutually exclusive" - ) payload = query_parameters.model_dump( exclude_unset=True, exclude={"schemas"}, diff --git a/tests/test_query.py b/tests/test_query.py index 694bb69..46a56bc 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -664,14 +664,6 @@ def test_invalid_resource_model(sync_client): sync_client.query(Group) -def test_cursor_and_start_index_mutually_exclusive(sync_client): - """cursor and startIndex MUST NOT be used together per RFC 9865.""" - - req = SearchRequest(cursor="abc123", start_index=1) - with pytest.raises(InvalidCursorError, match="mutually exclusive"): - sync_client.query(User, query_parameters=req) - - def test_response_invalid_cursor_chars(sync_client): """Server returning a nextCursor with reserved characters raises InvalidCursorError.""" from scim2_models import Context From d3fbceb99126ad4114112c49f42706a762329a70 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 14 Sep 2026 09:22:48 +0200 Subject: [PATCH 04/10] fix: update with get_model_by_payload --- scim2_client/client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scim2_client/client.py b/scim2_client/client.py index 82d2d91..51601dd 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -19,6 +19,7 @@ from scim2_models import Schema from scim2_models import SearchRequest from scim2_models import ServiceProviderConfig +from scim2_models import get_model_by_payload from scim2_client.errors import InvalidCursorError from scim2_client.errors import RequestPayloadValidationError @@ -327,7 +328,7 @@ def check_response( if response_payload is None: return None - actual_type = Resource.get_by_payload( + actual_type = get_model_by_payload( expected_types, response_payload, with_extensions=False ) @@ -380,7 +381,7 @@ def _prepare_create_request( resource_model = resource.__class__ else: - resource_model = Resource.get_by_payload(self.resource_models, resource) + resource_model = get_model_by_payload(self.resource_models, resource) if not resource_model: raise SCIMRequestError( "Cannot guess resource type from the payload" @@ -561,7 +562,7 @@ def _prepare_replace_request( resource_model = resource.__class__ else: - resource_model = Resource.get_by_payload(self.resource_models, resource) + resource_model = get_model_by_payload(self.resource_models, resource) if not resource_model: raise SCIMRequestError( "Cannot guess resource type from the payload", From 9c70e04810b1b418531030f3d5670b3dee815f13 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 14 Sep 2026 14:19:50 +0200 Subject: [PATCH 05/10] fix: code styling and error message --- scim2_client/client.py | 4 +++- scim2_client/errors.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scim2_client/client.py b/scim2_client/client.py index 51601dd..3613bda 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -347,7 +347,9 @@ def check_response( try: return actual_type.model_validate(response_payload, scim_ctx=scim_ctx) except ValidationError as exc: - cursor_errors = [e for e in exc.errors() if e["type"] == "scim_invalidCursor"] + cursor_errors = [ + e for e in exc.errors() if e["type"] == "scim_invalidCursor" + ] if cursor_errors: scim_exc = InvalidCursorError() if sys.version_info >= (3, 11): # pragma: no cover diff --git a/scim2_client/errors.py b/scim2_client/errors.py index f4f6b32..d79d452 100644 --- a/scim2_client/errors.py +++ b/scim2_client/errors.py @@ -75,13 +75,15 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: ) super().__init__(message, *args, **kwargs) + class InvalidCountError(SCIMRequestError): - """Cursor has expired.""" + """Count value is invalid.""" def __init__(self, *args: Any, **kwargs: Any) -> None: message = kwargs.pop("message", "Invalid count") super().__init__(message, *args, **kwargs) + class ExpiredCursorError(SCIMRequestError): """Cursor has expired.""" @@ -89,6 +91,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: message = kwargs.pop("message", "Expired cursor") super().__init__(message, *args, **kwargs) + class SCIMResponseError(SCIMClientError): """Base exception for errors happening during response payload validation.""" From e46721b74b7b9e256190127d92a3117b17a74754 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 14 Sep 2026 15:09:47 +0200 Subject: [PATCH 06/10] test: fix coverage --- tests/test_errors.py | 49 +++++++++++++++++++++++++++++++++++++++++++ tests/test_query.py | 50 +++++++++++++++++++++----------------------- 2 files changed, 73 insertions(+), 26 deletions(-) create mode 100644 tests/test_errors.py diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..e28e502 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,49 @@ +from scim2_client.errors import ExpiredCursorError +from scim2_client.errors import InvalidCountError +from scim2_client.errors import InvalidCursorError +from scim2_client.errors import SCIMRequestError + + +def test_invalid_cursor_error_default_message(): + exc = InvalidCursorError() + assert exc.message == "Cursor value is invalid." + assert str(exc) == "Cursor value is invalid." + assert isinstance(exc, SCIMRequestError) + + +def test_invalid_cursor_error_custom_message(): + exc = InvalidCursorError(message="custom cursor issue") + assert exc.message == "custom cursor issue" + assert str(exc) == "custom cursor issue" + + +def test_invalid_count_error_default_message(): + exc = InvalidCountError() + assert exc.message == "Invalid count" + assert str(exc) == "Invalid count" + assert isinstance(exc, SCIMRequestError) + + +def test_invalid_count_error_custom_message(): + exc = InvalidCountError(message="custom count issue") + assert exc.message == "custom count issue" + assert str(exc) == "custom count issue" + + +def test_expired_cursor_error_default_message(): + exc = ExpiredCursorError() + assert exc.message == "Expired cursor" + assert str(exc) == "Expired cursor" + assert isinstance(exc, SCIMRequestError) + + +def test_expired_cursor_error_custom_message(): + exc = ExpiredCursorError(message="custom expiry issue") + assert exc.message == "custom expiry issue" + assert str(exc) == "custom expiry issue" + + +def test_cursor_and_count_errors_carry_source(): + source = {"cursor": "abc"} + exc = InvalidCursorError(source=source) + assert exc.source is source diff --git a/tests/test_query.py b/tests/test_query.py index 46a56bc..5105d20 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,6 +1,7 @@ import datetime import pytest +from scim2_models import Context from scim2_models import Error from scim2_models import Group from scim2_models import ListResponse @@ -323,6 +324,29 @@ def test_user_with_invalid_id(sync_client): response = sync_client.query(User, "unknown", raise_scim_errors=False) assert response == Error(detail="Resource unknown not found", status=404) +def test_cursor_errors(sync_client): + """Test that a nextCursor with reserved characters raises InvalidCursorError.""" + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 1, + "nextCursor": "invalid%cursor", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + } + ], + } + with pytest.raises(InvalidCursorError, match="Cursor value is invalid."): + sync_client.check_response( + payload=payload, + status_code=200, + headers={"content-type": "application/scim+json"}, + expected_types=[ListResponse[User]], + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + def test_raise_scim_errors(sync_client): """Test that querying an user with an invalid id raises an exception.""" @@ -664,32 +688,6 @@ def test_invalid_resource_model(sync_client): sync_client.query(Group) -def test_response_invalid_cursor_chars(sync_client): - """Server returning a nextCursor with reserved characters raises InvalidCursorError.""" - from scim2_models import Context - - payload = { - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": 1, - "nextCursor": "invalid%cursor", - "Resources": [ - { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "id": "2819c223-7f76-453a-919d-413861904646", - "userName": "bjensen@example.com", - } - ], - } - with pytest.raises(InvalidCursorError): - sync_client.check_response( - payload=payload, - status_code=200, - headers={"content-type": "application/scim+json"}, - expected_types=[ListResponse[User]], - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - ) - - def test_service_provider_config_endpoint(sync_client): """Test that querying the /ServiceProviderConfig enpdoint correctly returns a ServiceProviderConfig (and not a ListResponse).""" response = sync_client.query(ServiceProviderConfig) From aa6d35c065cb324facccc186c62cf7b14c1abc5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Wed, 30 Sep 2026 17:33:52 +0200 Subject: [PATCH 07/10] test: cover cursor pagination through query --- tests/test_query.py | 99 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 22 deletions(-) diff --git a/tests/test_query.py b/tests/test_query.py index bb15bee..0627d55 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,9 +1,10 @@ import datetime import pytest -from scim2_models import Context from scim2_models import Error +from scim2_models import ExpiredCursorException from scim2_models import Group +from scim2_models import InvalidCursorException from scim2_models import InvalidValueException from scim2_models import ListResponse from scim2_models import Meta @@ -322,28 +323,82 @@ def test_user_with_invalid_id(sync_client): assert response == Error(detail="Resource unknown not found", status=404) -def test_cursor_errors(sync_client): - """Test that a response with an invalid nextCursor raises ResponsePayloadValidationException.""" - payload = { - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": 1, - "nextCursor": "invalid%cursor", - "Resources": [ - { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "id": "2819c223-7f76-453a-919d-413861904646", - "userName": "bjensen@example.com", - } - ], - } +def test_cursor_pagination(httpserver, sync_client): + """Test that the cursor is sent in the query string and the next cursor is returned.""" + httpserver.expect_oneshot_request( + "/Users", query_string="cursor=&count=1" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "itemsPerPage": 1, + "nextCursor": "VZUTiyhEQJ94IR", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + } + ], + }, + status=200, + content_type="application/scim+json", + ) + + response = sync_client.query( + User, query_parameters=SearchRequest(cursor="", count=1) + ) + assert response.next_cursor == "VZUTiyhEQJ94IR" + assert response.total_results is None + assert response.resources[0].user_name == "bjensen@example.com" + + +@pytest.mark.parametrize("field", ["nextCursor", "previousCursor"]) +@pytest.mark.parametrize("cursor", ["invalid%cursor", ""]) +def test_invalid_response_cursor(httpserver, sync_client, field, cursor): + """Test that a response with an invalid cursor raises ResponsePayloadValidationException.""" + httpserver.expect_oneshot_request("/Users").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 1, + field: cursor, + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + } + ], + }, + status=200, + content_type="application/scim+json", + ) + with pytest.raises(ResponsePayloadValidationException): - sync_client.check_response( - payload=payload, - status_code=200, - headers={"content-type": "application/scim+json"}, - expected_types=[ListResponse[User]], - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - ) + sync_client.query(User, query_parameters=SearchRequest(cursor="abc")) + + +@pytest.mark.parametrize( + "scim_type,exception", + [ + ("invalidCursor", InvalidCursorException), + ("expiredCursor", ExpiredCursorException), + ], +) +def test_cursor_rejected_by_server(httpserver, sync_client, scim_type, exception): + """Test that a cursor rejected by the server raises the matching exception.""" + httpserver.expect_oneshot_request("/Users").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "scimType": scim_type, + "detail": "Cursor rejected", + "status": "400", + }, + status=400, + content_type="application/scim+json", + ) + + with pytest.raises(exception, match="Cursor rejected"): + sync_client.query(User, query_parameters=SearchRequest(cursor="abc")) def test_raise_scim_errors(sync_client): From 103d3559d6da289ba18799d23795341f3891fed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Wed, 30 Sep 2026 17:33:52 +0200 Subject: [PATCH 08/10] doc: document cursor pagination --- doc/changelog.rst | 14 ++++++++++++++ scim2_client/client.py | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/doc/changelog.rst b/doc/changelog.rst index 2feb017..ed0ed1d 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,20 @@ Changelog ========= +[Unreleased] +------------ + +Added +^^^^^ +- Cursor-based pagination (:rfc:`9865`). Pass ``cursor`` in :class:`~scim2_models.SearchRequest` + and read the next cursor in :attr:`~scim2_models.ListResponse.next_cursor`. + A response with an invalid ``nextCursor`` or ``previousCursor`` raises + :class:`~scim2_client.ResponsePayloadValidationException`. + +Changed +^^^^^^^ +- scim2-models 0.10.0 is now the minimum supported version. + [0.10.0] - 2026-09-27 --------------------- diff --git a/scim2_client/client.py b/scim2_client/client.py index a7f7a8c..f7d3575 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -1345,6 +1345,10 @@ def query( Use :class:`~scim2_models.SearchRequest` when listing resources, to also pass ``filter``, ``sortBy``, ``sortOrder``, ``startIndex`` and ``count`` (:rfc:`RFC 7644 §3.4.2 <7644#section-3.4.2>`). + Pass ``cursor`` instead of ``startIndex`` for cursor-based pagination + (:rfc:`RFC 9865 §2 <9865#section-2>`). An empty cursor requests the + first page. The response gives the cursor of the next page in + :attr:`~scim2_models.ListResponse.next_cursor`. :param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`. :param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`. :param expected_status_codes: The list of expected status codes form the response. @@ -1813,6 +1817,10 @@ async def query( Use :class:`~scim2_models.SearchRequest` when listing resources, to also pass ``filter``, ``sortBy``, ``sortOrder``, ``startIndex`` and ``count`` (:rfc:`RFC 7644 §3.4.2 <7644#section-3.4.2>`). + Pass ``cursor`` instead of ``startIndex`` for cursor-based pagination + (:rfc:`RFC 9865 §2 <9865#section-2>`). An empty cursor requests the + first page. The response gives the cursor of the next page in + :attr:`~scim2_models.ListResponse.next_cursor`. :param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`. :param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`. :param expected_status_codes: The list of expected status codes form the response. From f2053caec155fa188d7784557f15e61445e38ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Wed, 30 Sep 2026 18:26:03 +0200 Subject: [PATCH 09/10] chore: require scim2-models 0.10.1 --- doc/changelog.rst | 2 +- pyproject.toml | 2 +- uv.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index ed0ed1d..b9acead 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -13,7 +13,7 @@ Added Changed ^^^^^^^ -- scim2-models 0.10.0 is now the minimum supported version. +- scim2-models 0.10.1 is now the minimum supported version. [0.10.0] - 2026-09-27 --------------------- diff --git a/pyproject.toml b/pyproject.toml index 6623357..b735ece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ requires-python = ">= 3.11" dependencies = [ - "scim2-models>=0.10.0", + "scim2-models>=0.10.1", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index af11ad7..72caf17 100644 --- a/uv.lock +++ b/uv.lock @@ -1240,7 +1240,7 @@ doc = [ [package.metadata] requires-dist = [ { name = "httpx2", marker = "extra == 'httpx2'", specifier = ">=2.12" }, - { name = "scim2-models", specifier = ">=0.10.0" }, + { name = "scim2-models", specifier = ">=0.10.1" }, { name = "werkzeug", marker = "extra == 'werkzeug'", specifier = ">=3.1.3" }, ] provides-extras = ["httpx2", "werkzeug"] @@ -1270,15 +1270,15 @@ doc = [ [[package]] name = "scim2-models" -version = "0.10.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lark" }, { name = "pydantic", extra = ["email"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/1c/6134213f67f2edb41b3984db8ddd3a2793ffc91ee29153141b87fc88a112/scim2_models-0.10.0.tar.gz", hash = "sha256:1c7e03d24d5c9dff3399d55ef9cb978ceb0c1516980434bae20991c450cee8a4", size = 102168, upload-time = "2026-09-30T13:54:07.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/eb/7a51b7b1f508a18a784f3427c26563505eda510e08bf06584440e9c93c14/scim2_models-0.10.1.tar.gz", hash = "sha256:f97d6fb5eecf0a7ef2b75f301a0b5370f9be2bfa9b072d3c566800460196f624", size = 101791, upload-time = "2026-09-30T16:19:18.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/f1/e2eef6eadeca70a6fb55ca442936dce9d6031a1c86c3e3a433b6767c09c4/scim2_models-0.10.0-py3-none-any.whl", hash = "sha256:a14d938a5d1c22e2ba6d1888896d7992b3aa55c1522b41d1f2f0fd7531a0bf20", size = 124765, upload-time = "2026-09-30T13:54:06.271Z" }, + { url = "https://files.pythonhosted.org/packages/97/57/7970e29757eba393a2fc8815e8fa3d0272cef73d397c532d9bbe99bbdba0/scim2_models-0.10.1-py3-none-any.whl", hash = "sha256:7ced87b5cd6ed2735721f17357d8934837f3ea5f9172751e9df4b66afa8baf1a", size = 124372, upload-time = "2026-09-30T16:19:16.666Z" }, ] [[package]] From 95e4a31796dda9ffd48012165ce10c56fc3b8e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Wed, 30 Sep 2026 18:26:03 +0200 Subject: [PATCH 10/10] test: accept the last page of a cursor-only server --- doc/changelog.rst | 1 + tests/test_query.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/doc/changelog.rst b/doc/changelog.rst index b9acead..a4aad00 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -8,6 +8,7 @@ Added ^^^^^ - Cursor-based pagination (:rfc:`9865`). Pass ``cursor`` in :class:`~scim2_models.SearchRequest` and read the next cursor in :attr:`~scim2_models.ListResponse.next_cursor`. + When the server advertises cursor pagination, a response without ``totalResults`` is accepted. A response with an invalid ``nextCursor`` or ``previousCursor`` raises :class:`~scim2_client.ResponsePayloadValidationException`. diff --git a/tests/test_query.py b/tests/test_query.py index 0627d55..01981b6 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -8,6 +8,7 @@ from scim2_models import InvalidValueException from scim2_models import ListResponse from scim2_models import Meta +from scim2_models import Pagination from scim2_models import Resource from scim2_models import ResponseParameters from scim2_models import SCIMException @@ -17,6 +18,8 @@ from scim2_models import UniquenessException from scim2_models import User +from scim2_client.engines.httpx2 import Client +from scim2_client.engines.httpx2 import SyncSCIMClient from scim2_client.errors import RequestNetworkException from scim2_client.errors import ResponsePayloadValidationException from scim2_client.errors import SCIMResponseException @@ -352,6 +355,41 @@ def test_cursor_pagination(httpserver, sync_client): assert response.resources[0].user_name == "bjensen@example.com" +def test_cursor_pagination_last_page(httpserver): + """Test that the last page of a cursor-only server needs neither a cursor nor totalResults.""" + httpserver.expect_oneshot_request( + "/Users", query_string="cursor=VZUTiyhEQJ94IR" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "itemsPerPage": 1, + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + } + ], + }, + status=200, + content_type="application/scim+json", + ) + provider = ScimProvider( + models=[User, Group], + config=ServiceProviderConfig(pagination=Pagination(cursor=True, index=False)), + ) + + with Client(base_url=f"http://localhost:{httpserver.port}") as client: + scim_client = SyncSCIMClient(client, provider=provider) + response = scim_client.query( + User, query_parameters=SearchRequest(cursor="VZUTiyhEQJ94IR") + ) + + assert response.next_cursor is None + assert response.total_results is None + assert response.resources[0].user_name == "bjensen@example.com" + + @pytest.mark.parametrize("field", ["nextCursor", "previousCursor"]) @pytest.mark.parametrize("cursor", ["invalid%cursor", ""]) def test_invalid_response_cursor(httpserver, sync_client, field, cursor):