API - send emails when updating account (WIP)

This commit is contained in:
Sam 2022-03-13 08:56:23 +01:00
parent baede9ba5c
commit 191390f397
5 changed files with 431 additions and 28 deletions

View File

@ -11,3 +11,33 @@ def reset_password_email(user: Dict, email_data: Dict) -> None:
recipient=user['email'], recipient=user['email'],
data=email_data, 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,
)

View File

@ -10,6 +10,7 @@ os.environ['DATABASE_URL'] = os.environ['DATABASE_TEST_URL']
pytest_plugins = [ pytest_plugins = [
'fittrackee.tests.fixtures.fixtures_app', 'fittrackee.tests.fixtures.fixtures_app',
'fittrackee.tests.fixtures.fixtures_emails',
'fittrackee.tests.fixtures.fixtures_workouts', 'fittrackee.tests.fixtures.fixtures_workouts',
'fittrackee.tests.fixtures.fixtures_users', 'fittrackee.tests.fixtures.fixtures_users',
] ]

View File

@ -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

View File

@ -1,7 +1,7 @@
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from io import BytesIO from io import BytesIO
from unittest.mock import Mock, patch from unittest.mock import MagicMock, Mock, patch
import pytest import pytest
from flask import Flask from flask import Flask
@ -14,6 +14,10 @@ from fittrackee.workouts.models import Sport, Workout
from ..api_test_case import ApiTestCaseMixin from ..api_test_case import ApiTestCaseMixin
from ..utils import random_string 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): class TestUserRegistration(ApiTestCaseMixin):
def test_user_can_register(self, app: Flask) -> None: def test_user_can_register(self, app: Flask) -> None:
@ -579,6 +583,16 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
class TestUserAccountUpdate(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( def test_it_returns_error_if_payload_is_empty(
self, app: Flask, user_1: User self, app: Flask, user_1: User
) -> None: ) -> None:
@ -659,8 +673,44 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
self.assert_401(response, error_message='invalid credentials') 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( 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: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email app, user_1.email
@ -683,16 +733,19 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
assert data['status'] == 'success' assert data['status'] == 'success'
assert data['message'] == 'user account updated' assert data['message'] == 'user account updated'
def test_it_does_not_update_user_account_if_no_new_password_provided( def test_it_does_not_send_emails_if_no_change(
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: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email 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', '/api/auth/profile/edit/account',
content_type='application/json', content_type='application/json',
data=json.dumps( data=json.dumps(
@ -704,12 +757,19 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
) )
assert response.status_code == 200 self.assert_no_emails_sent(
assert current_email == user_1.email email_updated_to_current_address_mock,
assert current_hashed_password == user_1.password email_updated_to_new_address_mock,
password_change_email_mock,
)
def test_it_returns_error_if_new_email_is_invalid( 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: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email app, user_1.email
@ -729,8 +789,13 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
self.assert_400(response, 'email: valid email must be provided\n') self.assert_400(response, 'email: valid email must be provided\n')
def test_it_does_not_update_email_if_new_email_provided( def test_it_only_updates_email_to_confirm_if_new_email_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: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email app, user_1.email
@ -755,18 +820,20 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
assert new_email == user_1.email_to_confirm assert new_email == user_1.email_to_confirm
assert user_1.confirmation_token is not None assert user_1.confirmation_token is not None
def test_it_updates_email_to_confirm_when_new_email_provided( def test_it_calls_email_updated_to_current_email_send_when_new_email_provided( # noqa
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: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email app, user_1.email
) )
previous_confirmation_token = random_string() new_email = 'new.email@example.com'
user_1.email_to_confirm = 'new.email@example.com'
user_1.confirmation_token = previous_confirmation_token
new_email = 'another.email@example.com'
response = client.patch( client.patch(
'/api/auth/profile/edit/account', '/api/auth/profile/edit/account',
content_type='application/json', content_type='application/json',
data=json.dumps( data=json.dumps(
@ -776,14 +843,104 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
) )
), ),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
environ_base={'HTTP_USER_AGENT': USER_AGENT},
) )
assert response.status_code == 200 email_updated_to_current_address_mock.send.assert_called_once_with(
assert new_email == user_1.email_to_confirm {
assert user_1.confirmation_token != previous_confirmation_token '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( 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: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email app, user_1.email
@ -804,8 +961,13 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
self.assert_400(response, 'password: 8 characters required') self.assert_400(response, 'password: 8 characters required')
def test_it_updates_auth_user_password( def test_it_updates_auth_user_password_when_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: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email app, user_1.email
@ -824,13 +986,21 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
), ),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
) )
assert response.status_code == 200 assert response.status_code == 200
data = json.loads(response.data.decode()) data = json.loads(response.data.decode())
assert data['status'] == 'success' assert data['status'] == 'success'
assert data['message'] == 'user account updated' assert data['message'] == 'user account updated'
assert current_hashed_password != user_1.password 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( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email app, user_1.email
) )
@ -848,9 +1018,142 @@ class TestUserAccountUpdate(ApiTestCaseMixin):
), ),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
) )
assert response.status_code == 200 assert response.status_code == 200
assert new_password != user_1.password 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): class TestUserPreferencesUpdate(ApiTestCaseMixin):
def test_it_updates_user_preferences( def test_it_updates_user_preferences(

View File

@ -11,7 +11,12 @@ from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from fittrackee import appLog, bcrypt, db 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.files import get_absolute_file_path
from fittrackee.responses import ( from fittrackee.responses import (
ForbiddenErrorResponse, ForbiddenErrorResponse,
@ -686,6 +691,46 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]:
return InvalidPayloadErrorResponse(error_messages) return InvalidPayloadErrorResponse(error_messages)
db.session.commit() 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 { return {
'status': 'success', 'status': 'success',
'message': 'user account updated', 'message': 'user account updated',