diff --git a/fittrackee/emails/tasks.py b/fittrackee/emails/tasks.py index 75c285a2..766b5d41 100644 --- a/fittrackee/emails/tasks.py +++ b/fittrackee/emails/tasks.py @@ -11,3 +11,33 @@ def reset_password_email(user: Dict, email_data: Dict) -> None: recipient=user['email'], data=email_data, ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def email_updated_to_current_address(user: Dict, email_data: Dict) -> None: + email_service.send( + template='email_update_to_current_email', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def email_updated_to_new_address(user: Dict, email_data: Dict) -> None: + email_service.send( + template='email_update_to_new_email', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def password_change_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='password_change', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) diff --git a/fittrackee/tests/conftest.py b/fittrackee/tests/conftest.py index 2cc0ae74..9914ebd5 100644 --- a/fittrackee/tests/conftest.py +++ b/fittrackee/tests/conftest.py @@ -10,6 +10,7 @@ os.environ['DATABASE_URL'] = os.environ['DATABASE_TEST_URL'] pytest_plugins = [ 'fittrackee.tests.fixtures.fixtures_app', + 'fittrackee.tests.fixtures.fixtures_emails', 'fittrackee.tests.fixtures.fixtures_workouts', 'fittrackee.tests.fixtures.fixtures_users', ] diff --git a/fittrackee/tests/fixtures/fixtures_emails.py b/fittrackee/tests/fixtures/fixtures_emails.py new file mode 100644 index 00000000..fde40a20 --- /dev/null +++ b/fittrackee/tests/fixtures/fixtures_emails.py @@ -0,0 +1,24 @@ +from typing import Iterator +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture() +def email_updated_to_current_address_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.users.auth.email_updated_to_current_address' + ) as mock: + yield mock + + +@pytest.fixture() +def email_updated_to_new_address_mock() -> Iterator[MagicMock]: + with patch('fittrackee.users.auth.email_updated_to_new_address') as mock: + yield mock + + +@pytest.fixture() +def password_change_email_mock() -> Iterator[MagicMock]: + with patch('fittrackee.users.auth.password_change_email') as mock: + yield mock diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index 4f1c4dd1..b23d1072 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -1,7 +1,7 @@ import json from datetime import datetime, timedelta from io import BytesIO -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest from flask import Flask @@ -14,6 +14,10 @@ from fittrackee.workouts.models import Sport, Workout from ..api_test_case import ApiTestCaseMixin from ..utils import random_string +USER_AGENT = ( + 'Mozilla/5.0 (X11; Linux x86_64; rv:98.0) Gecko/20100101 Firefox/98.0' +) + class TestUserRegistration(ApiTestCaseMixin): def test_user_can_register(self, app: Flask) -> None: @@ -579,6 +583,16 @@ class TestUserProfileUpdate(ApiTestCaseMixin): class TestUserAccountUpdate(ApiTestCaseMixin): + @staticmethod + def assert_no_emails_sent( + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: + email_updated_to_current_address_mock.send.assert_not_called() + email_updated_to_new_address_mock.send.assert_not_called() + password_change_email_mock.send.assert_not_called() + def test_it_returns_error_if_payload_is_empty( self, app: Flask, user_1: User ) -> None: @@ -659,8 +673,44 @@ class TestUserAccountUpdate(ApiTestCaseMixin): self.assert_401(response, error_message='invalid credentials') + def test_it_does_not_send_emails_when_error_occurs( + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.patch( + '/api/auth/profile/edit/account', + content_type='application/json', + data=json.dumps( + dict( + email=user_1.email, + password=random_string(), + new_password=random_string(), + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_no_emails_sent( + email_updated_to_current_address_mock, + email_updated_to_new_address_mock, + password_change_email_mock, + ) + def test_it_does_not_returns_error_if_no_new_password_provided( - self, app: Flask, user_1: User + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -683,16 +733,19 @@ class TestUserAccountUpdate(ApiTestCaseMixin): assert data['status'] == 'success' assert data['message'] == 'user account updated' - def test_it_does_not_update_user_account_if_no_new_password_provided( - self, app: Flask, user_1: User + def test_it_does_not_send_emails_if_no_change( + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) - current_hashed_password = user_1.password - current_email = user_1.email - response = client.patch( + client.patch( '/api/auth/profile/edit/account', content_type='application/json', data=json.dumps( @@ -704,12 +757,19 @@ class TestUserAccountUpdate(ApiTestCaseMixin): headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 200 - assert current_email == user_1.email - assert current_hashed_password == user_1.password + self.assert_no_emails_sent( + email_updated_to_current_address_mock, + email_updated_to_new_address_mock, + password_change_email_mock, + ) def test_it_returns_error_if_new_email_is_invalid( - self, app: Flask, user_1: User + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -729,8 +789,13 @@ class TestUserAccountUpdate(ApiTestCaseMixin): self.assert_400(response, 'email: valid email must be provided\n') - def test_it_does_not_update_email_if_new_email_provided( - self, app: Flask, user_1: User + def test_it_only_updates_email_to_confirm_if_new_email_provided( + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -755,18 +820,20 @@ class TestUserAccountUpdate(ApiTestCaseMixin): assert new_email == user_1.email_to_confirm assert user_1.confirmation_token is not None - def test_it_updates_email_to_confirm_when_new_email_provided( - self, app: Flask, user_1: User + def test_it_calls_email_updated_to_current_email_send_when_new_email_provided( # noqa + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) - previous_confirmation_token = random_string() - user_1.email_to_confirm = 'new.email@example.com' - user_1.confirmation_token = previous_confirmation_token - new_email = 'another.email@example.com' + new_email = 'new.email@example.com' - response = client.patch( + client.patch( '/api/auth/profile/edit/account', content_type='application/json', data=json.dumps( @@ -776,14 +843,104 @@ class TestUserAccountUpdate(ApiTestCaseMixin): ) ), headers=dict(Authorization=f'Bearer {auth_token}'), + environ_base={'HTTP_USER_AGENT': USER_AGENT}, ) - assert response.status_code == 200 - assert new_email == user_1.email_to_confirm - assert user_1.confirmation_token != previous_confirmation_token + email_updated_to_current_address_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_1.email, + }, + { + 'username': user_1.username, + 'fittrackee_url': 'http://0.0.0.0:5000', + 'operating_system': 'linux', + 'browser_name': 'firefox', + 'new_email_address': new_email, + }, + ) + + def test_it_calls_email_updated_to_new_email_send_when_new_email_provided( + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + new_email = 'new.email@example.com' + expected_token = random_string() + + with patch('secrets.token_urlsafe', return_value=expected_token): + client.patch( + '/api/auth/profile/edit/account', + content_type='application/json', + data=json.dumps( + dict( + email=new_email, + password='12345678', + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + environ_base={'HTTP_USER_AGENT': USER_AGENT}, + ) + + email_updated_to_new_address_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_1.email_to_confirm, + }, + { + 'username': user_1.username, + 'fittrackee_url': 'http://0.0.0.0:5000', + 'operating_system': 'linux', + 'browser_name': 'firefox', + 'email_confirmation_url': ( + f'http://0.0.0.0:5000/email-update?token={expected_token}' + ), + }, + ) + + def test_it_does_not_calls_password_change_email_send_when_new_email_provided( # noqa + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + new_email = 'new.email@example.com' + expected_token = random_string() + + with patch('secrets.token_urlsafe', return_value=expected_token): + client.patch( + '/api/auth/profile/edit/account', + content_type='application/json', + data=json.dumps( + dict( + email=new_email, + password='12345678', + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + environ_base={'HTTP_USER_AGENT': USER_AGENT}, + ) + + password_change_email_mock.send.assert_not_called() def test_it_returns_error_if_controls_fail_on_new_password( - self, app: Flask, user_1: User + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -804,8 +961,13 @@ class TestUserAccountUpdate(ApiTestCaseMixin): self.assert_400(response, 'password: 8 characters required') - def test_it_updates_auth_user_password( - self, app: Flask, user_1: User + def test_it_updates_auth_user_password_when_new_password_provided( + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -824,13 +986,21 @@ class TestUserAccountUpdate(ApiTestCaseMixin): ), headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == 200 data = json.loads(response.data.decode()) assert data['status'] == 'success' assert data['message'] == 'user account updated' assert current_hashed_password != user_1.password - def test_new_password_is_hashed(self, app: Flask, user_1: User) -> None: + def test_new_password_is_hashed( + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) @@ -848,9 +1018,142 @@ class TestUserAccountUpdate(ApiTestCaseMixin): ), headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == 200 assert new_password != user_1.password + def test_it_calls_password_change_email_when_new_password_provided( + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.patch( + '/api/auth/profile/edit/account', + content_type='application/json', + data=json.dumps( + dict( + email=user_1.email, + password='12345678', + new_password=random_string(), + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + environ_base={'HTTP_USER_AGENT': USER_AGENT}, + ) + + password_change_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_1.email, + }, + { + 'username': user_1.username, + 'fittrackee_url': 'http://0.0.0.0:5000', + 'operating_system': 'linux', + 'browser_name': 'firefox', + }, + ) + + def test_it_does_not_call_email_updated_emails_send_when_new_password_provided( # noqa + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.patch( + '/api/auth/profile/edit/account', + content_type='application/json', + data=json.dumps( + dict( + email=user_1.email, + password='12345678', + new_password=random_string(), + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + email_updated_to_current_address_mock.send.assert_not_called() + email_updated_to_new_address_mock.send.assert_not_called() + + def test_it_updates_email_to_confirm_and_password_when_new_email_and_password_provided( # noqa + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + current_email = user_1.email + current_hashed_password = user_1.password + new_email = 'new.email@example.com' + + response = client.patch( + '/api/auth/profile/edit/account', + content_type='application/json', + data=json.dumps( + dict( + email=new_email, + password='12345678', + new_password=random_string(), + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'user account updated' + assert user_1.email == current_email + assert user_1.email_to_confirm == new_email + assert user_1.password != current_hashed_password + + def test_it_calls_all_email_send_when_new_email_and_password_provided( + self, + app: Flask, + user_1: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.patch( + '/api/auth/profile/edit/account', + content_type='application/json', + data=json.dumps( + dict( + email='new.email@example.com', + password='12345678', + new_password=random_string(), + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + email_updated_to_current_address_mock.send.assert_called_once() + email_updated_to_new_address_mock.send.assert_called_once() + password_change_email_mock.send.assert_called_once() + class TestUserPreferencesUpdate(ApiTestCaseMixin): def test_it_updates_user_preferences( diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index b742845f..0a4e43c0 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -11,7 +11,12 @@ from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.utils import secure_filename from fittrackee import appLog, bcrypt, db -from fittrackee.emails.tasks import reset_password_email +from fittrackee.emails.tasks import ( + email_updated_to_current_address, + email_updated_to_new_address, + password_change_email, + reset_password_email, +) from fittrackee.files import get_absolute_file_path from fittrackee.responses import ( ForbiddenErrorResponse, @@ -686,6 +691,46 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: return InvalidPayloadErrorResponse(error_messages) db.session.commit() + + ui_url = current_app.config['UI_URL'] + user_data = { + 'language': ( + 'en' if auth_user.language is None else auth_user.language + ), + 'email': auth_user.email, + } + data = { + 'username': auth_user.username, + 'fittrackee_url': ui_url, + 'operating_system': request.user_agent.platform, + 'browser_name': request.user_agent.browser, + } + + if new_password is not None: + password_change_email.send(user_data, data) + + if ( + auth_user.email_to_confirm is not None + and auth_user.email_to_confirm != auth_user.email + ): + email_data = { + **data, + **{'new_email_address': email_to_confirm}, + } + email_updated_to_current_address.send(user_data, email_data) + + email_data = { + **data, + **{ + 'email_confirmation_url': ( + f'{ui_url}/email-update' + f'?token={auth_user.confirmation_token}' + ) + }, + } + user_data = {**user_data, **{'email': auth_user.email_to_confirm}} + email_updated_to_new_address.send(user_data, email_data) + return { 'status': 'success', 'message': 'user account updated',