From 82d56fb8b232a47fe1aa3be092e8f31c8445733c Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 25 Jun 2026 11:13:05 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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)