diff --git a/scim2_client/client.py b/scim2_client/client.py index 76bb2be..3613bda 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -19,7 +19,9 @@ 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 from scim2_client.errors import ResponsePayloadValidationError from scim2_client.errors import SCIMClientError @@ -326,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 ) @@ -345,6 +347,14 @@ 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)) @@ -373,7 +383,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" @@ -554,7 +564,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", diff --git a/scim2_client/errors.py b/scim2_client/errors.py index deb02a5..d79d452 100644 --- a/scim2_client/errors.py +++ b/scim2_client/errors.py @@ -65,6 +65,33 @@ 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): + """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.""" + + 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.""" 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 dceb548..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 @@ -13,6 +14,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 @@ -322,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."""