diff --git a/src/xero/exceptions.py b/src/xero/exceptions.py index a4a62e3..1e7cebc 100644 --- a/src/xero/exceptions.py +++ b/src/xero/exceptions.py @@ -69,10 +69,17 @@ def __init__(self, response): class XeroUnauthorized(XeroException): # HTTP 401: Unauthorized def __init__(self, response): - payload = parse_qs(response.text) - self.errors = [payload["oauth_problem"][0]] - self.problem = self.errors[0] - super().__init__(response, payload["oauth_problem_advice"][0]) + if response.headers["content-type"].startswith("application/json"): + data = json.loads(response.text) + msg = data.get("Detail", "") + self.errors = [msg.split(":")[0]] + self.problem = self.errors[0] + super().__init__(response, msg) + else: + payload = parse_qs(response.text) + self.errors = [payload["oauth_problem"][0]] + self.problem = self.errors[0] + super().__init__(response, payload["oauth_problem_advice"][0]) class XeroForbidden(XeroException): diff --git a/tests/test_auth.py b/tests/test_auth.py index 4a7b178..901737d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -30,7 +30,9 @@ class PublicCredentialsTest(unittest.TestCase): def test_initial_constructor(self, r_post): "Initial construction causes a request to get a request token" r_post.return_value = Mock( - status_code=200, text="oauth_token=token&oauth_token_secret=token_secret" + status_code=200, + text="oauth_token=token&oauth_token_secret=token_secret", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = PublicCredentials( @@ -64,6 +66,7 @@ def test_bad_credentials(self, r_post): r_post.return_value = Mock( status_code=401, text="oauth_problem=consumer_key_unknown&oauth_problem_advice=Consumer%20key%20was%20not%20recognised", + headers={"content-type": "text/html; charset=utf-8"}, ) with self.assertRaises(XeroUnauthorized): @@ -127,7 +130,9 @@ def test_validated_constructor(self, r_post): def test_url(self, r_post): "The request token URL can be obtained" r_post.return_value = Mock( - status_code=200, text="oauth_token=token&oauth_token_secret=token_secret" + status_code=200, + text="oauth_token=token&oauth_token_secret=token_secret", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = PublicCredentials(consumer_key="key", consumer_secret="secret") @@ -140,7 +145,9 @@ def test_url(self, r_post): def test_url_with_scope(self, r_post): "The request token URL includes the scope parameter" r_post.return_value = Mock( - status_code=200, text="oauth_token=token&oauth_token_secret=token_secret" + status_code=200, + text="oauth_token=token&oauth_token_secret=token_secret", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = PublicCredentials( @@ -153,7 +160,9 @@ def test_url_with_scope(self, r_post): def test_configurable_url(self, r_post): "Test configurable API url" r_post.return_value = Mock( - status_code=200, text="oauth_token=token&oauth_token_secret=token_secret" + status_code=200, + text="oauth_token=token&oauth_token_secret=token_secret", + headers={"content-type": "text/html; charset=utf-8"}, ) url = "https//api-tls.xero.com" @@ -170,6 +179,7 @@ def test_verify(self, r_post): r_post.return_value = Mock( status_code=200, text="oauth_token=verified_token&oauth_token_secret=verified_token_secret", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = PublicCredentials( @@ -212,6 +222,7 @@ def test_verify_failure(self, r_post): r_post.return_value = Mock( status_code=401, text="oauth_problem=bad_verifier&oauth_problem_advice=The consumer was denied access to this resource.", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = PublicCredentials( @@ -256,7 +267,9 @@ class PartnerCredentialsTest(unittest.TestCase): def test_initial_constructor(self, r_post): "Initial construction causes a request to get a request token" r_post.return_value = Mock( - status_code=200, text="oauth_token=token&oauth_token_secret=token_secret" + status_code=200, + text="oauth_token=token&oauth_token_secret=token_secret", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = PartnerCredentials( @@ -293,6 +306,7 @@ def test_refresh(self, r_post): r_post.return_value = Mock( status_code=200, text="oauth_token=token2&oauth_token_secret=token_secret2&oauth_session_handle=session", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = PartnerCredentials( @@ -329,6 +343,7 @@ def test_configurable_url(self, r_post): r_post.return_value = Mock( status_code=200, text="oauth_token=token&oauth_token_secret=token_secret&oauth_session_handle=session", + headers={"content-type": "text/html; charset=utf-8"}, ) url = "https//api-tls.xero.com" diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 2a98523..e32be14 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -23,13 +23,11 @@ class ExceptionsTest(unittest.TestCase): def test_bad_request(self, r_put): "Data with validation errors raises a bad request exception" # Verified response from the live API - head = dict() - head["content-type"] = "text/xml; charset=utf-8" r_put.return_value = Mock( status_code=400, encoding="utf-8", text=mock_data.bad_request_text, - headers=head, + headers={"content-type": "text/xml; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -74,13 +72,14 @@ def test_bad_request(self, r_put): @patch("requests.put") def test_bad_request_invalid_response(self, r_put): "If the error response from the backend is malformed (or truncated), raise a XeroExceptionUnknown" - head = {"content-type": "text/xml; charset=utf-8"} - # Same error as before, but the response got cut off prematurely bad_response = mock_data.bad_request_text[:1000] r_put.return_value = Mock( - status_code=400, encoding="utf-8", text=bad_response, headers=head + status_code=400, + encoding="utf-8", + text=bad_response, + headers={"content-type": "text/xml; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -109,12 +108,10 @@ def test_bad_request_invalid_response(self, r_put): def test_unregistered_app(self, r_get): "An app without a signature raises a BadRequest exception, but with HTML payload" # Verified response from the live API - head = dict() - head["content-type"] = "text/html; charset=utf-8" r_get.return_value = Mock( status_code=400, text="oauth_problem=signature_method_rejected&oauth_problem_advice=No%20certificates%20have%20been%20registered%20for%20the%20consumer", - headers=head, + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -148,6 +145,7 @@ def test_unauthorized_invalid(self, r_get): r_get.return_value = Mock( status_code=401, text="oauth_problem=signature_invalid&oauth_problem_advice=Failed%20to%20validate%20signature", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -172,12 +170,13 @@ def test_unauthorized_invalid(self, r_get): self.fail("Should raise a XeroUnauthorized, not %s" % e) @patch("requests.get") - def test_unauthorized_expired(self, r_get): + def test_unauthorized_expired_text(self, r_get): "A session with an expired token raises an unauthorized exception" # Verified response from the live API r_get.return_value = Mock( status_code=401, text="oauth_problem=token_expired&oauth_problem_advice=The%20access%20token%20has%20expired", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -201,12 +200,47 @@ def test_unauthorized_expired(self, r_get): except Exception as e: self.fail("Should raise a XeroUnauthorized, not %s" % e) + @patch("requests.get") + def test_unauthorized_expired_json(self, r_get): + "A session with an expired token raises an unauthorized exception" + # Verified response from the live API + r_get.return_value = Mock( + status_code=401, + text='{"Type":null,"Title":"Unauthorized","Status":401,"Detail":"TokenExpired: token expired at 01/01/2001 00:00:00"}', + headers={"content-type": "application/json; charset=utf-8"}, + ) + + credentials = Mock(base_url="") + xero = Xero(credentials) + + try: + xero.contacts.all() + self.fail("Should raise a XeroUnauthorized.") + + except XeroUnauthorized as e: + # Error messages have been extracted + self.assertEqual( + str(e), "TokenExpired: token expired at 01/01/2001 00:00:00" + ) + self.assertEqual(e.errors[0], "TokenExpired") + + # The response has also been stored + self.assertEqual(e.response.status_code, 401) + self.assertEqual( + e.response.text, + '{"Type":null,"Title":"Unauthorized","Status":401,"Detail":"TokenExpired: token expired at 01/01/2001 00:00:00"}', + ) + except Exception as e: + self.fail("Should raise a XeroUnauthorized, not %s" % e) + @patch("requests.get") def test_forbidden(self, r_get): "In case of an SSL failure, a Forbidden exception is raised" # This is unconfirmed; haven't been able to verify this response from API. r_get.return_value = Mock( - status_code=403, text="The client SSL certificate was not valid." + status_code=403, + text="The client SSL certificate was not valid.", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -233,7 +267,9 @@ def test_not_found(self, r_get): "If you request an object that doesn't exist, a Not Found exception is raised" # Verified response from the live API r_get.return_value = Mock( - status_code=404, text="The resource you're looking for cannot be found" + status_code=404, + text="The resource you're looking for cannot be found", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -261,7 +297,10 @@ def test_rate_limit_exceeded_429(self, r_get): # Response based off Xero documentation; not confirmed by reality. r_get.return_value = Mock( status_code=429, - headers={"X-Rate-Limit-Problem": "day"}, + headers={ + "X-Rate-Limit-Problem": "day", + "content-type": "text/html; charset=utf-8", + }, text="oauth_problem=rate%20limit%20exceeded&oauth_problem_advice=please%20wait%20before%20retrying%20the%20xero%20api", ) @@ -296,6 +335,7 @@ def test_internal_error(self, r_get): r_get.return_value = Mock( status_code=500, text="An unhandled error with the Xero API occurred. Contact the Xero API team if problems persist.", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -326,7 +366,10 @@ def test_not_implemented(self, r_post): "In case of an SSL failure, a Forbidden exception is raised" # Verified response from the live API r_post.return_value = Mock( - status_code=501, encoding="utf-8", text=mock_data.not_implemented_text + status_code=501, + encoding="utf-8", + text=mock_data.not_implemented_text, + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -353,6 +396,7 @@ def test_rate_limit_exceeded(self, r_get): r_get.return_value = Mock( status_code=503, text="oauth_problem=rate%20limit%20exceeded&oauth_problem_advice=please%20wait%20before%20retrying%20the%20xero%20api", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = Mock(base_url="") @@ -381,7 +425,9 @@ def test_not_available(self, r_get): "If Xero goes down for maintenance, an exception is raised" # Response based off Xero documentation; not confirmed by reality. r_get.return_value = Mock( - status_code=503, text="The Xero API is currently offline for maintenance" + status_code=503, + text="The Xero API is currently offline for maintenance", + headers={"content-type": "text/html; charset=utf-8"}, ) credentials = Mock(base_url="")