♻️ Refactor email logic to allow re-using util functions for testing and development (#663)

This commit is contained in:
Sebastián Ramírez
2024-03-10 00:02:36 +01:00
committed by GitHub
parent c048a61d81
commit d4e35a0dfd
8 changed files with 102 additions and 70 deletions

View File

@@ -138,7 +138,7 @@ If you use GitHub Actions the tests will run automatically.
If your stack is already up and you just want to run the tests, you can use: If your stack is already up and you just want to run the tests, you can use:
```bash ```bash
docker compose exec backend /app/tests-start.sh docker compose exec backend bash /app/tests-start.sh
``` ```
That `/app/tests-start.sh` script just calls `pytest` after making sure that the rest of the stack is running. If you need to pass extra arguments to `pytest`, you can pass them to that command and they will be forwarded. That `/app/tests-start.sh` script just calls `pytest` after making sure that the rest of the stack is running. If you need to pass extra arguments to `pytest`, you can pass them to that command and they will be forwarded.

View File

@@ -3,7 +3,7 @@ from typing import Annotated
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import jwt from jose import JWTError, jwt
from pydantic import ValidationError from pydantic import ValidationError
from sqlmodel import Session from sqlmodel import Session
@@ -32,7 +32,7 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
) )
token_data = TokenPayload(**payload) token_data = TokenPayload(**payload)
except (jwt.JWTError, ValidationError): except (JWTError, ValidationError):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials", detail="Could not validate credentials",

View File

@@ -12,7 +12,8 @@ from app.core.security import get_password_hash
from app.models import Message, NewPassword, Token, User, UserOut from app.models import Message, NewPassword, Token, User, UserOut
from app.utils import ( from app.utils import (
generate_password_reset_token, generate_password_reset_token,
send_reset_password_email, generate_reset_password_email,
send_email,
verify_password_reset_token, verify_password_reset_token,
) )
@@ -62,9 +63,14 @@ def recover_password(email: str, session: SessionDep) -> Message:
detail="The user with this username does not exist in the system.", detail="The user with this username does not exist in the system.",
) )
password_reset_token = generate_password_reset_token(email=email) password_reset_token = generate_password_reset_token(email=email)
send_reset_password_email( email_data = generate_reset_password_email(
email_to=user.email, email=email, token=password_reset_token email_to=user.email, email=email, token=password_reset_token
) )
send_email(
email_to=user.email,
subject=email_data.subject,
html_content=email_data.html_content,
)
return Message(message="Password recovery email sent") return Message(message="Password recovery email sent")

View File

@@ -23,7 +23,7 @@ from app.models import (
UserUpdate, UserUpdate,
UserUpdateMe, UserUpdateMe,
) )
from app.utils import send_new_account_email from app.utils import generate_new_account_email, send_email
router = APIRouter() router = APIRouter()
@@ -61,9 +61,14 @@ def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
user = crud.create_user(session=session, user_create=user_in) user = crud.create_user(session=session, user_create=user_in)
if settings.EMAILS_ENABLED and user_in.email: if settings.EMAILS_ENABLED and user_in.email:
send_new_account_email( email_data = generate_new_account_email(
email_to=user_in.email, username=user_in.email, password=user_in.password email_to=user_in.email, username=user_in.email, password=user_in.password
) )
send_email(
email_to=user_in.email,
subject=email_data.subject,
html_content=email_data.html_content,
)
return user return user
@@ -185,7 +190,9 @@ def delete_user(
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if (user == current_user and not current_user.is_superuser) or (user != current_user and current_user.is_superuser): if (user == current_user and not current_user.is_superuser) or (
user != current_user and current_user.is_superuser
):
statement = delete(Item).where(Item.owner_id == user_id) statement = delete(Item).where(Item.owner_id == user_id)
session.exec(statement) session.exec(statement)
session.delete(user) session.delete(user)

View File

@@ -4,7 +4,7 @@ from pydantic.networks import EmailStr
from app.api.deps import get_current_active_superuser from app.api.deps import get_current_active_superuser
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.models import Message from app.models import Message
from app.utils import send_test_email from app.utils import generate_test_email, send_email
router = APIRouter() router = APIRouter()
@@ -31,5 +31,10 @@ def test_email(email_to: EmailStr) -> Message:
""" """
Test emails. Test emails.
""" """
send_test_email(email_to=email_to) email_data = generate_test_email(email_to=email_to)
send_email(
email_to=email_to,
subject=email_data.subject,
html_content=email_data.html_content,
)
return Message(message="Test email sent") return Message(message="Test email sent")

View File

@@ -39,8 +39,8 @@ def test_use_access_token(
def test_recovery_password( def test_recovery_password(
client: TestClient, normal_user_token_headers: dict[str, str], mocker client: TestClient, normal_user_token_headers: dict[str, str], mocker
) -> None: ) -> None:
mocker.patch("app.utils.send_reset_password_email", return_value=None)
mocker.patch("app.utils.send_email", return_value=None) mocker.patch("app.utils.send_email", return_value=None)
mocker.patch("app.core.config.settings.EMAILS_ENABLED", True)
email = "test@example.com" email = "test@example.com"
r = client.post( r = client.post(
f"{settings.API_V1_STR}/password-recovery/{email}", f"{settings.API_V1_STR}/password-recovery/{email}",
@@ -75,7 +75,7 @@ def test_reset_password(
r = client.post( r = client.post(
f"{settings.API_V1_STR}/reset-password/", f"{settings.API_V1_STR}/reset-password/",
headers=superuser_token_headers, headers=superuser_token_headers,
json=data json=data,
) )
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"message": "Password updated successfully"} assert r.json() == {"message": "Password updated successfully"}
@@ -88,7 +88,7 @@ def test_reset_password_invalid_token(
r = client.post( r = client.post(
f"{settings.API_V1_STR}/reset-password/", f"{settings.API_V1_STR}/reset-password/",
headers=superuser_token_headers, headers=superuser_token_headers,
json=data json=data,
) )
response = r.json() response = r.json()

View File

@@ -32,7 +32,7 @@ def test_get_users_normal_user_me(
def test_create_user_new_email( def test_create_user_new_email(
client: TestClient, superuser_token_headers: dict, db: Session, mocker client: TestClient, superuser_token_headers: dict, db: Session, mocker
) -> None: ) -> None:
mocker.patch("app.utils.send_new_account_email") mocker.patch("app.utils.send_email")
mocker.patch("app.core.config.settings.EMAILS_ENABLED", True) mocker.patch("app.core.config.settings.EMAILS_ENABLED", True)
username = random_email() username = random_email()
password = random_lower_string() password = random_lower_string()
@@ -68,9 +68,7 @@ def test_get_existing_user(
assert existing_user.email == api_user["email"] assert existing_user.email == api_user["email"]
def test_get_existing_user_current_user( def test_get_existing_user_current_user(client: TestClient, db: Session) -> None:
client: TestClient, db: Session
) -> None:
username = random_email() username = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=username, password=password) user_in = UserCreate(email=username, password=password)
@@ -184,7 +182,10 @@ def test_update_password_me(
client: TestClient, superuser_token_headers: dict, db: Session client: TestClient, superuser_token_headers: dict, db: Session
) -> None: ) -> None:
new_password = random_lower_string() new_password = random_lower_string()
data = {"current_password": settings.FIRST_SUPERUSER_PASSWORD, "new_password": new_password} data = {
"current_password": settings.FIRST_SUPERUSER_PASSWORD,
"new_password": new_password,
}
r = client.patch( r = client.patch(
f"{settings.API_V1_STR}/users/me/password", f"{settings.API_V1_STR}/users/me/password",
headers=superuser_token_headers, headers=superuser_token_headers,
@@ -195,7 +196,10 @@ def test_update_password_me(
assert updated_user["message"] == "Password updated successfully" assert updated_user["message"] == "Password updated successfully"
# Revert to the old password to keep consistency in test # Revert to the old password to keep consistency in test
old_data = {"current_password": new_password, "new_password": settings.FIRST_SUPERUSER_PASSWORD} old_data = {
"current_password": new_password,
"new_password": settings.FIRST_SUPERUSER_PASSWORD,
}
r = client.patch( r = client.patch(
f"{settings.API_V1_STR}/users/me/password", f"{settings.API_V1_STR}/users/me/password",
headers=superuser_token_headers, headers=superuser_token_headers,
@@ -222,7 +226,10 @@ def test_update_password_me_incorrect_password(
def test_update_password_me_same_password_error( def test_update_password_me_same_password_error(
client: TestClient, superuser_token_headers: dict, db: Session client: TestClient, superuser_token_headers: dict, db: Session
) -> None: ) -> None:
data = {"current_password": settings.FIRST_SUPERUSER_PASSWORD, "new_password": settings.FIRST_SUPERUSER_PASSWORD} data = {
"current_password": settings.FIRST_SUPERUSER_PASSWORD,
"new_password": settings.FIRST_SUPERUSER_PASSWORD,
}
r = client.patch( r = client.patch(
f"{settings.API_V1_STR}/users/me/password", f"{settings.API_V1_STR}/users/me/password",
headers=superuser_token_headers, headers=superuser_token_headers,
@@ -230,12 +237,12 @@ def test_update_password_me_same_password_error(
) )
assert r.status_code == 400 assert r.status_code == 400
updated_user = r.json() updated_user = r.json()
assert updated_user["detail"] == "New password cannot be the same as the current one" assert (
updated_user["detail"] == "New password cannot be the same as the current one"
)
def test_create_user_open( def test_create_user_open(client: TestClient, mocker) -> None:
client: TestClient, mocker
) -> None:
mocker.patch("app.core.config.settings.USERS_OPEN_REGISTRATION", True) mocker.patch("app.core.config.settings.USERS_OPEN_REGISTRATION", True)
username = random_email() username = random_email()
password = random_lower_string() password = random_lower_string()
@@ -251,9 +258,7 @@ def test_create_user_open(
assert created_user["full_name"] == full_name assert created_user["full_name"] == full_name
def test_create_user_open_forbidden_error( def test_create_user_open_forbidden_error(client: TestClient, mocker) -> None:
client: TestClient, mocker
) -> None:
mocker.patch("app.core.config.settings.USERS_OPEN_REGISTRATION", False) mocker.patch("app.core.config.settings.USERS_OPEN_REGISTRATION", False)
username = random_email() username = random_email()
password = random_lower_string() password = random_lower_string()
@@ -267,19 +272,23 @@ def test_create_user_open_forbidden_error(
assert r.json()["detail"] == "Open user registration is forbidden on this server" assert r.json()["detail"] == "Open user registration is forbidden on this server"
def test_create_user_open_already_exists_error( def test_create_user_open_already_exists_error(client: TestClient, mocker) -> None:
client: TestClient, mocker
) -> None:
mocker.patch("app.core.config.settings.USERS_OPEN_REGISTRATION", True) mocker.patch("app.core.config.settings.USERS_OPEN_REGISTRATION", True)
password = random_lower_string() password = random_lower_string()
full_name = random_lower_string() full_name = random_lower_string()
data = {"email": settings.FIRST_SUPERUSER, "password": password, "full_name": full_name} data = {
"email": settings.FIRST_SUPERUSER,
"password": password,
"full_name": full_name,
}
r = client.post( r = client.post(
f"{settings.API_V1_STR}/users/open", f"{settings.API_V1_STR}/users/open",
json=data, json=data,
) )
assert r.status_code == 400 assert r.status_code == 400
assert r.json()["detail"] == "The user with this username already exists in the system" assert (
r.json()["detail"] == "The user with this username already exists in the system"
)
def test_update_user( def test_update_user(
@@ -311,7 +320,9 @@ def test_update_user_not_exists(
json=data, json=data,
) )
assert r.status_code == 404 assert r.status_code == 404
assert r.json()["detail"] == "The user with this username does not exist in the system" assert (
r.json()["detail"] == "The user with this username does not exist in the system"
)
def test_delete_user_super_user( def test_delete_user_super_user(
@@ -331,9 +342,7 @@ def test_delete_user_super_user(
assert deleted_user["message"] == "User deleted successfully" assert deleted_user["message"] == "User deleted successfully"
def test_delete_user_current_user( def test_delete_user_current_user(client: TestClient, db: Session) -> None:
client: TestClient, db: Session
) -> None:
username = random_email() username = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=username, password=password) user_in = UserCreate(email=username, password=password)

View File

@@ -1,26 +1,38 @@
import logging import logging
from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import emails import emails
from emails.template import JinjaTemplate from jinja2 import Template
from jose import jwt from jose import JWTError, jwt
from app.core.config import settings from app.core.config import settings
@dataclass
class EmailData:
html_content: str
subject: str
def render_email_template(*, template_name: str, context: dict[str, Any]):
template_str = (Path(settings.EMAIL_TEMPLATES_DIR) / template_name).read_text()
html_content = Template(template_str).render(context)
return html_content
def send_email( def send_email(
*,
email_to: str, email_to: str,
subject_template: str = "", subject: str = "",
html_template: str = "", html_content: str = "",
environment: dict[str, Any] | None = None,
) -> None: ) -> None:
current_environment = environment or {}
assert settings.EMAILS_ENABLED, "no provided configuration for email variables" assert settings.EMAILS_ENABLED, "no provided configuration for email variables"
message = emails.Message( message = emails.Message(
subject=JinjaTemplate(subject_template), subject=subject,
html=JinjaTemplate(html_template), html=html_content,
mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
) )
smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT}
@@ -30,35 +42,28 @@ def send_email(
smtp_options["user"] = settings.SMTP_USER smtp_options["user"] = settings.SMTP_USER
if settings.SMTP_PASSWORD: if settings.SMTP_PASSWORD:
smtp_options["password"] = settings.SMTP_PASSWORD smtp_options["password"] = settings.SMTP_PASSWORD
response = message.send(to=email_to, render=current_environment, smtp=smtp_options) response = message.send(to=email_to, smtp=smtp_options)
logging.info(f"send email result: {response}") logging.info(f"send email result: {response}")
def send_test_email(email_to: str) -> None: def generate_test_email(email_to: str) -> EmailData:
project_name = settings.PROJECT_NAME project_name = settings.PROJECT_NAME
subject = f"{project_name} - Test email" subject = f"{project_name} - Test email"
with open(Path(settings.EMAIL_TEMPLATES_DIR) / "test_email.html") as f: html_content = render_email_template(
template_str = f.read() template_name="test_email.html",
send_email( context={"project_name": settings.PROJECT_NAME, "email": email_to},
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={"project_name": settings.PROJECT_NAME, "email": email_to},
) )
return EmailData(html_content=html_content, subject=subject)
def send_reset_password_email(email_to: str, email: str, token: str) -> None: def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData:
project_name = settings.PROJECT_NAME project_name = settings.PROJECT_NAME
subject = f"{project_name} - Password recovery for user {email}" subject = f"{project_name} - Password recovery for user {email}"
with open(Path(settings.EMAIL_TEMPLATES_DIR) / "reset_password.html") as f:
template_str = f.read()
server_host = settings.SERVER_HOST server_host = settings.SERVER_HOST
link = f"{server_host}/reset-password?token={token}" link = f"{server_host}/reset-password?token={token}"
send_email( html_content = render_email_template(
email_to=email_to, template_name="reset_password.html",
subject_template=subject, context={
html_template=template_str,
environment={
"project_name": settings.PROJECT_NAME, "project_name": settings.PROJECT_NAME,
"username": email, "username": email,
"email": email_to, "email": email_to,
@@ -66,19 +71,18 @@ def send_reset_password_email(email_to: str, email: str, token: str) -> None:
"link": link, "link": link,
}, },
) )
return EmailData(html_content=html_content, subject=subject)
def send_new_account_email(email_to: str, username: str, password: str) -> None: def generate_new_account_email(
email_to: str, username: str, password: str
) -> EmailData:
project_name = settings.PROJECT_NAME project_name = settings.PROJECT_NAME
subject = f"{project_name} - New account for user {username}" subject = f"{project_name} - New account for user {username}"
with open(Path(settings.EMAIL_TEMPLATES_DIR) / "new_account.html") as f:
template_str = f.read()
link = settings.SERVER_HOST link = settings.SERVER_HOST
send_email( html_content = render_email_template(
email_to=email_to, template_name="new_account.html",
subject_template=subject, context={
html_template=template_str,
environment={
"project_name": settings.PROJECT_NAME, "project_name": settings.PROJECT_NAME,
"username": username, "username": username,
"password": password, "password": password,
@@ -86,6 +90,7 @@ def send_new_account_email(email_to: str, username: str, password: str) -> None:
"link": link, "link": link,
}, },
) )
return EmailData(html_content=html_content, subject=subject)
def generate_password_reset_token(email: str) -> str: def generate_password_reset_token(email: str) -> str:
@@ -105,5 +110,5 @@ def verify_password_reset_token(token: str) -> str | None:
try: try:
decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
return decoded_token["sub"] return decoded_token["sub"]
except jwt.JWTError: except JWTError:
return None return None