diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..9445573 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,7 @@ +.idea/ +.mypy_cache +.pytest_cache + +.*_credentials +.coverage +*.iml diff --git a/server/Dockerfile b/server/Dockerfile index f374eeb..1a1914d 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.6-slim-stretch +FROM python:3.7-slim-stretch MAINTAINER Drew Short ENV CORVUS_APP_DIRECTORY /opt/corvus diff --git a/server/Pipfile b/server/Pipfile index 3743c67..7ba8462 100644 --- a/server/Pipfile +++ b/server/Pipfile @@ -27,4 +27,4 @@ sphinxcontrib-httpdomain = ">=1.7,<1.8" sphinx-jsondomain = "*" [requires] -python_version = "3.6" +python_version = "3.7" diff --git a/server/Pipfile.lock b/server/Pipfile.lock index b199ee0..462e484 100644 --- a/server/Pipfile.lock +++ b/server/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "d7520d408998ef148cfa4cd8138e44f093e5604c75938e27dde748c17afa11ee" + "sha256": "79477dccc0014acea82818b6431dccece7c6ae8f6543c3f9ac1480283fc160ec" }, "pipfile-spec": 6, "requires": { - "python_version": "3.6" + "python_version": "3.7" }, "sources": [ { diff --git a/server/corvus/api/authentication_api.py b/server/corvus/api/authentication_api.py index 9a706fe..16152f4 100644 --- a/server/corvus/api/authentication_api.py +++ b/server/corvus/api/authentication_api.py @@ -73,8 +73,9 @@ def get_tokens() -> APIResponse: """ page, per_page = get_pagination_params(request.args) user_token_page = user_token_service.find_by_user(g.user, page, per_page) - if user_token_page is not None: - return APIResponse(APIPage.from_page(user_token_page), 200) + api_page = APIPage.from_page(user_token_page) + if api_page is not None: + return APIResponse(api_page, 200) return abort(404) diff --git a/server/corvus/api/model.py b/server/corvus/api/model.py index 8434666..5822def 100644 --- a/server/corvus/api/model.py +++ b/server/corvus/api/model.py @@ -66,7 +66,7 @@ class APIPage(BaseAPIMessage): self.page = page self.count = len(items) self.total_count = total_count - self.last_page = last_page + self.last_page = last_page if last_page > 0 else page self.items = items def to_dict(self) -> Dict[str, Any]: @@ -79,7 +79,12 @@ class APIPage(BaseAPIMessage): 'items': self.items } + def is_empty(self) -> bool: + """Check if the page is empty.""" + return self.count == 0 and self.total_count == 0 + @staticmethod def from_page(page: Pagination) -> 'APIPage': """Create an APIPage from a Pagination object.""" - return APIPage(page.page, page.total, page.pages, page.items) + page = APIPage(page.page, page.total, page.pages, page.items) + return page if not page.is_empty() else None diff --git a/server/tests/api/test_authentication_api.py b/server/tests/api/test_authentication_api.py index ba1b5c0..43c90db 100644 --- a/server/tests/api/test_authentication_api.py +++ b/server/tests/api/test_authentication_api.py @@ -1,18 +1,25 @@ +from datetime import timedelta + +import rfc3339 +from flask import json +from flask.testing import FlaskClient + from tests.conftest import AuthActions def test_login_happy_path(auth: AuthActions): - result = auth.login() - assert result.status_code == 200 - assert result.json['token'] is not None and len(result.json['token']) > 0 + with auth as result: + assert result.status_code == 200 + assert result.json[ + 'token'] is not None and len(result.json['token']) > 0 def test_bump_happy_path(auth: AuthActions): - auth.login() - result = auth.bump() - assert result.status_code == 200 - assert (result.json['lastLoginTime'] is not None - and len(result.json['lastLoginTime']) > 0) + with auth: + result = auth.bump() + assert result.status_code == 200 + assert (result.json['lastLoginTime'] is not None + and len(result.json['lastLoginTime']) > 0) def test_logout_happy_path(auth: AuthActions): @@ -20,3 +27,130 @@ def test_logout_happy_path(auth: AuthActions): result = auth.logout() assert result.status_code == 200 assert result.json['success'] + + +def test_get_tokens_no_tokens(auth: AuthActions, client: FlaskClient): + auth_header = auth.get_authorization_header_basic() + result = client.get( + '/auth/token', + headers={ + auth_header[0]: auth_header[1] + }) + assert 404 == result.status_code + assert result.json is not None + + +def test_get_tokens(auth: AuthActions, client: FlaskClient): + with auth: + auth_header = auth.get_authorization_header_basic() + result = client.get( + '/auth/token', + headers={ + auth_header[0]: auth_header[1] + }) + assert 200 == result.status_code + assert result.json is not None + assert result.json['page'] == 1 + assert result.json['lastPage'] == 1 + assert result.json['count'] == 1 + assert result.json['totalCount'] == 1 + assert result.json['items'][0]['token'] == auth.token + + +def test_get_nonexistant_token(auth: AuthActions, client: FlaskClient): + auth_header = auth.get_authorization_header_basic() + result = client.get( + '/auth/token/not-a-token', + headers={ + auth_header[0]: auth_header[1] + }) + assert 404 == result.status_code + assert result.json is not None + + +def test_create_get_delete_token(auth: AuthActions, client: FlaskClient): + auth_header = auth.get_authorization_header_basic() + result = client.post( + '/auth/token', + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }, + data=json.dumps({ + 'note': 'test note', + 'enabled': False + })) + assert 200 == result.status_code + assert result.json is not None + assert result.json['token'] is not None + assert result.json['note'] == 'test note' + assert not result.json['enabled'] + assert not result.json['isValid'] + auth_token = result.json['token'] + result = client.get( + '/auth/token/%s' % auth_token, + headers={ + auth_header[0]: auth_header[1] + }) + assert 200 == result.status_code + assert result.json is not None + assert result.json['token'] == auth_token + result = client.delete( + '/auth/token/%s' % auth_token, + headers={ + auth_header[0]: auth_header[1] + }) + assert 200 == result.status_code + assert result.json is not None + assert 'message' not in result.json + assert result.json['success'] + + +def test_create_get_delete_expired_token( + auth: AuthActions, client: FlaskClient): + auth_header = auth.get_authorization_header_basic() + result = client.post( + '/auth/token', + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }, + data=json.dumps({ + 'note': 'test note', + 'expirationTime': rfc3339.format( + rfc3339.datetime.now() - timedelta(days=1)) + })) + assert 200 == result.status_code + assert result.json is not None + assert result.json['token'] is not None + assert result.json['note'] == 'test note' + assert not result.json['isValid'] + auth_token = result.json['token'] + result = client.get( + '/auth/token/%s' % auth_token, + headers={ + auth_header[0]: auth_header[1] + }) + assert 200 == result.status_code + assert result.json is not None + assert result.json['token'] == auth_token + result = client.delete( + '/auth/token/%s' % auth_token, + headers={ + auth_header[0]: auth_header[1] + }) + assert 200 == result.status_code + assert result.json is not None + assert 'message' not in result.json + assert result.json['success'] + + +def test_delete_nonexistant_token(auth: AuthActions, client: FlaskClient): + auth_header = auth.get_authorization_header_basic() + result = client.delete( + '/auth/token/not-a-token', + headers={ + auth_header[0]: auth_header[1] + }) + assert 404 == result.status_code + assert result.json is not None diff --git a/server/tests/api/test_health_api.py b/server/tests/api/test_health_api.py new file mode 100644 index 0000000..5eb477a --- /dev/null +++ b/server/tests/api/test_health_api.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from flask.testing import FlaskClient + +from tests.conftest import AuthActions + + +def test_get_health_happy_path(auth: AuthActions, client: FlaskClient): + with auth: + auth_header = auth.get_authorization_header_token() + result = client.get( + '/health', + headers={ + auth_header[0]: auth_header[1] + }) + assert 200 == result.status_code + assert result.json is not None + assert result.json['message'] == 'Service is healthy' + assert result.json['success'] diff --git a/server/tests/api/test_user_api.py b/server/tests/api/test_user_api.py index 2e7aacb..01afb1f 100644 --- a/server/tests/api/test_user_api.py +++ b/server/tests/api/test_user_api.py @@ -8,159 +8,172 @@ from tests.conftest import AuthActions def test_get_users_happy_path(auth: AuthActions, client: FlaskClient): - auth.login() - auth_header = auth.get_authorization_header_token() - result = client.get( - '/user', - headers={ - auth_header[0]: auth_header[1] - }) - assert 200 == result.status_code - assert result.json is not None - assert result.json['page'] == 1 - assert result.json['lastPage'] == 1 - assert result.json['count'] == 1 - assert result.json['totalCount'] == 1 - assert result.json['items'][0]['name'] == auth.username + with auth: + auth_header = auth.get_authorization_header_token() + result = client.get( + '/user', + headers={ + auth_header[0]: auth_header[1] + }) + assert 200 == result.status_code + assert result.json is not None + assert result.json['page'] == 1 + assert result.json['lastPage'] == 1 + assert result.json['count'] == 1 + assert result.json['totalCount'] == 1 + assert result.json['items'][0]['name'] == auth.username def test_get_users_nonexistent_page(auth: AuthActions, client: FlaskClient): - auth.login() - auth_header = auth.get_authorization_header_token() - result = client.get( - '/user?page=2', - headers={ - auth_header[0]: auth_header[1] - }) - assert 404 == result.status_code - assert result.json is not None + with auth: + auth_header = auth.get_authorization_header_token() + result = client.get( + '/user?page=2', + headers={ + auth_header[0]: auth_header[1] + }) + assert 404 == result.status_code + assert result.json is not None + + +def test_get_users_bad_page_parameters(auth: AuthActions, client: FlaskClient): + with auth: + auth_header = auth.get_authorization_header_token() + result = client.get( + '/user?page=a', + headers={ + auth_header[0]: auth_header[1] + }) + assert 400 == result.status_code + assert result.json is not None def test_get_user_happy_path(auth: AuthActions, client: FlaskClient): - auth.login() - auth_header = auth.get_authorization_header_token() - result = client.get( - '/user/{}'.format(client.application.config['test_username']), - headers={ - auth_header[0]: auth_header[1] - }) - assert 200 == result.status_code - assert result.json is not None - assert result.json['name'] == client.application.config['test_username'] + with auth: + auth_header = auth.get_authorization_header_token() + result = client.get( + '/user/{}'.format(client.application.config['test_username']), + headers={ + auth_header[0]: auth_header[1] + }) + assert 200 == result.status_code + assert result.json is not None + assert result.json['name'] == client.application.config[ + 'test_username'] def test_patch_user_happy_path(auth: AuthActions, client: FlaskClient): - auth.login() - auth_header = auth.get_authorization_header_token() - last_login_time = rfc3339.format(datetime.now()) - - user = client.get( - '/user/{}'.format(client.application.config['test_username']), - headers={ - auth_header[0]: auth_header[1] - }) - - patched_user = client.patch( - '/user/{}'.format(client.application.config['test_username']), - data=json.dumps({ - 'version': user.json['version'], - 'lastLoginTime': last_login_time - }), - headers={ - auth_header[0]: auth_header[1], - 'Content-Type': 'application/json' - }) - - assert 200 == patched_user.status_code - assert patched_user.json['version'] == user.json['version'] + 1 - assert patched_user.json['lastLoginTime'] == last_login_time + with auth: + auth_header = auth.get_authorization_header_token() + last_login_time = rfc3339.format(datetime.now()) + + user = client.get( + '/user/{}'.format(client.application.config['test_username']), + headers={ + auth_header[0]: auth_header[1] + }) + + patched_user = client.patch( + '/user/{}'.format(client.application.config['test_username']), + data=json.dumps({ + 'version': user.json['version'], + 'lastLoginTime': last_login_time + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + + assert 200 == patched_user.status_code + assert patched_user.json['version'] == user.json['version'] + 1 + assert patched_user.json['lastLoginTime'] == last_login_time def test_register_user_happy_path(auth: AuthActions, client: FlaskClient): - auth.login() - auth_header = auth.get_authorization_header_token() - result = client.post( - '/user', - data=json.dumps({ - 'name': 'test_registered_user' - }), - headers={ - auth_header[0]: auth_header[1], - 'Content-Type': 'application/json' - }) - assert 200 == result.status_code - assert result.json is not None - assert result.json['name'] == 'test_registered_user' + with auth: + auth_header = auth.get_authorization_header_token() + result = client.post( + '/user', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + assert 200 == result.status_code + assert result.json is not None + assert result.json['name'] == 'test_registered_user' def test_register_user_invalid_password( auth: AuthActions, client: FlaskClient): - auth.login() - auth_header = auth.get_authorization_header_token() - result = client.post( - '/user', - data=json.dumps({ - 'name': 'test_registered_user', - 'password': '' - }), - headers={ - auth_header[0]: auth_header[1], - 'Content-Type': 'application/json' - }) - assert 400 == result.status_code - assert result.json is not None - assert 'message' in result.json + with auth: + auth_header = auth.get_authorization_header_token() + result = client.post( + '/user', + data=json.dumps({ + 'name': 'test_registered_user', + 'password': '' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + assert 400 == result.status_code + assert result.json is not None + assert 'message' in result.json def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient): - auth.login() - auth_header = auth.get_authorization_header_token() - result1 = client.post( - '/user', - data=json.dumps({ - 'name': 'test_registered_user' - }), - headers={ - auth_header[0]: auth_header[1], - 'Content-Type': 'application/json' - }) - result2 = client.post( - '/user', - data=json.dumps({ - 'name': 'test_registered_user' - }), - headers={ - auth_header[0]: auth_header[1], - 'Content-Type': 'application/json' - }) - assert 200 == result1.status_code - assert result1.json is not None - assert result1.json['name'] == 'test_registered_user' - assert 400 == result2.status_code - assert result2.json is not None - assert result2.json['message'] == 'User name is already taken.' + with auth: + auth_header = auth.get_authorization_header_token() + result1 = client.post( + '/user', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + result2 = client.post( + '/user', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + assert 200 == result1.status_code + assert result1.json is not None + assert result1.json['name'] == 'test_registered_user' + assert 400 == result2.status_code + assert result2.json is not None + assert result2.json['message'] == 'User name is already taken.' def test_delete_user_happy_path(auth: AuthActions, client: FlaskClient): - auth.login() - auth_header = auth.get_authorization_header_token() - result1 = client.post( - '/user', - data=json.dumps({ - 'name': 'test_registered_user' - }), - headers={ - auth_header[0]: auth_header[1], - 'Content-Type': 'application/json' - }) - result2 = client.delete( - '/user/'+result1.json['name'], - headers={ - auth_header[0]: auth_header[1] - }) - assert 200 == result1.status_code - assert result1.json is not None - assert result1.json['name'] == 'test_registered_user' - assert 200 == result2.status_code - assert result2.json is not None - assert 'message' in result2.json + with auth: + auth_header = auth.get_authorization_header_token() + result1 = client.post( + '/user', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + result2 = client.delete( + '/user/'+result1.json['name'], + headers={ + auth_header[0]: auth_header[1] + }) + assert 200 == result1.status_code + assert result1.json is not None + assert result1.json['name'] == 'test_registered_user' + assert 200 == result2.status_code + assert result2.json is not None + assert 'message' in result2.json diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 1e80701..75b1aa5 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -122,6 +122,12 @@ class AuthActions(object): .decode('utf8').strip() return 'X-Auth-Token', '{}'.format(credentials) + def __enter__(self) -> 'AuthActions': + return self.login() + + def __exit__(self, type: Any, value: Any, traceback: Any) -> None: + self.logout() + @pytest.fixture def auth(client: Client) -> AuthActions: diff --git a/server/tests/service/test_authentication_service.py b/server/tests/service/test_authentication_service.py new file mode 100644 index 0000000..44bb4fe --- /dev/null +++ b/server/tests/service/test_authentication_service.py @@ -0,0 +1,38 @@ +import pytest + +from corvus import errors +from corvus.service import authentication_service + + +def test_validate_password_strength_good_password(): + proposed_good_password = 'AazZ1001' + assert proposed_good_password == authentication_service\ + .validate_password_strength(proposed_good_password) + + +def test_validate_password_strength_too_short(): + proposed_good_password = 'AazZ100' + with pytest.raises(errors.ValidationError) as error_info: + authentication_service.validate_password_strength( + proposed_good_password) + + +def test_validate_password_strength_missing_uppercase(): + proposed_good_password = 'aazz1001' + with pytest.raises(errors.ValidationError) as error_info: + authentication_service.validate_password_strength( + proposed_good_password) + + +def test_validate_password_strength_missing_lowercase(): + proposed_good_password = 'AAZZ1001' + with pytest.raises(errors.ValidationError) as error_info: + authentication_service.validate_password_strength( + proposed_good_password) + + +def test_validate_password_strength_missing_numbers(): + proposed_good_password = 'AAZZZZAA' + with pytest.raises(errors.ValidationError) as error_info: + authentication_service.validate_password_strength( + proposed_good_password)