Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

update XeroUnauthorized to handle json response #337

Merged
merged 10 commits into from
Sep 30, 2023
15 changes: 11 additions & 4 deletions src/xero/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("text/html"):
payload = parse_qs(response.text)
self.errors = [payload["oauth_problem"][0]]
self.problem = self.errors[0]
super().__init__(response, payload["oauth_problem_advice"][0])
elif 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)


class XeroForbidden(XeroException):
Expand Down
25 changes: 20 additions & 5 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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(
Expand All @@ -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"
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
Expand Down
76 changes: 61 additions & 15 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="")
Expand Down Expand Up @@ -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="")
Expand Down Expand Up @@ -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="")
Expand Down Expand Up @@ -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="")
Expand All @@ -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="")
Expand All @@ -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="")
Expand All @@ -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="")
Expand Down Expand Up @@ -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",
)

Expand Down Expand Up @@ -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="")
Expand Down Expand Up @@ -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="")
Expand All @@ -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="")
Expand Down Expand Up @@ -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="")
Expand Down