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

DRY the identity pick and validation #76

Closed
wants to merge 5 commits into from

Conversation

Glutexo
Copy link
Collaborator

@Glutexo Glutexo commented Dec 21, 2018

Introduced a new custom Exception to DRY the identity pick and validation. If the header is missing, undecodeable or invalid, this new Exception is raised. Aborting the request with a Forbidden status code is now unified in one place.

This will help with counting invalid logins, which now must accompany every abort call.

Asking @dehort for a review.

@Glutexo
Copy link
Collaborator Author

Glutexo commented Jan 4, 2019

Created an alternative pull request to this: #84 The code itself is the same, but there is no mocking in the tests. Pick whichever you like better.

@Glutexo
Copy link
Collaborator Author

Glutexo commented Jan 4, 2019

Fixed the unit tests – there was a test case copied from elsewhere that should not have been there at all.

Introduced a new custom Exception to DRY the identity pick and
validation. If the header is missing, undecodable or invalid, this new
Exception is raised. Aborting the request with a Forbidden status code
is not unified in one place.
Refactor the tests a bit:

* Moved the test descriptions from the noisy comments to the test
  names.
* Renamed unused variables.
* Removed a Flask function dependency.
Get rid of the helper method. Use unittest.mock’s side_effect
instead.
@Glutexo
Copy link
Collaborator Author

Glutexo commented Jan 15, 2019

Refactored tests for this PR too:

  • Moved the test descriptions from the noisy comments to the test names.
  • Got rid of the Flask function dependency.
  • Renamed unused variables.

Also rebased on current master.

_validate(identity)
except InvalidIdentityError:
abort(Forbidden.code)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs to catch the top level Exception so that any exceptions are caught and cause an abort.

We also need to make sure we are logging why the authentication request failed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any uncaught exception causes an implicit abort with a 500 Internal Server Error. If we want to make sure these aborts returns a valid JSON response (which we should!), it’s a matter of a generic error handling. Thus it belongs neither to the identity module nor to this pull request. Created a separate issue for that #110.

@@ -21,26 +31,30 @@ def _pick_identity():
try:
payload = request.headers[_IDENTITY_HEADER]
except KeyError:
abort(Forbidden.code)
raise InvalidIdentityError("The identity header is missing.")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to log why the authentication request failed.

Looks like there are a couple of approaches:

  1. Catch the top level Exception here (and in the other places the custom exception is used), log the reason ("unable to retrieve auth header...", "header cannot be decoded", "invalid header", etc), re-throw the exception. We could possibly remove the custom exception classes that we aren't being used that much.

  2. Keep the custom exception classes. Modify the exception handling at line 55 to log the reason why the authn request failed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the failure reason is the exception message, we can log it in the common except block. Good catch as exception implicitly causing an abort is logged automatically, but our manual abort does not log it. 👍

Made the error messages more concise yet more verbose and added logging for them. Maybe these changes should go to their own PR, but added them anyway. I didn’t add tests for the logging.

test_unit.py Outdated

@patch("app.auth.from_encoded")
@patch("app.auth.request", headers={_IDENTITY_HEADER: Mock()})
def test_identity_is_decoded(self, request, from_encoded):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I see much value in this test case. Isn't this same area of the code going to be hit in the valid/invalid decode testing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed all the test cases for this _pick_identity method and use it directly in the requires_identity decorator tests instead of patching them. Thanks to that those tests are covering all the success and failure cases of the _pick_identity method.

test_unit.py Outdated
from_encoded.assert_called_once_with(request.headers[_IDENTITY_HEADER])

@patch("app.auth.request", headers={_IDENTITY_HEADER: Mock()})
def test_identity_is_not_valid_if_decode_fails(self, _):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should make the exception handling more generic (catch the top level Exception class) in the _pick_identity method and remove or simplify this test case.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem lies in the fact that the identity can actually be bad in different ways, rather than in the number of possible exception types. I simplified this by removing the _pick_identity method tests. Now, I test only the actual abort, which is the same for any failure. The sub-tests are there for the various cases of a bad header, replacing the brittle _pick_identity tests with more durable ones.



@patch("app.auth._request_ctx_stack")
class AuthRequiresIdentityTestCase(TestCase):
Copy link
Collaborator

@dehort dehort Jan 18, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test case seems very complicated. This seems like a lot of (somewhat brittle) code. For example, if a method name changes in the impl, we have to chase down all of those name changes in here. Repeating the internal method names throughout these types of tests concerns me. Maybe I am misunderstanding things.

At the least, it looks like a few of the tests could be combined.

In the end, I think we're really just testing for the case where:

  • the auth header is not set
  • the auth header cannot be decoded
  • the auth header is invalid

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the tests are rather brittle, focusing on internal workings more than on the outcome. Even though I wouldn’t say that exactly a method rename would necessarily be a problem, many other light refactorings would.

I simplified the cases by removing the separate tests for the _pick_identity method and by using the _pick_identity and _validate methods without patching. Now, the decorator is tested as a whole unit. Only the Flask stuff remains mocked and that makes it different from the API tests.

Make the authentication error messages more verbose yet more
concise. Added logging of the identity validation errors as warnings.
Made tests simpler and more explicit. The requires_identity decorator
is now tested as a unit including the behavior of the _pick_identity
and _get_identity helper methods. Those helpers need not to be patched
and the tests of the right header format can be more readable.
Copy link
Collaborator Author

@Glutexo Glutexo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review, @dehort! I incorporated some changes:

  • Improved the error messages and added logging.
  • Simplified the tests.

More information in the inline comments. Is it better now, or even mergeable?

@@ -21,26 +31,30 @@ def _pick_identity():
try:
payload = request.headers[_IDENTITY_HEADER]
except KeyError:
abort(Forbidden.code)
raise InvalidIdentityError("The identity header is missing.")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the failure reason is the exception message, we can log it in the common except block. Good catch as exception implicitly causing an abort is logged automatically, but our manual abort does not log it. 👍

Made the error messages more concise yet more verbose and added logging for them. Maybe these changes should go to their own PR, but added them anyway. I didn’t add tests for the logging.

_validate(identity)
except InvalidIdentityError:
abort(Forbidden.code)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any uncaught exception causes an implicit abort with a 500 Internal Server Error. If we want to make sure these aborts returns a valid JSON response (which we should!), it’s a matter of a generic error handling. Thus it belongs neither to the identity module nor to this pull request. Created a separate issue for that #110.



@patch("app.auth._request_ctx_stack")
class AuthRequiresIdentityTestCase(TestCase):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the tests are rather brittle, focusing on internal workings more than on the outcome. Even though I wouldn’t say that exactly a method rename would necessarily be a problem, many other light refactorings would.

I simplified the cases by removing the separate tests for the _pick_identity method and by using the _pick_identity and _validate methods without patching. Now, the decorator is tested as a whole unit. Only the Flask stuff remains mocked and that makes it different from the API tests.

test_unit.py Outdated
from_encoded.assert_called_once_with(request.headers[_IDENTITY_HEADER])

@patch("app.auth.request", headers={_IDENTITY_HEADER: Mock()})
def test_identity_is_not_valid_if_decode_fails(self, _):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem lies in the fact that the identity can actually be bad in different ways, rather than in the number of possible exception types. I simplified this by removing the _pick_identity method tests. Now, I test only the actual abort, which is the same for any failure. The sub-tests are there for the various cases of a bad header, replacing the brittle _pick_identity tests with more durable ones.

test_unit.py Outdated

@patch("app.auth.from_encoded")
@patch("app.auth.request", headers={_IDENTITY_HEADER: Mock()})
def test_identity_is_decoded(self, request, from_encoded):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed all the test cases for this _pick_identity method and use it directly in the requires_identity decorator tests instead of patching them. Thanks to that those tests are covering all the success and failure cases of the _pick_identity method.

self._dummy_view_func()
with self.assertRaises(NoIdentityError):
_get_identity()

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like test_request_is_aborted_on_bad_identity and test_bad_identity_is_not_stored are essentially testing the same thing. Is there a reason these tests are not combined?

with self._patch_request_valid():
self._dummy_view_func()
self.assertEqual(Identity(account_number="some number"), _get_identity())

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like test_request_is_not_aborted_on_valid_identity and test_valid_identity_is_stored are essentially testing the same thing. Is there a reason these tests are not combined?

@Glutexo
Copy link
Collaborator Author

Glutexo commented Apr 16, 2019

This is now outdated. Replaced by #203.

@Glutexo Glutexo closed this Apr 16, 2019
@Glutexo Glutexo deleted the dry_identity branch April 16, 2019 11:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants