Skip to content

Commit

Permalink
Merge pull request #337 from schapman1974/patch-1
Browse files Browse the repository at this point in the history
update XeroUnauthorized to handle json response
  • Loading branch information
freakboy3742 authored Sep 30, 2023
2 parents 0d5f121 + 7415455 commit c306ca7
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 24 deletions.
15 changes: 11 additions & 4 deletions src/xero/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,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):
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

0 comments on commit c306ca7

Please sign in to comment.