From a844168b91d0ec5abbab331285c421e65576fcbf Mon Sep 17 00:00:00 2001 From: EliuX Date: Fri, 17 Apr 2020 17:24:45 -0500 Subject: [PATCH 01/13] fix: Close #82 Enforce users manipulate only their time entries --- .env.template | 3 +- commons/data_access_layer/cosmos_db.py | 87 +++++++++++-------- .../data_access_layer/cosmos_db_test.py | 54 +++++++++++- tests/conftest.py | 13 ++- .../time_entries/time_entries_model_test.py | 12 +-- .../time_entries_namespace_test.py | 38 +++++--- .../time_entries/time_entries_model.py | 75 +++++++++++----- 7 files changed, 199 insertions(+), 83 deletions(-) diff --git a/.env.template b/.env.template index 31a135a5..9d76e4e6 100644 --- a/.env.template +++ b/.env.template @@ -9,6 +9,7 @@ export FLASK_APP=time_tracker_api ## For Azure Cosmos DB export DATABASE_ACCOUNT_URI=https://.documents.azure.com:443 export DATABASE_MASTER_KEY= -export DATABASE_NAME= ### or # export COSMOS_DATABASE_URI=AccountEndpoint=;AccountKey= +## Also specify the database name +export DATABASE_NAME= diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 5e5e3ff6..4778d5d2 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -11,7 +11,7 @@ from werkzeug.exceptions import HTTPException from commons.data_access_layer.database import CRUDDao -from time_tracker_api.security import current_user_tenant_id, current_user_id +from time_tracker_api.security import current_user_tenant_id class CosmosDBFacade: @@ -105,10 +105,26 @@ def create_sql_condition_for_visibility(visible_only: bool, container_name='c') return '' @staticmethod - def create_sql_condition_for_owner_id(owner_id: str, container_name='c') -> str: - if owner_id: - return 'AND %s.owner_id=@owner_id' % container_name - return '' + def create_sql_where_conditions(conditions: dict, container_name='c') -> str: + where_conditions = [] + for k in conditions.keys(): + where_conditions.append('{c}.{var} = @{var}'.format(c=container_name, var=k)) + + if len(where_conditions) > 0: + return "AND {where_conditions_clause}".format( + where_conditions_clause=" AND ".join(where_conditions)) + else: + return "" + + @staticmethod + def append_conditions_values(params: list, conditions: dict) -> dict: + for k, v in conditions.items(): + params.append({ + "name": "@%s" % k, + "value": v + }) + + return params @staticmethod def check_visibility(item, throw_not_found_if_deleted): @@ -123,39 +139,41 @@ def create(self, data: dict, mapper: Callable = None): function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.container.create_item(body=data)) - def find(self, id: str, partition_key_value, visible_only=True, mapper: Callable = None): + def find(self, id: str, partition_key_value, peeker: 'function' = None, visible_only=True, mapper: Callable = None): found_item = self.container.read_item(id, partition_key_value) + if peeker: + peeker(found_item) + function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.check_visibility(found_item, visible_only)) - def find_all(self, partition_key_value: str, owner_id=None, max_count=None, offset=0, + def find_all(self, partition_key_value: str, conditions: dict = {}, max_count=None, offset=0, visible_only=True, mapper: Callable = None): # TODO Use the tenant_id param and change container alias max_count = self.get_page_size_or(max_count) - result = self.container.query_items( - query=""" + params = self.append_conditions_values([ + {"name": "@partition_key_value", "value": partition_key_value}, + {"name": "@offset", "value": offset}, + {"name": "@max_count", "value": max_count}, + ], conditions) + result = self.container.query_items(query=""" SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value - {owner_condition} {visibility_condition} {order_clause} + {conditions_clause} {visibility_condition} {order_clause} OFFSET @offset LIMIT @max_count """.format(partition_key_attribute=self.partition_key_attribute, visibility_condition=self.create_sql_condition_for_visibility(visible_only), - owner_condition=self.create_sql_condition_for_owner_id(owner_id), + conditions_clause=self.create_sql_where_conditions(conditions), order_clause=self.create_sql_order_clause()), - parameters=[ - {"name": "@partition_key_value", "value": partition_key_value}, - {"name": "@offset", "value": offset}, - {"name": "@max_count", "value": max_count}, - {"name": "@owner_id", "value": owner_id}, - ], - partition_key=partition_key_value, - max_item_count=max_count) + parameters=params, + partition_key=partition_key_value, + max_item_count=max_count) function_mapper = self.get_mapper_or_dict(mapper) return list(map(function_mapper, result)) def partial_update(self, id: str, changes: dict, partition_key_value: str, - visible_only=True, mapper: Callable = None): - item_data = self.find(id, partition_key_value, visible_only=visible_only, mapper=dict) + peeker: 'function' = None, visible_only=True, mapper: Callable = None): + item_data = self.find(id, partition_key_value, peeker=peeker, visible_only=visible_only, mapper=dict) item_data.update(changes) return self.update(id, item_data, mapper=mapper) @@ -164,10 +182,11 @@ def update(self, id: str, item_data: dict, mapper: Callable = None): function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.container.replace_item(id, body=item_data)) - def delete(self, id: str, partition_key_value: str, mapper: Callable = None): + def delete(self, id: str, partition_key_value: str, + peeker: 'function' = None, mapper: Callable = None): return self.partial_update(id, { 'deleted': str(uuid.uuid4()) - }, partition_key_value, visible_only=True, mapper=mapper) + }, partition_key_value, peeker=peeker, visible_only=True, mapper=mapper) def delete_permanently(self, id: str, partition_key_value: str) -> None: self.container.delete_item(id, partition_key_value) @@ -190,30 +209,21 @@ def on_update(self, update_item_data: dict): def create_sql_order_clause(self): if len(self.order_fields) > 0: return "ORDER BY c.{}".format(", c.".join(self.order_fields)) - else: - return "" + return "" class CosmosDBDao(CRUDDao): def __init__(self, repository: CosmosDBRepository): self.repository = repository - @property - def partition_key_value(self): - return current_user_tenant_id() - def get_all(self) -> list: - tenant_id: str = self.partition_key_value - owner_id = current_user_id() - return self.repository.find_all(partition_key_value=tenant_id, owner_id=owner_id) + return self.repository.find_all(partition_key_value=self.partition_key_value) def get(self, id): - tenant_id: str = self.partition_key_value - return self.repository.find(id, partition_key_value=tenant_id) + return self.repository.find(id, partition_key_value=self.partition_key_value) def create(self, data: dict): data[self.repository.partition_key_attribute] = self.partition_key_value - data['owner_id'] = current_user_id() return self.repository.create(data) def update(self, id, data: dict): @@ -222,8 +232,11 @@ def update(self, id, data: dict): partition_key_value=self.partition_key_value) def delete(self, id): - tenant_id: str = current_user_tenant_id() - self.repository.delete(id, partition_key_value=tenant_id) + self.repository.delete(id, partition_key_value=self.partition_key_value) + + @property + def partition_key_value(self): + return current_user_tenant_id() class CustomError(HTTPException): diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py index b126df89..b19cb5c6 100644 --- a/tests/commons/data_access_layer/cosmos_db_test.py +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -4,9 +4,10 @@ import pytest from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError from faker import Faker +from flask_restplus._http import HTTPStatus from pytest import fail -from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel +from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel, CustomError fake = Faker() Faker.seed() @@ -459,7 +460,7 @@ def test_find_all_can_find_deleted_items_only_if_visibile_only_is_true( assert deleted_item is not None assert deleted_item['deleted'] is not None - visible_items = cosmos_db_repository.find_all(sample_item['tenant_id']) + visible_items = cosmos_db_repository.find_all(partition_key_value=sample_item.get('tenant_id')) assert visible_items is not None assert any(item['id'] == sample_item['id'] for item in visible_items) == False, \ @@ -536,3 +537,52 @@ def test_delete_permanently_with_valid_id_should_succeed( except Exception as e: assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 + + +def test_repository_create_sql_where_conditions_with_multiple_values(cosmos_db_repository: CosmosDBRepository): + result = cosmos_db_repository.create_sql_where_conditions({ + 'owner_id': 'mark', + 'customer_id': 'me' + }, "c") + + assert result is not None + assert result == "AND c.owner_id = @owner_id AND c.customer_id = @customer_id" + + +def test_repository_create_sql_where_conditions_with_no_values(cosmos_db_repository: CosmosDBRepository): + result = cosmos_db_repository.create_sql_where_conditions({}, "c") + + assert result is not None + assert result == "" + + +def test_repository_append_conditions_values(cosmos_db_repository: CosmosDBRepository): + result = cosmos_db_repository.append_conditions_values([], {'owner_id': 'mark', 'customer_id': 'ioet'}) + + assert result is not None + assert result == [{'name': '@owner_id', 'value': 'mark'}, + {'name': '@customer_id', 'value': 'ioet'}] + + +def test_find_should_call_picker_if_it_was_specified(cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + another_item: dict): + def raise_bad_request_if_name_diff_the_one_from_sample_item(data: dict): + if sample_item['name'] != data['name']: + raise CustomError(HTTPStatus.BAD_REQUEST, "Anything") + + found_item = cosmos_db_repository.find(sample_item['id'], + partition_key_value=sample_item['tenant_id']) + + assert found_item is not None + assert found_item['id'] == sample_item['id'] + + try: + cosmos_db_repository.find(another_item['id'], + partition_key_value=another_item['tenant_id'], + peeker=raise_bad_request_if_name_diff_the_one_from_sample_item) + + fail('It should have not found any item because of condition') + except Exception as e: + assert e.code == HTTPStatus.BAD_REQUEST + assert e.description == "Anything" diff --git a/tests/conftest.py b/tests/conftest.py index 7dfd83ef..155c29b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -117,6 +117,17 @@ def sample_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dic return cosmos_db_repository.create(sample_item_data) +@pytest.fixture(scope="function") +def another_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dict: + sample_item_data = dict(id=fake.uuid4(), + name=fake.name(), + email=fake.safe_email(), + age=fake.pyint(min_value=10, max_value=80), + tenant_id=tenant_id) + + return cosmos_db_repository.create(sample_item_data) + + @pytest.yield_fixture(scope="module") -def time_entry_repository(cosmos_db_repository: CosmosDBRepository) -> TimeEntryCosmosDBRepository: +def time_entry_repository() -> TimeEntryCosmosDBRepository: return TimeEntryCosmosDBRepository() diff --git a/tests/time_tracker_api/time_entries/time_entries_model_test.py b/tests/time_tracker_api/time_entries/time_entries_model_test.py index fd094041..4d0f42e0 100644 --- a/tests/time_tracker_api/time_entries/time_entries_model_test.py +++ b/tests/time_tracker_api/time_entries/time_entries_model_test.py @@ -53,6 +53,7 @@ def test_find_interception_with_date_range_should_find(start_date: datetime, finally: time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) + def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, tenant_id: str, time_entry_repository: TimeEntryCosmosDBRepository): @@ -62,13 +63,13 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, try: colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date, - owner_id=owner_id, - partition_key_value=tenant_id) + owner_id=owner_id, + partition_key_value=tenant_id) non_colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date, - owner_id=owner_id, - partition_key_value=tenant_id, - ignore_id=existing_item.id) + owner_id=owner_id, + partition_key_value=tenant_id, + ignore_id=existing_item.id) colliding_result is not None assert any([existing_item.id == item.id for item in colliding_result]) @@ -77,4 +78,3 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, assert not any([existing_item.id == item.id for item in non_colliding_result]) finally: time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) - diff --git a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py index 0d209988..975797e5 100644 --- a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py +++ b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py @@ -113,7 +113,9 @@ def test_get_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker assert HTTPStatus.OK == response.status_code fake_time_entry == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id), partition_key_value=current_user_tenant_id()) + repository_find_mock.assert_called_once_with(str(valid_id), + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, @@ -130,7 +132,9 @@ def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id response = client.get("/time-entries/%s" % invalid_id, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), partition_key_value=current_user_tenant_id()) + repository_find_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): @@ -148,7 +152,8 @@ def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, m fake_time_entry == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_time_entry_input, - partition_key_value=current_user_tenant_id()) + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): @@ -185,7 +190,8 @@ def test_update_time_entry_should_return_not_found_with_invalid_id(client: Flask assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_time_entry_input, - partition_key_value=current_user_tenant_id()) + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): @@ -200,7 +206,8 @@ def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, moc assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_delete_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, @@ -216,7 +223,8 @@ def test_delete_time_entry_should_return_not_found_with_invalid_id(client: Flask assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, @@ -232,7 +240,8 @@ def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_for assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_stop_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture): @@ -247,7 +256,8 @@ def test_stop_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture) assert HTTPStatus.OK == response.status_code repository_update_mock.assert_called_once_with(str(valid_id), changes={"end_date": mocker.ANY}, - partition_key_value=current_user_tenant_id()) + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture): @@ -263,7 +273,8 @@ def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_update_mock.assert_called_once_with(invalid_id, changes={"end_date": ANY}, - partition_key_value=current_user_tenant_id()) + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_restart_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture): @@ -278,7 +289,8 @@ def test_restart_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixtu assert HTTPStatus.OK == response.status_code repository_update_mock.assert_called_once_with(str(valid_id), changes={"end_date": None}, - partition_key_value=current_user_tenant_id()) + partition_key_value=current_user_tenant_id(), + peeker=ANY) def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture): @@ -286,7 +298,8 @@ def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, moc from werkzeug.exceptions import UnprocessableEntity repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', - side_effect=UnprocessableEntity) + side_effect=UnprocessableEntity, + peeker=ANY) invalid_id = fake.word() response = client.post("/time-entries/%s/restart" % invalid_id, follow_redirects=True) @@ -294,4 +307,5 @@ def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, moc assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_update_mock.assert_called_once_with(invalid_id, changes={"end_date": None}, - partition_key_value=current_user_tenant_id()) + partition_key_value=current_user_tenant_id(), + peeker=ANY) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index daa780fe..9145f6db 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -1,4 +1,3 @@ -import abc from dataclasses import dataclass, field from typing import List, Callable @@ -8,13 +7,13 @@ from commons.data_access_layer.cosmos_db import CosmosDBDao, CosmosDBRepository, CustomError, CosmosDBModel, \ current_datetime, datetime_str from commons.data_access_layer.database import CRUDDao -from time_tracker_api.security import current_user_tenant_id +from time_tracker_api.security import current_user_id class TimeEntriesDao(CRUDDao): - @abc.abstractmethod - def delete_permanently(self, id: str): - raise NotImplementedError # pragma: no cover + @staticmethod + def current_user_id(): + return current_user_id() container_definition = { @@ -80,24 +79,24 @@ def on_update(self, updated_item_data: dict): def find_interception_with_date_range(self, start_date, end_date, owner_id, partition_key_value, ignore_id=None, visible_only=True, mapper: Callable = None): - # TODO Use the tenant_id param and change container alias + conditions = {"owner_id": owner_id} + params = self.append_conditions_values([ + {"name": "@partition_key_value", "value": partition_key_value}, + {"name": "@start_date", "value": start_date}, + {"name": "@end_date", "value": end_date or datetime_str(current_datetime())}, + {"name": "@ignore_id", "value": ignore_id}, + ], conditions) result = self.container.query_items( query=""" SELECT * FROM c WHERE c.tenant_id=@partition_key_value AND ((c.start_date BETWEEN @start_date AND @end_date) OR (c.end_date BETWEEN @start_date AND @end_date)) - {owner_condition} {ignore_id_condition} {visibility_condition} {order_clause} + {conditions_clause} {ignore_id_condition} {visibility_condition} {order_clause} """.format(partition_key_attribute=self.partition_key_attribute, ignore_id_condition=self.create_sql_ignore_id_condition(ignore_id), visibility_condition=self.create_sql_condition_for_visibility(visible_only), - owner_condition=self.create_sql_condition_for_owner_id(owner_id), + conditions_clause=self.create_sql_where_conditions(conditions), order_clause=self.create_sql_order_clause()), - parameters=[ - {"name": "@partition_key_value", "value": partition_key_value}, - {"name": "@start_date", "value": start_date}, - {"name": "@end_date", "value": end_date or datetime_str(current_datetime())}, - {"name": "@owner_id", "value": owner_id}, - {"name": "@ignore_id", "value": ignore_id}, - ], + parameters=params, partition_key=partition_key_value) function_mapper = self.get_mapper_or_dict(mapper) @@ -122,15 +121,43 @@ def validate_data(self, data): description="There is another time entry in that date range") -def create_dao() -> TimeEntriesDao: - repository = TimeEntryCosmosDBRepository() +class TimeEntriesCosmosDBDao(TimeEntriesDao, CosmosDBDao): + def __init__(self, repository): + CosmosDBDao.__init__(self, repository) + + @classmethod + def check_whether_current_user_owns_item(cls, data: dict): + if data.get('owner_id') is not None \ + and data.get('owner_id') != cls.current_user_id(): + raise CustomError(HTTPStatus.FORBIDDEN, + "The current user is not the owner of this time entry") + + def get_all(self) -> list: + return self.repository.find_all(partition_key_value=self.partition_key_value, + conditions={"owner_id": self.current_user_id()}) + + def get(self, id): + return self.repository.find(id, + partition_key_value=self.partition_key_value, + peeker=self.check_whether_current_user_owns_item) - class TimeEntriesCosmosDBDao(CosmosDBDao, TimeEntriesDao): - def __init__(self): - CosmosDBDao.__init__(self, repository) + def create(self, data: dict): + data[self.repository.partition_key_attribute] = self.partition_key_value + data["owner_id"] = self.current_user_id() + return self.repository.create(data) - def delete_permanently(self, id: str): - tenant_id: str = current_user_tenant_id() - self.repository.delete_permanently(id, partition_key_value=tenant_id) + def update(self, id, data: dict): + return self.repository.partial_update(id, + changes=data, + partition_key_value=self.partition_key_value, + peeker=self.check_whether_current_user_owns_item) + + def delete(self, id): + self.repository.delete(id, partition_key_value=self.partition_key_value, + peeker=self.check_whether_current_user_owns_item) + + +def create_dao() -> TimeEntriesDao: + repository = TimeEntryCosmosDBRepository() - return TimeEntriesCosmosDBDao() + return TimeEntriesCosmosDBDao(repository) From 3c2e9dacbd55928dcd38230172f77c5e7b98278b Mon Sep 17 00:00:00 2001 From: EliuX Date: Mon, 20 Apr 2020 10:39:38 -0500 Subject: [PATCH 02/13] fix: Close #85 #86 Ignore deleted objects and tweak ns constraints --- .../activities/activities_model.py | 2 +- .../activities/activities_namespace.py | 17 +-------- time_tracker_api/api.py | 20 ++++++++++ time_tracker_api/customers/customers_model.py | 2 +- .../customers/customers_namespace.py | 19 ++-------- .../project_types/project_types_model.py | 2 +- .../project_types/project_types_namespace.py | 34 +++++++---------- time_tracker_api/projects/projects_model.py | 2 +- .../projects/projects_namespace.py | 34 +++++++---------- .../time_entries/time_entries_namespace.py | 37 +++++++------------ 10 files changed, 69 insertions(+), 100 deletions(-) diff --git a/time_tracker_api/activities/activities_model.py b/time_tracker_api/activities/activities_model.py index 9a14c110..74813cd2 100644 --- a/time_tracker_api/activities/activities_model.py +++ b/time_tracker_api/activities/activities_model.py @@ -15,7 +15,7 @@ class ActivityDao(CRUDDao): 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { 'uniqueKeys': [ - {'paths': ['/name']}, + {'paths': ['/name', '/deleted']}, ] } } diff --git a/time_tracker_api/activities/activities_namespace.py b/time_tracker_api/activities/activities_namespace.py index 4e6b3f6d..d4f42f20 100644 --- a/time_tracker_api/activities/activities_namespace.py +++ b/time_tracker_api/activities/activities_namespace.py @@ -20,26 +20,13 @@ ), 'description': fields.String( title='Description', + required=False, description='Comments about the activity', example=faker.paragraph(), ) }) -activity_response_fields = { - 'id': fields.String( - readOnly=True, - required=True, - title='Identifier', - description='The unique identifier', - example=faker.uuid4(), - ), - 'tenant_id': fields.String( - required=True, - title='Identifier of Tenant', - description='Tenant this activity belongs to', - example=faker.uuid4(), - ), -} +activity_response_fields = {} activity_response_fields.update(common_fields) activity = ns.inherit( diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index b8b026ce..11ecf41a 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -15,13 +15,33 @@ description="API for the TimeTracker project" ) +# For matching UUIDs +UUID_REGEX = '[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12}' + # Common models structure common_fields = { + 'id': fields.String( + title='Identifier', + readOnly=True, + required=True, + description='The unique identifier', + pattern=UUID_REGEX, + example=faker.uuid4(), + ), + 'tenant_id': fields.String( + title='Identifier of Tenant', + readOnly=True, + required=True, + description='Tenant it belongs to', + # pattern=UUID_REGEX, This must be confirmed + example=faker.uuid4(), + ), 'deleted': fields.String( readOnly=True, required=True, title='Last event Identifier', description='Last event over this resource', + pattern=UUID_REGEX, ), } diff --git a/time_tracker_api/customers/customers_model.py b/time_tracker_api/customers/customers_model.py index 11e1af05..da9a9fcd 100644 --- a/time_tracker_api/customers/customers_model.py +++ b/time_tracker_api/customers/customers_model.py @@ -15,7 +15,7 @@ class CustomerDao(CRUDDao): 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { 'uniqueKeys': [ - {'paths': ['/name']}, + {'paths': ['/name', '/deleted']}, ] } } diff --git a/time_tracker_api/customers/customers_namespace.py b/time_tracker_api/customers/customers_namespace.py index f8064f95..f3b01fcc 100644 --- a/time_tracker_api/customers/customers_namespace.py +++ b/time_tracker_api/customers/customers_namespace.py @@ -12,35 +12,22 @@ # Customer Model customer_input = ns.model('CustomerInput', { 'name': fields.String( - required=True, title='Name', + required=True, max_length=50, description='Name of the customer', example=faker.company(), ), 'description': fields.String( title='Description', + required=False, max_length=250, description='Description about the customer', example=faker.paragraph(), ), }) -customer_response_fields = { - 'id': fields.String( - readOnly=True, - required=True, - title='Identifier', - description='The unique identifier', - example=faker.uuid4(), - ), - 'tenant_id': fields.String( - required=True, - title='Identifier of Tenant', - description='Tenant this customer belongs to', - example=faker.uuid4(), - ), -} +customer_response_fields = {} customer_response_fields.update(common_fields) customer = ns.inherit( diff --git a/time_tracker_api/project_types/project_types_model.py b/time_tracker_api/project_types/project_types_model.py index 9ff226d8..eb8564bc 100644 --- a/time_tracker_api/project_types/project_types_model.py +++ b/time_tracker_api/project_types/project_types_model.py @@ -15,7 +15,7 @@ class ProjectTypeDao(CRUDDao): 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { 'uniqueKeys': [ - {'paths': ['/name', '/customer_id']}, + {'paths': ['/name', '/customer_id', '/deleted']}, ] } } diff --git a/time_tracker_api/project_types/project_types_namespace.py b/time_tracker_api/project_types/project_types_namespace.py index 2ed93054..0e6c0ac6 100644 --- a/time_tracker_api/project_types/project_types_namespace.py +++ b/time_tracker_api/project_types/project_types_namespace.py @@ -2,7 +2,7 @@ from flask_restplus import Namespace, Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields +from time_tracker_api.api import common_fields, UUID_REGEX from time_tracker_api.project_types.project_types_model import create_dao faker = Faker() @@ -12,45 +12,37 @@ # ProjectType Model project_type_input = ns.model('ProjectTypeInput', { 'name': fields.String( - required=True, title='Name', + required=True, max_length=50, description='Name of the project type', example=faker.random_element(["Customer","Training","Internal"]), ), 'description': fields.String( title='Description', + required=False, max_length=250, - description='Description about the project type', + description='Comments about the project type', example=faker.paragraph(), ), 'customer_id': fields.String( title='Identifier of the Customer', - description='Customer this project type belongs to', + required=False, + description='Customer this project type belongs to. ' + 'If not specified, it will be considered an internal project of the tenant.', + pattern=UUID_REGEX, example=faker.uuid4(), ), 'parent_id': fields.String( - title='Identifier of Parent of the project type', - description='Defines a self reference of the model ProjectType', + title='Identifier of the parent project type', + required=False, + description='This parent node allows to created a tree-like structure for project types', + pattern=UUID_REGEX, example=faker.uuid4(), ), }) -project_type_response_fields = { - 'id': fields.String( - readOnly=True, - required=True, - title='Identifier', - description='The unique identifier', - example=faker.uuid4(), - ), - 'tenant_id': fields.String( - required=True, - title='Identifier of Tenant', - description='Tenant this project type belongs to', - example=faker.uuid4(), - ), -} +project_type_response_fields = {} project_type_response_fields.update(common_fields) project_type = ns.inherit( diff --git a/time_tracker_api/projects/projects_model.py b/time_tracker_api/projects/projects_model.py index 6700005e..a301840e 100644 --- a/time_tracker_api/projects/projects_model.py +++ b/time_tracker_api/projects/projects_model.py @@ -15,7 +15,7 @@ class ProjectDao(CRUDDao): 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { 'uniqueKeys': [ - {'paths': ['/name', '/customer_id']}, + {'paths': ['/name', '/customer_id', '/deleted']}, ] } } diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index 82a0fb8e..5fe4ad1e 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -2,7 +2,7 @@ from flask_restplus import Namespace, Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields +from time_tracker_api.api import common_fields, UUID_REGEX from time_tracker_api.projects.projects_model import create_dao faker = Faker() @@ -20,38 +20,30 @@ ), 'description': fields.String( title='Description', + required=False, max_length=250, description='Description about the project', example=faker.paragraph(), ), 'customer_id': fields.String( - required=True, title='Identifier of the Customer', - description='Customer this project belongs to', + required=False, + description='Customer this project type belongs to. ' + 'If not specified, it will be considered an internal project of the tenant.', + pattern=UUID_REGEX, example=faker.uuid4(), ), 'project_type_id': fields.String( - title='Identifier of Project type', - description='Type of the project. Used for grouping', - example=faker.uuid4(), - ) -}) - -project_response_fields = { - 'id': fields.String( - readOnly=True, - required=True, - title='Identifier', - description='The unique identifier', - example=faker.uuid4(), - ), - 'tenant_id': fields.String( + title='Identifier of the project type', required=False, - title='Identifier of Tenant', - description='Tenant this project belongs to', + description='This id allows to created a tree-like structure for projects, ' + 'grouped by project types', + pattern=UUID_REGEX, example=faker.uuid4(), ), -} +}) + +project_response_fields = {} project_response_fields.update(common_fields) project = ns.inherit( diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 09facb26..0b65c25a 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -6,7 +6,7 @@ from commons.data_access_layer.cosmos_db import current_datetime, datetime_str from commons.data_access_layer.database import COMMENTS_MAX_LENGTH -from time_tracker_api.api import common_fields +from time_tracker_api.api import common_fields, UUID_REGEX from time_tracker_api.time_entries.time_entries_model import create_dao faker = Faker() @@ -19,12 +19,21 @@ title='Project', required=True, description='The id of the selected project', + pattern=UUID_REGEX, example=faker.uuid4(), ), + 'start_date': fields.DateTime( + dt_format='iso8601', + title='Start date', + required=True, + description='When the user started doing this activity', + example=datetime_str(current_datetime() - timedelta(days=1)), + ), 'activity_id': fields.String( title='Activity', - required=True, + required=False, description='The id of the selected activity', + pattern=UUID_REGEX, example=faker.uuid4(), ), 'description': fields.String( @@ -34,13 +43,6 @@ example=faker.paragraph(nb_sentences=2), max_length=COMMENTS_MAX_LENGTH, ), - 'start_date': fields.DateTime( - dt_format='iso8601', - title='Start date', - required=True, - description='When the user started doing this activity', - example=datetime_str(current_datetime() - timedelta(days=1)), - ), 'end_date': fields.DateTime( dt_format='iso8601', title='End date', @@ -50,7 +52,9 @@ ), 'uri': fields.String( title='Uniform Resource identifier', - description='Either identifier or locator', + description='Either identifier or locator of a resource in the Internet that helps to understand' + ' what this time entry was about. For example, A Jira ticket, a Github issue, a Google document.', + required=False, example=faker.random_element([ 'https://github.com/ioet/time-tracker-backend/issues/51', '#54', @@ -75,25 +79,12 @@ }) time_entry_response_fields = { - 'id': fields.String( - readOnly=True, - title='Identifier', - description='The unique identifier', - example=faker.uuid4(), - ), 'running': fields.Boolean( readOnly=True, title='Is it running?', description='Whether this time entry is currently running or not', example=faker.boolean(), ), - 'tenant_id': fields.String( - required=True, - readOnly=True, - title='Identifier of Tenant', - description='Tenant this project belongs to', - example=faker.uuid4(), - ), 'owner_id': fields.String( required=True, readOnly=True, From 7beccfa14a90e599c0fd82d8aa19f9e4cc4a8e6d Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 20 Apr 2020 21:36:58 +0000 Subject: [PATCH 03/13] 0.3.2 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index e1424ed0..73e3bb4f 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.3.1' +__version__ = '0.3.2' From f65996304296919c5db220751e3a20bff7faf888 Mon Sep 17 00:00:00 2001 From: EliuX Date: Mon, 20 Apr 2020 18:45:47 -0500 Subject: [PATCH 04/13] feat: Close #89 Add endpoint to get running time entry --- tests/conftest.py | 21 ++++++++++++-- .../time_entries/time_entries_model_test.py | 16 ++++++++++ .../time_entries_namespace_test.py | 29 ++++++++++++++++++- time_tracker_api/api.py | 3 +- .../time_entries/time_entries_model.py | 21 ++++++++++++++ .../time_entries/time_entries_namespace.py | 13 ++++++++- 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 155c29b6..97b80b65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from flask import Flask from flask.testing import FlaskClient -from commons.data_access_layer.cosmos_db import CosmosDBRepository +from commons.data_access_layer.cosmos_db import CosmosDBRepository, datetime_str, current_datetime from time_tracker_api import create_app from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository @@ -128,6 +128,23 @@ def another_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> di return cosmos_db_repository.create(sample_item_data) -@pytest.yield_fixture(scope="module") +@pytest.fixture(scope="module") def time_entry_repository() -> TimeEntryCosmosDBRepository: return TimeEntryCosmosDBRepository() + + +@pytest.yield_fixture(scope="module") +def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, + owner_id: str, + tenant_id: str): + created_time_entry = time_entry_repository.create({ + "project_id": fake.uuid4(), + "start_date": datetime_str(current_datetime()), + "owner_id": owner_id, + "tenant_id": tenant_id + }) + + yield created_time_entry + + time_entry_repository.delete(id=created_time_entry.id, + partition_key_value=tenant_id) diff --git a/tests/time_tracker_api/time_entries/time_entries_model_test.py b/tests/time_tracker_api/time_entries/time_entries_model_test.py index 4d0f42e0..2b58c036 100644 --- a/tests/time_tracker_api/time_entries/time_entries_model_test.py +++ b/tests/time_tracker_api/time_entries/time_entries_model_test.py @@ -78,3 +78,19 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, assert not any([existing_item.id == item.id for item in non_colliding_result]) finally: time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) + + +def test_find_running_should_return_running_time_entry(running_time_entry, + time_entry_repository: TimeEntryCosmosDBRepository): + found_time_entry = time_entry_repository.find_running(partition_key_value=running_time_entry.tenant_id) + + assert found_time_entry is not None + assert found_time_entry.id == running_time_entry.id + + +def test_find_running_should_not_find_any_item(tenant_id: str, + time_entry_repository: TimeEntryCosmosDBRepository): + try: + time_entry_repository.find_running(partition_key_value=tenant_id) + except Exception as e: + assert type(e) is StopIteration diff --git a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py index 975797e5..34d34259 100644 --- a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py +++ b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py @@ -25,7 +25,8 @@ fake_time_entry = ({ "id": fake.random_int(1, 9999), "running": True, -}).update(valid_time_entry_input) +}) +fake_time_entry.update(valid_time_entry_input) def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_error(client: FlaskClient, @@ -309,3 +310,29 @@ def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, moc changes={"end_date": None}, partition_key_value=current_user_tenant_id(), peeker=ANY) + + +def test_get_running_should_call_find_running(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_update_mock = mocker.patch.object(time_entries_dao.repository, + 'find_running', + return_value=fake_time_entry) + + response = client.get("/time-entries/running", follow_redirects=True) + + assert HTTPStatus.OK == response.status_code + assert json.loads(response.data) is not None + repository_update_mock.assert_called_once_with(partition_key_value=current_user_tenant_id()) + + +def test_get_running_should_return_not_found_if_find_running_throws_StopIteration(client: FlaskClient, + mocker: MockFixture): + from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_update_mock = mocker.patch.object(time_entries_dao.repository, + 'find_running', + side_effect=StopIteration) + + response = client.get("/time-entries/running", follow_redirects=True) + + assert HTTPStatus.NOT_FOUND == response.status_code + repository_update_mock.assert_called_once_with(partition_key_value=current_user_tenant_id()) diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index ccf637ac..080dea08 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -77,7 +77,8 @@ def handle_cosmos_resource_exists_error(error): @api.errorhandler(CosmosResourceNotFoundError) -def handle_cosmos_resource_not_found_error(error): +@api.errorhandler(StopIteration) +def handle_not_found_errors(error): return {'message': 'It was not found'}, HTTPStatus.NOT_FOUND diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 9145f6db..f2646edc 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -1,3 +1,4 @@ +import abc from dataclasses import dataclass, field from typing import List, Callable @@ -15,6 +16,10 @@ class TimeEntriesDao(CRUDDao): def current_user_id(): return current_user_id() + @abc.abstractmethod + def find_running(self): + pass + container_definition = { 'id': 'time_entry', @@ -102,6 +107,19 @@ def find_interception_with_date_range(self, start_date, end_date, owner_id, part function_mapper = self.get_mapper_or_dict(mapper) return list(map(function_mapper, result)) + def find_running(self, partition_key_value: str, mapper: Callable = None): + result = self.container.query_items( + query=""" + SELECT * from c + WHERE NOT IS_DEFINED(c.end_date) OR c.end_date = null + OFFSET 0 LIMIT 1 + """, + partition_key=partition_key_value, + max_item_count=1) + + function_mapper = self.get_mapper_or_dict(mapper) + return function_mapper(next(result)) + def validate_data(self, data): if data.get('end_date') is not None: if data['end_date'] <= data.get('start_date'): @@ -156,6 +174,9 @@ def delete(self, id): self.repository.delete(id, partition_key_value=self.partition_key_value, peeker=self.check_whether_current_user_owns_item) + def find_running(self): + return self.repository.find_running(partition_key_value=self.partition_key_value) + def create_dao() -> TimeEntriesDao: repository = TimeEntryCosmosDBRepository() diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 0b65c25a..a7bc2a42 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -171,7 +171,7 @@ def post(self, id): @ns.route('//restart') @ns.response(HTTPStatus.NOT_FOUND, 'Stopped time entry not found') -@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The id has an invalid format.') +@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The id has an invalid format') @ns.param('id', 'The unique identifier of a stopped time entry') class RestartTimeEntry(Resource): @ns.doc('restart_time_entry') @@ -181,3 +181,14 @@ def post(self, id): return time_entries_dao.update(id, { 'end_date': None }) + + +@ns.route('/running') +@ns.response(HTTPStatus.OK, 'The time entry that is active: currently running') +@ns.response(HTTPStatus.NOT_FOUND, 'There is no time entry running right now') +class ActiveTimeEntry(Resource): + @ns.doc('running_time_entry') + @ns.marshal_with(time_entry) + def get(self): + """Find the time entry that is running""" + return time_entries_dao.find_running() From b8bbd8e145c4bdc6a5dfb495a381c29130101e8f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 21 Apr 2020 18:13:32 +0000 Subject: [PATCH 05/13] 0.4.0 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index 73e3bb4f..abeeedbf 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.3.2' +__version__ = '0.4.0' From d315d5bded6b39b72468bcaa8500323f89e8ebbe Mon Sep 17 00:00:00 2001 From: roberto Date: Tue, 21 Apr 2020 13:50:30 -0500 Subject: [PATCH 06/13] fix: Add ProxyFix to serve swagger.json over HTTPs --- time_tracker_api/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/time_tracker_api/__init__.py b/time_tracker_api/__init__.py index 6e633c7d..7a33a4db 100644 --- a/time_tracker_api/__init__.py +++ b/time_tracker_api/__init__.py @@ -46,6 +46,8 @@ def init_app(app: Flask): app.logger.setLevel(logging.INFO) add_debug_toolbar(app) + add_werkzeug_proxy_fix(app) + cors_origins = app.config.get('CORS_ORIGINS') if cors_origins: enable_cors(app, cors_origins) @@ -74,3 +76,9 @@ def enable_cors(app: Flask, cors_origins: str): cors_origins_list = cors_origins.split(",") CORS(app, resources={r"/*": {"origins": cors_origins_list}}) app.logger.info("Set CORS access to [%s]" % cors_origins) + + +def add_werkzeug_proxy_fix(app: Flask): + from werkzeug.middleware.proxy_fix import ProxyFix + app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) + app.logger.info("Add ProxyFix to serve swagger.json over https.") From 41a73da7d251d6047d60eca0b580c96a7c75ddbc Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 21 Apr 2020 19:06:07 +0000 Subject: [PATCH 07/13] 0.4.1 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index abeeedbf..f0ede3d3 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.4.0' +__version__ = '0.4.1' From 1a41ed7077c121c595ab29f2b6ff2fcdf8a3618f Mon Sep 17 00:00:00 2001 From: EliuX Date: Tue, 21 Apr 2020 20:00:09 -0500 Subject: [PATCH 08/13] feat: Ack JWT claims for authentication #94 --- requirements/time_tracker_api/prod.txt | 7 +- tests/conftest.py | 52 +++++++++- .../activities/activities_namespace_test.py | 25 +++-- tests/time_tracker_api/security_test.py | 34 +++++++ time_tracker_api/api.py | 5 +- time_tracker_api/config.py | 4 +- time_tracker_api/security.py | 97 +++++++++++++++---- 7 files changed, 194 insertions(+), 30 deletions(-) create mode 100644 tests/time_tracker_api/security_test.py diff --git a/requirements/time_tracker_api/prod.txt b/requirements/time_tracker_api/prod.txt index d72284f5..344bc4bb 100644 --- a/requirements/time_tracker_api/prod.txt +++ b/requirements/time_tracker_api/prod.txt @@ -26,8 +26,11 @@ Flask-Script==2.0.6 #Semantic versioning python-semantic-release==5.2.0 -# The Debug Toolbar +#The Debug Toolbar Flask-DebugToolbar==0.11.0 #CORS -flask-cors==3.0.8 \ No newline at end of file +flask-cors==3.0.8 + +#JWT +PyJWT==1.7.1 diff --git a/tests/conftest.py b/tests/conftest.py index 97b80b65..21de5386 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,50 @@ +from datetime import datetime, timedelta + +import jwt import pytest from faker import Faker -from flask import Flask +from flask import Flask, url_for from flask.testing import FlaskClient from commons.data_access_layer.cosmos_db import CosmosDBRepository, datetime_str, current_datetime from time_tracker_api import create_app +from time_tracker_api.security import get_or_generate_dev_secret_key from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository fake = Faker() Faker.seed() +TEST_USER = { + "name": "testuser@ioet.com", + "password": "secret" +} + + +class User: + def __init__(self, username, password): + self.username = username + self.password = password + + +class AuthActions: + """Auth actions container in tests""" + + def __init__(self, app, client): + self._app = app + self._client = client + + # def login(self, username=TEST_USER["name"], + # password=TEST_USER["password"]): + # login_url = url_for("security.login", self._app) + # return open_with_basic_auth(self._client, + # login_url, + # username, + # password) + # + # def logout(self): + # return self._client.get(url_for("security.logout", self._app), + # follow_redirects=True) + @pytest.fixture(scope='session') def app() -> Flask: @@ -148,3 +183,18 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, time_entry_repository.delete(id=created_time_entry.id, partition_key_value=tenant_id) + + +@pytest.fixture(scope="session") +def valid_jwt(app: Flask) -> str: + expiration_time = datetime.utcnow() + timedelta(seconds=3600) + return jwt.encode({ + "iss": "https://securityioet.b2clogin.com/%s/v2.0/" % fake.uuid4(), + "oid": fake.uuid4(), + 'exp': expiration_time + }, key=get_or_generate_dev_secret_key()).decode("UTF-8") + + +@pytest.fixture(scope="session") +def valid_header(valid_jwt: str) -> dict: + return {'Authorization': "Bearer %s" % valid_jwt} diff --git a/tests/time_tracker_api/activities/activities_namespace_test.py b/tests/time_tracker_api/activities/activities_namespace_test.py index 1b69fd74..90f3f1d6 100644 --- a/tests/time_tracker_api/activities/activities_namespace_test.py +++ b/tests/time_tracker_api/activities/activities_namespace_test.py @@ -19,13 +19,18 @@ }).update(valid_activity_data) -def test_create_activity_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_activity_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_create_mock = mocker.patch.object(activity_dao.repository, 'create', return_value=fake_activity) - response = client.post("/activities", json=valid_activity_data, follow_redirects=True) + response = client.post("/activities", + headers=valid_header, + json=valid_activity_data, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() @@ -57,7 +62,9 @@ def test_list_all_activities(client: FlaskClient, mocker: MockFixture): repository_find_all_mock.assert_called_once() -def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao valid_id = fake.random_int(1, 9999) @@ -66,7 +73,9 @@ def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: 'find', return_value=fake_activity) - response = client.get("/activities/%s" % valid_id, follow_redirects=True) + response = client.get("/activities/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_activity == json.loads(response.data) @@ -74,7 +83,9 @@ def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: partition_key_value=current_user_tenant_id()) -def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -84,7 +95,9 @@ def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClien 'find', side_effect=NotFound) - response = client.get("/activities/%s" % invalid_id, follow_redirects=True) + response = client.get("/activities/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), diff --git a/tests/time_tracker_api/security_test.py b/tests/time_tracker_api/security_test.py new file mode 100644 index 00000000..54ffe5e8 --- /dev/null +++ b/tests/time_tracker_api/security_test.py @@ -0,0 +1,34 @@ +from time_tracker_api.security import parse_jwt, parse_tenant_id_from_iss_claim + + +def test_parse_jwt_with_valid_input(valid_jwt: str): + result = parse_jwt("Bearer %s" % valid_jwt) + + assert result is not None + assert type(result) is dict + + +def test_parse_jwt_with_invalid_input(): + result = parse_jwt("whetever") + + assert result is None + + +def test_parse_tenant_id_from_iss_claim_with_valid_input(): + valid_iss_claim = "https://securityioet.b2clogin.com/b21c4e98-c4bf-420f-9d76-e51c2515c7a4/v2.0/" + + result = parse_tenant_id_from_iss_claim(valid_iss_claim) + + assert result is not None + assert type(result) is str + assert result == "b21c4e98-c4bf-420f-9d76-e51c2515c7a4" + + +def test_parse_tenant_id_from_iss_claim_with_invalid_input(): + invalid_iss_claim1 = "https://securityioet.b2clogin.com/whatever/v2.0/" + invalid_iss_claim2 = "" + + result1 = parse_tenant_id_from_iss_claim(invalid_iss_claim1) + result2 = parse_tenant_id_from_iss_claim(invalid_iss_claim2) + + assert result1 == result2 == None diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index 080dea08..b269136b 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -5,6 +5,7 @@ from flask_restplus._http import HTTPStatus from commons.data_access_layer.cosmos_db import CustomError +from time_tracker_api import security from time_tracker_api.version import __version__ faker = Faker() @@ -12,7 +13,9 @@ api = Api( version=__version__, title="TimeTracker API", - description="API for the TimeTracker project" + description="API for the TimeTracker project", + authorizations=security.authorizations, + security="TimeTracker JWT", ) # For matching UUIDs diff --git a/time_tracker_api/config.py b/time_tracker_api/config.py index c91f50b0..e31d9ab2 100644 --- a/time_tracker_api/config.py +++ b/time_tracker_api/config.py @@ -1,12 +1,12 @@ import os -from time_tracker_api.security import generate_dev_secret_key +from time_tracker_api.security import get_or_generate_dev_secret_key DISABLE_STR_VALUES = ("false", "0", "disabled") class Config: - SECRET_KEY = generate_dev_secret_key() + SECRET_KEY = get_or_generate_dev_secret_key() SQL_DATABASE_URI = os.environ.get('SQL_DATABASE_URI') PROPAGATE_EXCEPTIONS = True RESTPLUS_VALIDATE = True diff --git a/time_tracker_api/security.py b/time_tracker_api/security.py index 684f1dfc..d0c382ec 100644 --- a/time_tracker_api/security.py +++ b/time_tracker_api/security.py @@ -2,36 +2,97 @@ This is where we handle everything regarding to authorization and authentication. Also stores helper functions related to it. """ +import re + +import jwt from faker import Faker +from flask import request +from flask_restplus import abort +from flask_restplus._http import HTTPStatus +from jwt import DecodeError, ExpiredSignatureError fake = Faker() dev_secret_key: str = None +authorizations = { + "TimeTracker JWT": { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization', + 'description': "Specify in the value **'Bearer <JWT>'**, where JWT is the token", + } +} + +iss_claim_pattern = re.compile( + r"securityioet.b2clogin.com/(?P[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12})") + def current_user_id() -> str: - """ - Returns the id of the authenticated user in - Azure Active Directory - """ - return 'anonymous' + oid_claim = get_token_json().get("oid") + if oid_claim is None: + abort(message='The claim "oid" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED) + + return oid_claim def current_user_tenant_id() -> str: - # TODO Get this from the JWT - return "ioet" + iss_claim = get_token_json().get("iss") + if iss_claim is None: + abort(message='The claim "iss" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED) + + tenant_id = parse_tenant_id_from_iss_claim(iss_claim) + if tenant_id is None: + abort(message='The format of the claim "iss" cannot be understood. ' + 'Please contact the development team.', + code=HTTPStatus.UNAUTHORIZED) + return tenant_id -def generate_dev_secret_key(): - from time_tracker_api import flask_app as app - """ - Generates a security key for development purposes - :return: str - """ + +def get_or_generate_dev_secret_key(): global dev_secret_key - dev_secret_key = fake.password(length=16, special_chars=True, digits=True, upper_case=True, lower_case=True) - if app.config.get("FLASK_DEBUG", False): # pragma: no cover - print('*********************************************************') - print("The generated secret is \"%s\"" % dev_secret_key) - print('*********************************************************') + if dev_secret_key is None: + from time_tracker_api import flask_app as app + """ + Generates a security key for development purposes + :return: str + """ + dev_secret_key = fake.password(length=16, special_chars=True, digits=True, upper_case=True, lower_case=True) + if app.config.get("FLASK_DEBUG", False): # pragma: no cover + print('*********************************************************') + print("The generated secret is \"%s\"" % dev_secret_key) + print('*********************************************************') return dev_secret_key + + +def parse_jwt(authentication_header_content): + if authentication_header_content is not None: + parsed_content = authentication_header_content.split("Bearer ") + + if len(parsed_content) > 1: + return jwt.decode(parsed_content[1], verify=False) + + return None + + +def get_authorization_jwt(): + auth_header = request.headers.get('Authorization') + return parse_jwt(auth_header) + + +def get_token_json(): + try: + return get_authorization_jwt() + except DecodeError: + abort(message='Malformed token', code=HTTPStatus.UNAUTHORIZED) + except ExpiredSignatureError: + abort(message='Expired token', code=HTTPStatus.UNAUTHORIZED) + + +def parse_tenant_id_from_iss_claim(iss_claim: str) -> str: + m = iss_claim_pattern.search(iss_claim) + if m is not None: + return m.group('tenant_id') + + return None From 7efc921ce662c4d7b2d264e2837c5acb5fe5690c Mon Sep 17 00:00:00 2001 From: EliuX Date: Wed, 22 Apr 2020 12:01:37 -0500 Subject: [PATCH 09/13] fix: Close #94 Fix tests to support authentication --- commons/data_access_layer/database.py | 1 - commons/data_access_layer/sql.py | 12 -- tests/commons/data_access_layer/sql_test.py | 7 - tests/conftest.py | 47 +---- .../activities/activities_namespace_test.py | 99 +++++++--- .../customers/customers_namespace_test.py | 125 ++++++++---- .../project_types_namespace_test.py | 124 ++++++++---- .../projects/projects_namespace_test.py | 121 ++++++++---- .../time_entries_namespace_test.py | 182 +++++++++++++----- 9 files changed, 480 insertions(+), 238 deletions(-) diff --git a/commons/data_access_layer/database.py b/commons/data_access_layer/database.py index 077780cf..27bee0ca 100644 --- a/commons/data_access_layer/database.py +++ b/commons/data_access_layer/database.py @@ -36,7 +36,6 @@ def delete(self, id): def init_app(app: Flask) -> None: - init_sql(app) # TODO Delete after the migration to Cosmos DB has finished. init_cosmos_db(app) diff --git a/commons/data_access_layer/sql.py b/commons/data_access_layer/sql.py index c1afb0e2..eb356620 100644 --- a/commons/data_access_layer/sql.py +++ b/commons/data_access_layer/sql.py @@ -4,10 +4,8 @@ from flask_sqlalchemy import SQLAlchemy from commons.data_access_layer.database import CRUDDao, ID_MAX_LENGTH -from time_tracker_api.security import current_user_id db: SQLAlchemy = None -AuditedSQLModel = None def handle_commit_issues(f): @@ -25,16 +23,6 @@ def init_app(app: Flask) -> None: global db db = SQLAlchemy(app) - global AuditedSQLModel - - class AuditedSQLModelClass(): - created_at = db.Column(db.DateTime, server_default=db.func.now()) - updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow) - created_by = db.Column(db.String(ID_MAX_LENGTH), default=current_user_id) - updated_by = db.Column(db.String(ID_MAX_LENGTH), onupdate=current_user_id) - - AuditedSQLModel = AuditedSQLModelClass - class SQLRepository(): def __init__(self, model_type: type): diff --git a/tests/commons/data_access_layer/sql_test.py b/tests/commons/data_access_layer/sql_test.py index 48f220ff..26f992b8 100644 --- a/tests/commons/data_access_layer/sql_test.py +++ b/tests/commons/data_access_layer/sql_test.py @@ -16,10 +16,6 @@ def test_create(sql_repository): assert result is not None assert result.id is not None - assert result.created_at is not None - assert result.created_by is not None - assert result.updated_at is None - assert result.updated_by is None existing_elements_registry.append(result) @@ -43,9 +39,6 @@ def test_update(sql_repository): assert updated_element.id == existing_element.id assert updated_element.name == "Jon Snow" assert updated_element.age == 34 - assert updated_element.updated_at is not None - assert updated_element.updated_at > updated_element.created_at - assert updated_element.updated_by is not None def test_find_all(sql_repository): diff --git a/tests/conftest.py b/tests/conftest.py index 21de5386..5fad738c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from flask.testing import FlaskClient from commons.data_access_layer.cosmos_db import CosmosDBRepository, datetime_str, current_datetime +from commons.data_access_layer.database import init_sql from time_tracker_api import create_app from time_tracker_api.security import get_or_generate_dev_secret_key from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository @@ -14,37 +15,6 @@ fake = Faker() Faker.seed() -TEST_USER = { - "name": "testuser@ioet.com", - "password": "secret" -} - - -class User: - def __init__(self, username, password): - self.username = username - self.password = password - - -class AuthActions: - """Auth actions container in tests""" - - def __init__(self, app, client): - self._app = app - self._client = client - - # def login(self, username=TEST_USER["name"], - # password=TEST_USER["password"]): - # login_url = url_for("security.login", self._app) - # return open_with_basic_auth(self._client, - # login_url, - # username, - # password) - # - # def logout(self): - # return self._client.get(url_for("security.logout", self._app), - # follow_redirects=True) - @pytest.fixture(scope='session') def app() -> Flask: @@ -58,9 +28,12 @@ def client(app: Flask) -> FlaskClient: @pytest.fixture(scope="module") -def sql_model_class(): - from commons.data_access_layer.sql import db, AuditedSQLModel - class PersonSQLModel(db.Model, AuditedSQLModel): +def sql_model_class(app: Flask): + with app.app_context(): + init_sql(app) + + from commons.data_access_layer.sql import db + class PersonSQLModel(db.Model): __tablename__ = 'test' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) @@ -186,11 +159,11 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, @pytest.fixture(scope="session") -def valid_jwt(app: Flask) -> str: +def valid_jwt(app: Flask, tenant_id: str, owner_id: str) -> str: expiration_time = datetime.utcnow() + timedelta(seconds=3600) return jwt.encode({ - "iss": "https://securityioet.b2clogin.com/%s/v2.0/" % fake.uuid4(), - "oid": fake.uuid4(), + "iss": "https://securityioet.b2clogin.com/%s/v2.0/" % tenant_id, + "oid": owner_id, 'exp': expiration_time }, key=get_or_generate_dev_secret_key()).decode("UTF-8") diff --git a/tests/time_tracker_api/activities/activities_namespace_test.py b/tests/time_tracker_api/activities/activities_namespace_test.py index 90f3f1d6..508e7f88 100644 --- a/tests/time_tracker_api/activities/activities_namespace_test.py +++ b/tests/time_tracker_api/activities/activities_namespace_test.py @@ -4,8 +4,6 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from time_tracker_api.security import current_user_tenant_id - fake = Faker() valid_activity_data = { @@ -36,34 +34,45 @@ def test_create_activity_should_succeed_with_valid_request(client: FlaskClient, repository_create_mock.assert_called_once() -def test_create_activity_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_activity_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_create_mock = mocker.patch.object(activity_dao.repository, 'create', return_value=fake_activity) - response = client.post("/activities", json=None, follow_redirects=True) + response = client.post("/activities", + headers=valid_header, + json=None, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_activities(client: FlaskClient, mocker: MockFixture): +def test_list_all_activities(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_find_all_mock = mocker.patch.object(activity_dao.repository, 'find_all', return_value=[]) - response = client.get("/activities", follow_redirects=True) + response = client.get("/activities", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code json_data = json.loads(response.data) assert [] == json_data - repository_find_all_mock.assert_called_once() + repository_find_all_mock.assert_called_once_with(partition_key_value=tenant_id) def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, + tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao @@ -79,12 +88,12 @@ def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_activity == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + repository_find_mock.assert_called_once_with(str(valid_id), partition_key_value=tenant_id) def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, + tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -101,10 +110,11 @@ def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClien assert HTTPStatus.NOT_FOUND == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_activity_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): +def test_get_activity_should_return_422_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import UnprocessableEntity @@ -117,41 +127,54 @@ def test_get_activity_should_return_422_for_invalid_id_format(client: FlaskClien response = client.get("/activities/%s" % invalid_id, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + repository_find_mock.assert_not_called() -def test_update_activity_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_activity_should_succeed_with_valid_data(client: FlaskClient, + tenant_id: str, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_update_mock = mocker.patch.object(activity_dao.repository, 'partial_update', return_value=fake_activity) - valid_id = fake.random_int(1, 9999) - response = client.put("/activities/%s" % valid_id, json=valid_activity_data, follow_redirects=True) + valid_id = fake.uuid4() + response = client.put("/activities/%s" % valid_id, + headers=valid_header, + json=valid_activity_data, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_activity == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_activity_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_activity_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_activity_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_update_mock = mocker.patch.object(activity_dao.repository, 'partial_update', return_value=fake_activity) valid_id = fake.random_int(1, 9999) - response = client.put("/activities/%s" % valid_id, json=None, follow_redirects=True) + response = client.put("/activities/%s" % valid_id, + headers=valid_header, + json=None, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() -def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskClient, + tenant_id: str, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -162,16 +185,20 @@ def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskCl side_effect=NotFound) response = client.put("/activities/%s" % invalid_id, + headers=valid_header, json=valid_activity_data, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_activity_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao valid_id = fake.random_int(1, 9999) @@ -180,15 +207,20 @@ def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, mocke 'delete', return_value=None) - response = client.delete("/activities/%s" % valid_id, follow_redirects=True) + response = client.delete("/activities/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_activity_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -198,14 +230,19 @@ def test_delete_activity_should_return_not_found_with_invalid_id(client: FlaskCl 'delete', side_effect=NotFound) - response = client.delete("/activities/%s" % invalid_id, follow_redirects=True) + response = client.delete("/activities/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): +def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import UnprocessableEntity @@ -215,8 +252,10 @@ def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskCl 'delete', side_effect=UnprocessableEntity) - response = client.delete("/activities/%s" % invalid_id, follow_redirects=True) + response = client.delete("/activities/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) diff --git a/tests/time_tracker_api/customers/customers_namespace_test.py b/tests/time_tracker_api/customers/customers_namespace_test.py index 707df28a..1777cbe8 100644 --- a/tests/time_tracker_api/customers/customers_namespace_test.py +++ b/tests/time_tracker_api/customers/customers_namespace_test.py @@ -4,8 +4,6 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from time_tracker_api.security import current_user_tenant_id - fake = Faker() valid_customer_data = { @@ -15,41 +13,55 @@ } fake_customer = ({ - "id": fake.random_int(1, 9999) + "id": fake.random_int(1, 9999), }).update(valid_customer_data) -def test_create_customer_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_customer_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_create_mock = mocker.patch.object(customer_dao.repository, 'create', return_value=fake_customer) - response = client.post("/customers", json=valid_customer_data, follow_redirects=True) + response = client.post("/customers", + headers=valid_header, + json=valid_customer_data, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_customer_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_customer_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_create_mock = mocker.patch.object(customer_dao.repository, 'create', return_value=fake_customer) - response = client.post("/customers", json=None, follow_redirects=True) + response = client.post("/customers", + headers=valid_header, + json=None, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_customers(client: FlaskClient, mocker: MockFixture): +def test_list_all_customers(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_find_all_mock = mocker.patch.object(customer_dao.repository, 'find_all', return_value=[]) - response = client.get("/customers", follow_redirects=True) + response = client.get("/customers", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code json_data = json.loads(response.data) @@ -57,7 +69,10 @@ def test_list_all_customers(client: FlaskClient, mocker: MockFixture): repository_find_all_mock.assert_called_once() -def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao valid_id = fake.random_int(1, 9999) @@ -66,15 +81,20 @@ def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: 'find', return_value=fake_customer) - response = client.get("/customers/%s" % valid_id, follow_redirects=True) + response = client.get("/customers/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_customer == json.loads(response.data) repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_get_customer_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import NotFound @@ -84,14 +104,19 @@ def test_get_customer_should_return_not_found_with_invalid_id(client: FlaskClien 'find', side_effect=NotFound) - response = client.get("/customers/%s" % invalid_id, follow_redirects=True) + response = client.get("/customers/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_customer_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): +def test_get_customer_should_return_422_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import UnprocessableEntity @@ -101,14 +126,19 @@ def test_get_customer_should_return_422_for_invalid_id_format(client: FlaskClien 'find', side_effect=UnprocessableEntity) - response = client.get("/customers/%s" % invalid_id, follow_redirects=True) + response = client.get("/customers/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_customer_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_customer_should_succeed_with_valid_data(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_update_mock = mocker.patch.object(customer_dao.repository, @@ -116,29 +146,40 @@ def test_update_customer_should_succeed_with_valid_data(client: FlaskClient, moc return_value=fake_customer) valid_id = fake.random_int(1, 9999) - response = client.put("/customers/%s" % valid_id, json=valid_customer_data, follow_redirects=True) + response = client.put("/customers/%s" % valid_id, + headers=valid_header, + json=valid_customer_data, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_customer == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_customer_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_customer_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_customer_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_update_mock = mocker.patch.object(customer_dao.repository, 'partial_update', return_value=fake_customer) valid_id = fake.random_int(1, 9999) - response = client.put("/customers/%s" % valid_id, json=None, follow_redirects=True) + response = client.put("/customers/%s" % valid_id, + headers=valid_header, + json=None, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() -def test_update_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_customer_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import NotFound @@ -149,16 +190,20 @@ def test_update_customer_should_return_not_found_with_invalid_id(client: FlaskCl side_effect=NotFound) response = client.put("/customers/%s" % invalid_id, + headers=valid_header, json=valid_customer_data, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_customer_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_customer_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao valid_id = fake.random_int(1, 9999) @@ -167,15 +212,20 @@ def test_delete_customer_should_succeed_with_valid_id(client: FlaskClient, mocke 'delete', return_value=None) - response = client.delete("/customers/%s" % valid_id, follow_redirects=True) + response = client.delete("/customers/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_customer_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import NotFound @@ -185,14 +235,19 @@ def test_delete_customer_should_return_not_found_with_invalid_id(client: FlaskCl 'delete', side_effect=NotFound) - response = client.delete("/customers/%s" % invalid_id, follow_redirects=True) + response = client.delete("/customers/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_customer_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): +def test_delete_customer_should_return_422_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import UnprocessableEntity @@ -202,8 +257,10 @@ def test_delete_customer_should_return_422_for_invalid_id_format(client: FlaskCl 'delete', side_effect=UnprocessableEntity) - response = client.delete("/customers/%s" % invalid_id, follow_redirects=True) + response = client.delete("/customers/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) diff --git a/tests/time_tracker_api/project_types/project_types_namespace_test.py b/tests/time_tracker_api/project_types/project_types_namespace_test.py index 613b0cdc..3d0b3135 100644 --- a/tests/time_tracker_api/project_types/project_types_namespace_test.py +++ b/tests/time_tracker_api/project_types/project_types_namespace_test.py @@ -4,8 +4,6 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from time_tracker_api.security import current_user_tenant_id - fake = Faker() valid_project_type_data = { @@ -21,19 +19,26 @@ }).update(valid_project_type_data) -def test_create_project_type_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_project_type_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao repository_create_mock = mocker.patch.object(project_type_dao.repository, 'create', return_value=fake_project_type) - response = client.post("/project-types", json=valid_project_type_data, follow_redirects=True) + response = client.post("/project-types", + headers=valid_header, + json=valid_project_type_data, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_project_type_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_project_type_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao invalid_project_type_data = valid_project_type_data.copy() invalid_project_type_data.update({ @@ -43,59 +48,77 @@ def test_create_project_type_should_reject_bad_request(client: FlaskClient, mock 'create', return_value=fake_project_type) - response = client.post("/project-types", json=invalid_project_type_data, follow_redirects=True) + response = client.post("/project-types", + headers=valid_header, + json=invalid_project_type_data, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_project_types(client: FlaskClient, mocker: MockFixture): +def test_list_all_project_types(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao repository_find_all_mock = mocker.patch.object(project_type_dao.repository, 'find_all', return_value=[]) - response = client.get("/project-types", follow_redirects=True) + response = client.get("/project-types", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code assert [] == json.loads(response.data) repository_find_all_mock.assert_called_once() -def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_project_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.project_types.project_types_namespace import project_type_dao valid_id = fake.random_int(1, 9999) repository_find_mock = mocker.patch.object(project_type_dao.repository, 'find', return_value=fake_project_type) - response = client.get("/project-types/%s" % valid_id, follow_redirects=True) + response = client.get("/project-types/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_project_type == json.loads(response.data) repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import NotFound - invalid_id = fake.random_int(1, 9999) + invalid_id = str(fake.random_int(1, 9999)) repository_find_mock = mocker.patch.object(project_type_dao.repository, 'find', side_effect=NotFound) - response = client.get("/project-types/%s" % invalid_id, follow_redirects=True) + response = client.get("/project-types/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + repository_find_mock.assert_called_once_with(invalid_id, partition_key_value=tenant_id) def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import UnprocessableEntity @@ -105,14 +128,19 @@ def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_fo 'find', side_effect=UnprocessableEntity) - response = client.get("/project-types/%s" % invalid_id, follow_redirects=True) + response = client.get("/project-types/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_succeed_with_valid_data(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.project_types.project_types_namespace import project_type_dao repository_update_mock = mocker.patch.object(project_type_dao.repository, @@ -120,16 +148,21 @@ def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mock return_value=fake_project_type) valid_id = fake.random_int(1, 9999) - response = client.put("/project-types/%s" % valid_id, json=valid_project_type_data, follow_redirects=True) + response = client.put("/project-types/%s" % valid_id, + headers=valid_header, + json=valid_project_type_data, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_project_type == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_project_type_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao invalid_project_type_data = valid_project_type_data.copy() invalid_project_type_data.update({ @@ -140,13 +173,19 @@ def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: M return_value=fake_project_type) valid_id = fake.random_int(1, 9999) - response = client.put("/project-types/%s" % valid_id, json=invalid_project_type_data, follow_redirects=True) + response = client.put("/project-types/%s" % valid_id, + headers=valid_header, + json=invalid_project_type_data, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() -def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import NotFound @@ -157,16 +196,20 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli side_effect=NotFound) response = client.put("/project-types/%s" % invalid_id, + headers=valid_header, json=valid_project_type_data, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_project_type_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao valid_id = fake.random_int(1, 9999) @@ -175,15 +218,20 @@ def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker 'delete', return_value=None) - response = client.delete("/project-types/%s" % valid_id, follow_redirects=True) + response = client.delete("/project-types/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import NotFound @@ -193,15 +241,19 @@ def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskCli 'delete', side_effect=NotFound) - response = client.delete("/project-types/%s" % invalid_id, follow_redirects=True) + response = client.delete("/project-types/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import UnprocessableEntity @@ -211,8 +263,10 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format 'delete', side_effect=UnprocessableEntity) - response = client.delete("/project-types/%s" % invalid_id, follow_redirects=True) + response = client.delete("/project-types/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) diff --git a/tests/time_tracker_api/projects/projects_namespace_test.py b/tests/time_tracker_api/projects/projects_namespace_test.py index e8707dc3..6c7ff5eb 100644 --- a/tests/time_tracker_api/projects/projects_namespace_test.py +++ b/tests/time_tracker_api/projects/projects_namespace_test.py @@ -4,8 +4,6 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from time_tracker_api.security import current_user_tenant_id - fake = Faker() valid_project_data = { @@ -20,19 +18,26 @@ }).update(valid_project_data) -def test_create_project_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_project_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao repository_create_mock = mocker.patch.object(project_dao.repository, 'create', return_value=fake_project) - response = client.post("/projects", json=valid_project_data, follow_redirects=True) + response = client.post("/projects", + headers=valid_header, + json=valid_project_data, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_project_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao invalid_project_data = valid_project_data.copy() invalid_project_data.update({ @@ -42,41 +47,56 @@ def test_create_project_should_reject_bad_request(client: FlaskClient, mocker: M 'create', return_value=fake_project) - response = client.post("/projects", json=invalid_project_data, follow_redirects=True) + response = client.post("/projects", + headers=valid_header, + json=invalid_project_data, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_projects(client: FlaskClient, mocker: MockFixture): +def test_list_all_projects(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao repository_find_all_mock = mocker.patch.object(project_dao.repository, 'find_all', return_value=[]) - response = client.get("/projects", follow_redirects=True) + response = client.get("/projects", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code assert [] == json.loads(response.data) repository_find_all_mock.assert_called_once() -def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_project_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao valid_id = fake.random_int(1, 9999) repository_find_mock = mocker.patch.object(project_dao.repository, 'find', return_value=fake_project) - response = client.get("/projects/%s" % valid_id, follow_redirects=True) + response = client.get("/projects/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_project == json.loads(response.data) repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound @@ -86,15 +106,19 @@ def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient 'find', side_effect=NotFound) - response = client.get("/projects/%s" % invalid_id, follow_redirects=True) + response = client.get("/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import UnprocessableEntity @@ -104,14 +128,19 @@ def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_fo 'find', side_effect=UnprocessableEntity) - response = client.get("/projects/%s" % invalid_id, follow_redirects=True) + response = client.get("/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_succeed_with_valid_data(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao repository_update_mock = mocker.patch.object(project_dao.repository, @@ -119,16 +148,21 @@ def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mock return_value=fake_project) valid_id = fake.random_int(1, 9999) - response = client.put("/projects/%s" % valid_id, json=valid_project_data, follow_redirects=True) + response = client.put("/projects/%s" % valid_id, + headers=valid_header, + json=valid_project_data, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_project == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_project_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao invalid_project_data = valid_project_data.copy() invalid_project_data.update({ @@ -139,13 +173,19 @@ def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: M return_value=fake_project) valid_id = fake.random_int(1, 9999) - response = client.put("/projects/%s" % valid_id, json=invalid_project_data, follow_redirects=True) + response = client.put("/projects/%s" % valid_id, + headers=valid_header, + json=invalid_project_data, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() -def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound @@ -156,16 +196,20 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli side_effect=NotFound) response = client.put("/projects/%s" % invalid_id, + headers=valid_header, json=valid_project_data, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_project_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao valid_id = fake.random_int(1, 9999) @@ -174,15 +218,20 @@ def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker 'delete', return_value=None) - response = client.delete("/projects/%s" % valid_id, follow_redirects=True) + response = client.delete("/projects/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound @@ -192,15 +241,19 @@ def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskCli 'delete', side_effect=NotFound) - response = client.delete("/projects/%s" % invalid_id, follow_redirects=True) + response = client.delete("/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import UnprocessableEntity @@ -210,8 +263,10 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format 'delete', side_effect=UnprocessableEntity) - response = client.delete("/projects/%s" % invalid_id, follow_redirects=True) + response = client.delete("/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) diff --git a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py index 34d34259..f3fe83bb 100644 --- a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py +++ b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py @@ -8,7 +8,6 @@ from pytest_mock import MockFixture from commons.data_access_layer.cosmos_db import current_datetime -from time_tracker_api.security import current_user_tenant_id fake = Faker() @@ -30,7 +29,8 @@ def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_error(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, 'create_item', @@ -40,14 +40,18 @@ def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_erro invalid_time_entry_input.update({ "end_date": str(yesterday.isoformat()) }) - response = client.post("/time-entries", json=invalid_time_entry_input, follow_redirects=True) + response = client.post("/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_container_create_item_mock.assert_not_called() def test_create_time_entry_with_end_date_in_future_should_raise_bad_request_error(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, 'create_item', @@ -56,25 +60,35 @@ def test_create_time_entry_with_end_date_in_future_should_raise_bad_request_erro invalid_time_entry_input.update({ "end_date": str(fake.future_datetime().isoformat()) }) - response = client.post("/time-entries", json=invalid_time_entry_input, follow_redirects=True) + response = client.post("/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_container_create_item_mock.assert_not_called() -def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_create_mock = mocker.patch.object(time_entries_dao.repository, 'create', return_value=fake_time_entry) - response = client.post("/time-entries", json=valid_time_entry_input, follow_redirects=True) + response = client.post("/time-entries", + headers=valid_header, + json=valid_time_entry_input, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_time_entry_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao invalid_time_entry_input = valid_time_entry_input.copy() invalid_time_entry_input.update({ @@ -84,43 +98,57 @@ def test_create_time_entry_should_reject_bad_request(client: FlaskClient, mocker 'create', return_value=fake_time_entry) - response = client.post("/time-entries", json=invalid_time_entry_input, follow_redirects=True) + response = client.post("/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_time_entries(client: FlaskClient, mocker: MockFixture): +def test_list_all_time_entries(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_find_all_mock = mocker.patch.object(time_entries_dao.repository, 'find_all', return_value=[]) - response = client.get("/time-entries", follow_redirects=True) + response = client.get("/time-entries", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code assert [] == json.loads(response.data) repository_find_all_mock.assert_called_once() -def test_get_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_time_entry_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_find_mock = mocker.patch.object(time_entries_dao.repository, 'find', return_value=fake_time_entry) valid_id = fake.random_int(1, 9999) - response = client.get("/time-entries/%s" % valid_id, follow_redirects=True) + response = client.get("/time-entries/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_time_entry == json.loads(response.data) repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity @@ -130,15 +158,20 @@ def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id 'find', side_effect=UnprocessableEntity) - response = client.get("/time-entries/%s" % invalid_id, follow_redirects=True) + response = client.get("/time-entries/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', @@ -146,6 +179,7 @@ def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, m valid_id = fake.random_int(1, 9999) response = client.put("/time-entries/%s" % valid_id, + headers=valid_header, json=valid_time_entry_input, follow_redirects=True) @@ -153,11 +187,14 @@ def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, m fake_time_entry == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_time_entry_input, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_time_entry_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao invalid_time_entry_data = valid_time_entry_input.copy() invalid_time_entry_data.update({ @@ -169,6 +206,7 @@ def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker valid_id = fake.random_int(1, 9999) response = client.put("/time-entries/%s" % valid_id, + headers=valid_header, json=invalid_time_entry_data, follow_redirects=True) @@ -176,7 +214,10 @@ def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker repository_update_mock.assert_not_called() -def test_update_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import NotFound repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -185,34 +226,42 @@ def test_update_time_entry_should_return_not_found_with_invalid_id(client: Flask invalid_id = fake.random_int(1, 9999) response = client.put("/time-entries/%s" % invalid_id, + headers=valid_header, json=valid_time_entry_input, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_time_entry_input, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_remove_mock = mocker.patch.object(time_entries_dao.repository, 'delete', return_value=None) valid_id = fake.random_int(1, 9999) - response = client.delete("/time-entries/%s" % valid_id, follow_redirects=True) + response = client.delete("/time-entries/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) def test_delete_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import NotFound repository_remove_mock = mocker.patch.object(time_entries_dao.repository, @@ -220,16 +269,20 @@ def test_delete_time_entry_should_return_not_found_with_invalid_id(client: Flask side_effect=NotFound) invalid_id = fake.random_int(1, 9999) - response = client.delete("/time-entries/%s" % invalid_id, follow_redirects=True) + response = client.delete("/time-entries/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_remove_mock = mocker.patch.object(time_entries_dao.repository, @@ -237,31 +290,41 @@ def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_for side_effect=UnprocessableEntity) invalid_id = fake.word() - response = client.delete("/time-entries/%s" % invalid_id, follow_redirects=True) + response = client.delete("/time-entries/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_stop_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_stop_time_entry_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', return_value=fake_time_entry) valid_id = fake.random_int(1, 9999) - response = client.post("/time-entries/%s/stop" % valid_id, follow_redirects=True) + response = client.post("/time-entries/%s/stop" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code repository_update_mock.assert_called_once_with(str(valid_id), changes={"end_date": mocker.ANY}, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture): +def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -269,32 +332,42 @@ def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker side_effect=UnprocessableEntity) invalid_id = fake.word() - response = client.post("/time-entries/%s/stop" % invalid_id, follow_redirects=True) + response = client.post("/time-entries/%s/stop" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_update_mock.assert_called_once_with(invalid_id, changes={"end_date": ANY}, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_restart_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_restart_time_entry_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', return_value=fake_time_entry) valid_id = fake.random_int(1, 9999) - response = client.post("/time-entries/%s/restart" % valid_id, follow_redirects=True) + response = client.post("/time-entries/%s/restart" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code repository_update_mock.assert_called_once_with(str(valid_id), changes={"end_date": None}, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture): +def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -303,36 +376,47 @@ def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, moc peeker=ANY) invalid_id = fake.word() - response = client.post("/time-entries/%s/restart" % invalid_id, follow_redirects=True) + response = client.post("/time-entries/%s/restart" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_update_mock.assert_called_once_with(invalid_id, changes={"end_date": None}, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_get_running_should_call_find_running(client: FlaskClient, mocker: MockFixture): +def test_get_running_should_call_find_running(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'find_running', return_value=fake_time_entry) - response = client.get("/time-entries/running", follow_redirects=True) + response = client.get("/time-entries/running", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code assert json.loads(response.data) is not None - repository_update_mock.assert_called_once_with(partition_key_value=current_user_tenant_id()) + repository_update_mock.assert_called_once_with(partition_key_value=tenant_id) def test_get_running_should_return_not_found_if_find_running_throws_StopIteration(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'find_running', side_effect=StopIteration) - response = client.get("/time-entries/running", follow_redirects=True) + response = client.get("/time-entries/running", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(partition_key_value=current_user_tenant_id()) + repository_update_mock.assert_called_once_with(partition_key_value=tenant_id) From be9fec2b59dc0a5c125c1156b057b61038b578f2 Mon Sep 17 00:00:00 2001 From: EliuX Date: Wed, 22 Apr 2020 13:18:50 -0500 Subject: [PATCH 10/13] fix: Ignore deleted-running time entry --- tests/conftest.py | 2 +- time_tracker_api/time_entries/time_entries_model.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5fad738c..92781c03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -154,7 +154,7 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, yield created_time_entry - time_entry_repository.delete(id=created_time_entry.id, + time_entry_repository.delete_permanently(id=created_time_entry.id, partition_key_value=tenant_id) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index f2646edc..3c2bd098 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -111,9 +111,11 @@ def find_running(self, partition_key_value: str, mapper: Callable = None): result = self.container.query_items( query=""" SELECT * from c - WHERE NOT IS_DEFINED(c.end_date) OR c.end_date = null + WHERE (NOT IS_DEFINED(c.end_date) OR c.end_date = null) {visibility_condition} OFFSET 0 LIMIT 1 - """, + """.format( + visibility_condition=self.create_sql_condition_for_visibility(True), + ), partition_key=partition_key_value, max_item_count=1) From fe27bbb22d5f0e04333938c58eaff0909a083e6e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 22 Apr 2020 21:03:52 +0000 Subject: [PATCH 11/13] 0.5.0 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index f0ede3d3..2b8877c5 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.4.1' +__version__ = '0.5.0' From 9a951ae631036961a0c291649bf15975d3b33493 Mon Sep 17 00:00:00 2001 From: roberto Date: Wed, 22 Apr 2020 13:26:10 -0500 Subject: [PATCH 12/13] chore: add pre-commit library and config to enforce semantic commit messages --- .pre-commit-config.yaml | 8 +++++ .../git_hooks/enforce_semantic_commit_msg.py | 29 +++++++++++++++++++ requirements/time_tracker_api/dev.txt | 3 ++ 3 files changed, 40 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 commons/git_hooks/enforce_semantic_commit_msg.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..797fb4b0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: +- repo: local + hooks: + - id: semantic-commit-msg + name: Check semantic commit message format + entry: python ./commons/git_hooks/enforce_semantic_commit_msg.py + language: python + stages : [commit-msg] diff --git a/commons/git_hooks/enforce_semantic_commit_msg.py b/commons/git_hooks/enforce_semantic_commit_msg.py new file mode 100644 index 00000000..c01a3605 --- /dev/null +++ b/commons/git_hooks/enforce_semantic_commit_msg.py @@ -0,0 +1,29 @@ +import re +import sys + +ERROR_MSG = """ +Commit failed! +Please use semantic commit message format. Examples: + 'feat: Applying some changes' + 'fix: Fixing something broken' + 'feat(config): Fix something in config files' + +For more details in commit message format, review https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits +""" + +SUCCESS_MSG = "Commit succeed!. Semantic commit message is correct." + +COMMIT_MSG_REGEX = r'(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert)(\([\w\-]+\))?:\s.*' + + +# Get the commit message file +commit_msg_file = open(sys.argv[1]) # The first argument is the file +commit_msg = commit_msg_file.read() + + +if re.match(COMMIT_MSG_REGEX, commit_msg) is None: + print(ERROR_MSG) + sys.exit(1) + +print(SUCCESS_MSG) +sys.exit(0) diff --git a/requirements/time_tracker_api/dev.txt b/requirements/time_tracker_api/dev.txt index c242e4a1..3a26760c 100644 --- a/requirements/time_tracker_api/dev.txt +++ b/requirements/time_tracker_api/dev.txt @@ -13,3 +13,6 @@ pytest-mock==2.0.0 # Coverage coverage==4.5.1 + +# Git hooks +pre-commit==2.2.0 \ No newline at end of file From cc3e1a3bffd5244169d3c86d371f849b0e5714f0 Mon Sep 17 00:00:00 2001 From: roberto Date: Wed, 22 Apr 2020 13:26:27 -0500 Subject: [PATCH 13/13] docs: add instructions to enable enforce-semantic-commit-message git hook --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index a4fdaff7..877ac6cb 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,16 @@ DateTime strings in Azure Cosmos DB is `YYYY-MM-DDThh:mm:ss.fffffffZ` which foll ## Development +### Git hooks +We use [pre-commit](https://github.com/pre-commit/pre-commit) library to manage local git hooks, as developers we just need to run in our virtual environment: + +``` +pre-commit install +``` +With this command the library will take configuration from `.pre-commit-config.yaml` and will set up the hooks by us. + +Currently, we only have a hook to enforce semantic commit message. + ### Test We are using [Pytest](https://docs.pytest.org/en/latest/index.html) for tests. The tests are located in the package `tests` and use the [conventions for python test discovery](https://docs.pytest.org/en/latest/goodpractices.html#test-discovery).