diff --git a/cterasdk/exceptions/base.py b/cterasdk/exceptions/base.py index d34a3d4b..08bdba9f 100644 --- a/cterasdk/exceptions/base.py +++ b/cterasdk/exceptions/base.py @@ -2,12 +2,27 @@ class CTERAException(Exception): """ Base Exception. - :parm str message: Error message + :param str message: Error message + :ivar str reason: Portal error message from :attr:`~BaseException.__cause__` when it is an HTTP transport error """ def __init__(self, message=None, **kwargs): super().__init__(message) for k, v in kwargs.items(): setattr(self, k, v) + @property + def reason(self): + cause = self.__cause__ + if cause is None: + return None + error_object = getattr(cause, 'error', None) + if error_object is None: + return None + response = getattr(error_object, 'response', None) + portal_error = getattr(response, 'error', None) if response else None + if portal_error is None or isinstance(portal_error, str): + return None + return getattr(portal_error, 'msg', None) + def __repr__(self): return str(self) diff --git a/docs/source/UserGuides/Miscellaneous/Changelog.rst b/docs/source/UserGuides/Miscellaneous/Changelog.rst index 19039c3a..e12cf442 100644 --- a/docs/source/UserGuides/Miscellaneous/Changelog.rst +++ b/docs/source/UserGuides/Miscellaneous/Changelog.rst @@ -1,6 +1,17 @@ Changelog ========= +2.20.50 +------- + +Bug Fixes +^^^^^^^^^ + +* Expose portal validation details via :attr:`~cterasdk.exceptions.CTERAException.reason` (for + example, when ``admins.modify`` fails because the new password matches the current password). + +Related issue: `SDK-369 `_ + 2.20.49 ------- diff --git a/docs/source/UserGuides/Miscellaneous/Exceptions.rst b/docs/source/UserGuides/Miscellaneous/Exceptions.rst index 9cd4fd39..a991dc58 100644 --- a/docs/source/UserGuides/Miscellaneous/Exceptions.rst +++ b/docs/source/UserGuides/Miscellaneous/Exceptions.rst @@ -6,6 +6,9 @@ Exceptions :members: :show-inheritance: +:attr:`~cterasdk.exceptions.CTERAException.reason` returns the portal ``msg`` from +:attr:`~BaseException.__cause__` when the cause is an HTTP transport error. + .. autoclass:: cterasdk.exceptions.ObjectNotFoundException :noindex: :members: diff --git a/tests/ut/core/admin/test_users.py b/tests/ut/core/admin/test_users.py index 1caace86..38bf628a 100644 --- a/tests/ut/core/admin/test_users.py +++ b/tests/ut/core/admin/test_users.py @@ -319,6 +319,86 @@ def test_modify_admin_user(self): actual_param = self._global_admin.api.put.call_args[0][1] self._assert_equal_objects(actual_param, new_user_object) + def test_modify_admin_user_same_password_exposes_portal_reason(self): + current_user_object = self._get_admin_object( + name=self._username, + email=self._email, + firstName=self._first_name, + lastName=self._last_name, + password=self._password, + role=self._role, + company=None, + comment=None + ) + portal_msg = 'Object validation failed (field: password error: Old and new passwords cannot be the same)' + password_value = 'Str0ngP@ssword!1' + portal_error = Object() + portal_error.msg = portal_msg + field = Object() + field.name = 'password' + field.desc = 'Old and new passwords cannot be the same' + field.val = password_value + portal_error.fields = [field] + http_error = Object() + http_error.request = Object(url='/administrators/' + self._username) + http_error.response = Object(status=500, error=portal_error) + self._init_global_admin(get_response=current_user_object) + self._global_admin.api.put = mock.MagicMock(side_effect=exceptions.transport.InternalServerError(http_error)) + with self.assertRaises(exceptions.CTERAException) as error: + admins.Administrators(self._global_admin).modify(self._username, password=self._password) + ref = f'/administrators/{self._username}' + self._global_admin.api.get.assert_called_once_with(ref) + self._global_admin.api.put.assert_called_once_with(ref, mock.ANY) + self.assertEqual(f'Could not modify user: {ref}', str(error.exception)) + self.assertIn('Old and new passwords cannot be the same', error.exception.reason) + self.assertNotIn(password_value, error.exception.reason) + + def test_modify_admin_user_http_error_without_portal_msg_uses_generic_message(self): + current_user_object = self._get_admin_object( + name=self._username, + email=self._email, + firstName=self._first_name, + lastName=self._last_name, + password=self._password, + role=self._role, + company=None, + comment=None + ) + portal_error = Object() + http_error = Object() + http_error.request = Object(url='/administrators/' + self._username) + http_error.response = Object(status=500, error=portal_error) + self._init_global_admin(get_response=current_user_object) + self._global_admin.api.put = mock.MagicMock(side_effect=exceptions.transport.InternalServerError(http_error)) + ref = f'/administrators/{self._username}' + expected_message = f'Could not modify user: {ref}' + with self.assertRaises(exceptions.CTERAException) as error: + admins.Administrators(self._global_admin).modify(self._username, password=self._password) + self.assertEqual(expected_message, str(error.exception)) + self.assertIsNone(error.exception.reason) + + def test_modify_admin_user_non_http_error_uses_generic_message(self): + current_user_object = self._get_admin_object( + name=self._username, + email=self._email, + firstName=self._first_name, + lastName=self._last_name, + password=self._password, + role=self._role, + company=None, + comment=None + ) + self._init_global_admin(get_response=current_user_object) + self._global_admin.api.put = mock.MagicMock( + side_effect=exceptions.ObjectNotFoundException('Object not found') + ) + ref = f'/administrators/{self._username}' + expected_message = f'Could not modify user: {ref}' + with self.assertRaises(exceptions.CTERAException) as error: + admins.Administrators(self._global_admin).modify(self._username, password=self._password) + self.assertEqual(expected_message, str(error.exception)) + self.assertIsNone(error.exception.reason) + def test_delete_admin_user(self): execute_response = 'Success' self._init_global_admin(execute_response=execute_response) diff --git a/tests/ut/exceptions/test_base.py b/tests/ut/exceptions/test_base.py new file mode 100644 index 00000000..5877cf2d --- /dev/null +++ b/tests/ut/exceptions/test_base.py @@ -0,0 +1,49 @@ +from cterasdk.common import Object +from cterasdk.exceptions import CTERAException +from cterasdk.exceptions.transport import InternalServerError +from tests.ut import base + + +class TestCTERAExceptionReason(base.BaseTest): + + @staticmethod + def _internal_server_error(portal_error): + http_error = Object() + http_error.request = Object(url='https://example/admin/api/administrators/alice') + http_error.response = Object(status=500, error=portal_error) + return InternalServerError(http_error) + + def test_reason_returns_portal_msg_from_http_cause(self): + portal_msg = 'Object validation failed (field: password error: Old and new passwords cannot be the same)' + portal_error = Object() + portal_error.msg = portal_msg + try: + raise CTERAException('Could not modify user: /administrators/alice') from self._internal_server_error( + portal_error + ) + except CTERAException as error: + self.assertEqual(portal_msg, error.reason) + + def test_reason_is_none_without_cause(self): + self.assertIsNone(CTERAException('Failed').reason) + + def test_reason_is_none_when_cause_is_not_http_transport_error(self): + try: + raise CTERAException('Failed') from ValueError('not transport') + except CTERAException as error: + self.assertIsNone(error.reason) + + def test_reason_is_none_when_portal_error_has_no_msg(self): + try: + raise CTERAException('Failed') from self._internal_server_error(Object()) + except CTERAException as error: + self.assertIsNone(error.reason) + + def test_reason_is_none_when_response_error_is_plain_string(self): + http_error = Object() + http_error.request = Object(url='https://example/admin') + http_error.response = Object(status=500, error='Internal Server Error') + try: + raise CTERAException('Failed') from InternalServerError(http_error) + except CTERAException as error: + self.assertIsNone(error.reason)