From ae884b6c03876ad81dc3df11ffdde449895f6d34 Mon Sep 17 00:00:00 2001 From: CWei Date: Tue, 13 Jan 2026 13:47:03 +0800 Subject: [PATCH 01/12] feat(smtp): integrate SMTP mailer support - Add SMTP configuration to env and Docker setup - Implement mailer with validation and error handling - Register SMTP mailer in FastAPI lifecycle --- .env.example | 10 ++ backend/core/config.py | 13 ++- backend/extensions/__init__.py | 4 +- backend/extensions/smtp.py | 173 ++++++++++++++++++++++++++++++ backend/utils/custom_exception.py | 7 +- docker-compose-prod.yaml | 9 ++ docker-compose.yaml | 9 ++ 7 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 backend/extensions/smtp.py diff --git a/.env.example b/.env.example index e47b296..1bdd430 100755 --- a/.env.example +++ b/.env.example @@ -58,3 +58,13 @@ GRAFANA_USER=grafanaUser GRAFANA_PASSWORD=grafanaPassword GRAFANA_HOST=${HOSTNAME:-localhost} GRAFANA_PORT=8109 + +# SMTP setting +SMTP_ENABLE=false +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=SMTPUserName +SMTP_PASSWORD=SMTPPassword +SMTP_FROM_EMAIL=SMTPFromEmail +SMTP_FROM_NAME=Docker Fullstack Template +SMTP_ENCRYPTION=tls \ No newline at end of file diff --git a/backend/core/config.py b/backend/core/config.py index 7279e80..5c25aa1 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -41,6 +41,7 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 1 day PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30 # 30 minutes + PASSWORD_RESET_EMAIL_COOLDOWN_SECONDS: int = 60 # 1 minute # Session settings SESSION_EXPIRE_MINUTES: int = 10080 # 7 days @@ -54,7 +55,17 @@ class Settings(BaseSettings): PASSWORD_MIN_LENGTH: int = 6 RATE_LIMIT: int = 200 RATE_LIMIT_WINDOW_SECONDS: int = 300 # 5 minutes - BLOCK_TIME_SECONDS: int = 600 # 10 minutes + BLOCK_TIME_SECONDS: int = 600 # 10 minutes + + # SMTP setting + SMTP_ENABLE: bool = False + SMTP_HOST: str + SMTP_PORT: int + SMTP_USERNAME: str + SMTP_PASSWORD: str + SMTP_FROM_EMAIL: str + SMTP_FROM_NAME: str + SMTP_ENCRYPTION: str # Default admin user settings DEFAULT_ADMIN_EMAIL: str = "admin@example.com" diff --git a/backend/extensions/__init__.py b/backend/extensions/__init__.py index 6f9d386..0b83571 100644 --- a/backend/extensions/__init__.py +++ b/backend/extensions/__init__.py @@ -1,5 +1,7 @@ from .exception_handler import add_exception_handlers +from .smtp import add_smtp def register_extensions(app): # Add new extensions imports below. - add_exception_handlers(app) \ No newline at end of file + add_exception_handlers(app) + add_smtp(app) \ No newline at end of file diff --git a/backend/extensions/smtp.py b/backend/extensions/smtp.py new file mode 100644 index 0000000..2ceb6ef --- /dev/null +++ b/backend/extensions/smtp.py @@ -0,0 +1,173 @@ +import ssl +import smtplib +import logging +from fastapi import FastAPI +from core.config import settings +from dataclasses import dataclass +from email.mime.text import MIMEText +from typing import Iterable, Optional +from email.message import EmailMessage +from email.mime.multipart import MIMEMultipart +from utils.custom_exception import SMTPNotConfiguredException + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class SMTPSettings: + enabled: bool + host: str + port: int + username: Optional[str] + password: Optional[str] + from_email: Optional[str] + from_name: str + encryption: str + + +class SMTPMailer: + def __init__(self, cfg: SMTPSettings): + self._cfg = cfg + + @property + def enabled(self) -> bool: + return self._cfg.enabled + + def _validate(self) -> None: + if not self._cfg.enabled: + raise SMTPNotConfiguredException("SMTP is disabled") + missing = [] + if not self._cfg.host: + missing.append("SMTP_HOST") + if not self._cfg.port: + missing.append("SMTP_PORT") + if not self._cfg.username: + missing.append("SMTP_USERNAME/SMTP_USER") + if not self._cfg.password: + missing.append("SMTP_PASSWORD") + if not self._cfg.from_email: + missing.append("SMTP_FROM_EMAIL/SMTP_FROM") + if missing: + raise SMTPNotConfiguredException(f"Missing SMTP settings: {', '.join(missing)}") + + enc = (self._cfg.encryption or "").strip().lower() + if enc not in {"tls", "ssl", "none"}: + raise SMTPNotConfiguredException("SMTP_ENCRYPTION must be tls, ssl, or none") + + def _open(self, timeout: int = 30) -> smtplib.SMTP: + """ + Create a new SMTP connection per send. + This avoids keeping long-lived connections in the web process. + """ + self._validate() + + enc = self._cfg.encryption.strip().lower() + context = ssl.create_default_context() + + if enc == "ssl": + client: smtplib.SMTP = smtplib.SMTP_SSL(self._cfg.host, self._cfg.port, timeout=timeout, context=context) + else: + client = smtplib.SMTP(self._cfg.host, self._cfg.port, timeout=timeout) + + try: + client.ehlo() + if enc == "tls": + client.starttls(context=context) + client.ehlo() + if self._cfg.username and self._cfg.password: + client.login(self._cfg.username, self._cfg.password) + return client + except Exception: + try: + client.quit() + except Exception: + pass + raise + + def send_text( + self, + *, + to_emails: Iterable[str], + subject: str, + body: str, + html_body: Optional[str] = None, + from_email: Optional[str] = None, + from_name: Optional[str] = None, + timeout: int = 30, + ) -> None: + self._validate() + + sender_email = from_email or self._cfg.from_email + sender_name = from_name or self._cfg.from_name + + # If HTML body is provided, create multipart message + if html_body: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{sender_name} <{sender_email}>" + msg["To"] = ", ".join(list(to_emails)) + + # Add plain text and HTML parts + part1 = MIMEText(body, "plain", "utf-8") + part2 = MIMEText(html_body, "html", "utf-8") + msg.attach(part1) + msg.attach(part2) + else: + # Plain text only + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = f"{sender_name} <{sender_email}>" + msg["To"] = ", ".join(list(to_emails)) + msg.set_content(body) + + with self._open(timeout=timeout) as client: + client.send_message(msg) + + +def build_smtp_settings() -> SMTPSettings: + return SMTPSettings( + enabled=bool(settings.SMTP_ENABLE), + host=(settings.SMTP_HOST or "").strip(), + port=int(settings.SMTP_PORT), + username=(settings.SMTP_USERNAME or None), + password=(settings.SMTP_PASSWORD or None), + from_email=(settings.SMTP_FROM_EMAIL or None), + from_name=(settings.SMTP_FROM_NAME or "Docker Fullstack Template"), + encryption=(settings.SMTP_ENCRYPTION or "tls"), + ) + + +# Global singleton instance +_SMTP_MAILER: Optional[SMTPMailer] = None + + +def get_mailer() -> SMTPMailer: + """ + Get SMTP mailer singleton instance. + Lazy initialization on first access. + """ + global _SMTP_MAILER + if _SMTP_MAILER is None: + cfg = build_smtp_settings() + _SMTP_MAILER = SMTPMailer(cfg) + + if cfg.enabled: + logger.info( + "SMTP enabled: host=%s port=%s encryption=%s from=%s", + cfg.host, + cfg.port, + (cfg.encryption or "").lower(), + cfg.from_email, + ) + else: + logger.info("SMTP disabled") + + return _SMTP_MAILER + + +def add_smtp(app: FastAPI) -> None: + """ + Initialize SMTP mailer and register to app.state. + """ + mailer = get_mailer() + app.state.smtp = mailer \ No newline at end of file diff --git a/backend/utils/custom_exception.py b/backend/utils/custom_exception.py index 1dd3dc2..e48d3e9 100644 --- a/backend/utils/custom_exception.py +++ b/backend/utils/custom_exception.py @@ -68,4 +68,9 @@ def __init__(self, message: str = "Resource conflict", details: Dict[str, Any] = class TokenException(BaseServiceException): """Token related exceptions""" def __init__(self, message: str = "Token error", details: Dict[str, Any] = None): - super().__init__(message=message, error_code="TOKEN_ERROR", details=details, status_code=401, log_level="warning") \ No newline at end of file + super().__init__(message=message, error_code="TOKEN_ERROR", details=details, status_code=401, log_level="warning") + +class SMTPNotConfiguredException(BaseServiceException): + """SMTP configuration related exceptions""" + def __init__(self, message: str = "SMTP is not configured", details: Dict[str, Any] = None): + super().__init__(message=message, error_code="SMTP_NOT_CONFIGURED", details=details, status_code=503, log_level="warning") \ No newline at end of file diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 01ff5b3..9797c7c 100755 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -110,6 +110,15 @@ services: - DB_WRITE_TIMEOUT=${DB_WRITE_TIMEOUT} # Redis settings - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 + # SMTP settings + - SMTP_ENABLE=${SMTP_ENABLE} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - SMTP_USERNAME=${SMTP_USERNAME} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL} + - SMTP_FROM_NAME=${SMTP_FROM_NAME} + - SMTP_ENCRYPTION=${SMTP_ENCRYPTION} command: ["sh", "./init-prod.sh"] depends_on: mariadb: diff --git a/docker-compose.yaml b/docker-compose.yaml index 52d0dd1..0f0393c 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -109,6 +109,15 @@ services: - DB_WRITE_TIMEOUT=${DB_WRITE_TIMEOUT:-30} # Redis settings - REDIS_URL=redis://:${REDIS_PASSWORD:-redis}@redis:6379/0 + # SMTP settings + - SMTP_ENABLE=${SMTP_ENABLE:-false} + - SMTP_HOST=${SMTP_HOST:-smtp.gmail.com} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USERNAME=${SMTP_USERNAME:-your-email@gmail.com} + - SMTP_PASSWORD=${SMTP_PASSWORD:-your-password} + - SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-your-email@gmail.com} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-Docker Fullstack Template} + - SMTP_ENCRYPTION=${SMTP_ENCRYPTION:-tls} command: ["sh", "./init-dev.sh"] depends_on: mariadb: From be163114d42c464e19820d3613eae9e131163a8d Mon Sep 17 00:00:00 2001 From: CWei Date: Tue, 13 Jan 2026 13:49:50 +0800 Subject: [PATCH 02/12] feat(auth): implement password reset APIs with cooldown - Add forgot password and reset cooldown endpoints - Add email template for password reset notifications - Implement reset and cooldown handling in auth services - Extend schemas for password reset requests and responses --- backend/api/auth/controller.py | 80 ++++++++++++++++++- backend/api/auth/schema.py | 8 +- backend/api/auth/services.py | 131 ++++++++++++++++++++++++++++++- backend/utils/email_templates.py | 83 ++++++++++++++++++++ 4 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 backend/utils/email_templates.py diff --git a/backend/api/auth/controller.py b/backend/api/auth/controller.py index de4d71d..d7bb094 100644 --- a/backend/api/auth/controller.py +++ b/backend/api/auth/controller.py @@ -7,8 +7,10 @@ from utils.get_real_ip import get_real_ip from sqlalchemy.ext.asyncio import AsyncSession from core.security import verify_password_reset_token, verify_token -from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query from utils.response import APIResponse, parse_responses, common_responses +from extensions.smtp import get_mailer, SMTPMailer +from utils.custom_exception import SMTPNotConfiguredException from .schema import ( UserRegister, UserLogin, @@ -17,7 +19,9 @@ PasswordResetRequiredResponse, ResetPasswordRequest, TokenValidationResponse, - LogoutRequest + LogoutRequest, + ForgotPasswordRequest, + PasswordResetCooldownResponse, ) from .services import ( register, @@ -26,13 +30,16 @@ token, logout_all_devices, reset_password, - validate_password_reset_token + validate_password_reset_token, + forgot_password, + get_password_reset_cooldown, ) from utils.custom_exception import ( ConflictException, AuthenticationException, PasswordResetRequiredException, - NotFoundException + NotFoundException, + ValidationException, ) logger = logging.getLogger(__name__) @@ -310,5 +317,70 @@ async def validate_reset_token_api( return APIResponse(code=200, message="Token is valid", data=result) except AuthenticationException: raise HTTPException(status_code=401, detail="Invalid or expired token") + except Exception: + raise HTTPException(status_code=500) + + +@router.post( + "/forgot-password", + response_model=APIResponse[None], + response_model_exclude_none=True, + summary="Send reset password email", + responses=parse_responses({ + 200: ("Reset password email sent", None), + 400: ("Please wait before requesting another password reset email", None), + 403: ("Account is disabled", None), + 404: ("User not registered", None), + 503: ("SMTP is disabled", None), + }, common_responses), +) +async def forgot_password_api( + request: Request, + request_data: ForgotPasswordRequest, + db: AsyncSession = Depends(get_db), + mailer: SMTPMailer = Depends(get_mailer), + redis_client = Depends(get_redis), +): + """ + Send password reset email based on input email. + """ + try: + await forgot_password(db, request_data.email, mailer, redis_client) + return APIResponse(code=200, message="Reset password email sent") + except ValidationException: + raise HTTPException(status_code=400, detail="Please wait before requesting another password reset email") + except AuthenticationException: + raise HTTPException(status_code=403, detail="Account is disabled") + except NotFoundException: + raise HTTPException(status_code=404, detail="User not registered") + except SMTPNotConfiguredException: + raise HTTPException(status_code=503, detail="SMTP is disabled") + except Exception: + raise HTTPException(status_code=500) + + +@router.get( + "/forgot-password/cooldown", + response_model=APIResponse[PasswordResetCooldownResponse], + response_model_exclude_none=True, + summary="Get password reset email cooldown status", + responses=parse_responses({ + 200: ("Cooldown status retrieved", PasswordResetCooldownResponse), + }, common_responses), +) +async def get_password_reset_cooldown_api( + email: str = Query(..., description="Email address to check cooldown for"), + redis_client = Depends(get_redis), +): + """ + Get remaining cooldown time for password reset email. + Returns 0 if no cooldown is active. + """ + try: + result = await get_password_reset_cooldown(email, redis_client) + response_data = PasswordResetCooldownResponse( + cooldown_seconds=result["cooldown_seconds"] + ) + return APIResponse(code=200, message="Cooldown status retrieved", data=response_data) except Exception: raise HTTPException(status_code=500) \ No newline at end of file diff --git a/backend/api/auth/schema.py b/backend/api/auth/schema.py index ea4b650..245692d 100644 --- a/backend/api/auth/schema.py +++ b/backend/api/auth/schema.py @@ -50,4 +50,10 @@ class ResetPasswordRequest(BaseModel): new_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH, max_length=50, description="New password") class TokenValidationResponse(BaseModel): - is_valid: bool = Field(..., description="Whether the token is valid") \ No newline at end of file + is_valid: bool = Field(..., description="Whether the token is valid") + +class ForgotPasswordRequest(BaseModel): + email: EmailStr = Field(..., description="User email address") + +class PasswordResetCooldownResponse(BaseModel): + cooldown_seconds: int = Field(..., description="Remaining cooldown time in seconds") \ No newline at end of file diff --git a/backend/api/auth/services.py b/backend/api/auth/services.py index 9e53080..65b7816 100644 --- a/backend/api/auth/services.py +++ b/backend/api/auth/services.py @@ -2,12 +2,15 @@ import redis from typing import Optional from sqlalchemy import select +from urllib.parse import quote from models.users import Users from core.config import settings +from extensions.smtp import SMTPMailer from models.login_logs import LoginLogs from datetime import datetime, timedelta from models.user_sessions import UserSessions from sqlalchemy.ext.asyncio import AsyncSession +from utils.email_templates import PASSWORD_RESET_TEMPLATE from models.password_reset_tokens import PasswordResetTokens from .schema import ( UserRegister, @@ -29,10 +32,13 @@ ConflictException, AuthenticationException, PasswordResetRequiredException, + NotFoundException, + SMTPNotConfiguredException, ServerException, - NotFoundException + ValidationException, ) + async def register( db: AsyncSession, redis_client: redis.Redis, @@ -312,8 +318,8 @@ async def validate_password_reset_token( ) user = result.scalar_one_or_none() - if not user or not user.password_reset_required: - raise AuthenticationException("User not found or password reset not required") + if not user or not user.status: + raise AuthenticationException("User not found or account disabled") return TokenValidationResponse( is_valid=True @@ -324,6 +330,89 @@ async def validate_password_reset_token( except Exception as e: raise ServerException(f"Token validation failed: {str(e)}") +async def forgot_password( + db: AsyncSession, + email: str, + mailer: SMTPMailer, + redis_client: redis.Redis, +) -> dict: + """ + Forgot password and send reset password email + """ + try: + user = await _get_user_by_email_for_password_reset(db, email) + + if not getattr(mailer, "enabled", False): + raise SMTPNotConfiguredException("SMTP is disabled") + + cooldown_key = f"password_reset_cooldown:{email}" + remaining_seconds = await redis_client.ttl(cooldown_key) + + if remaining_seconds > 0: + raise ValidationException( + f"Please wait {remaining_seconds} seconds before requesting another password reset email", + details={"cooldown_seconds": remaining_seconds} + ) + + token_meta = await _request_password_reset_email(db, user) + reset_token = token_meta["reset_token"] + reset_url = ( + f"http{'s' if settings.SSL_ENABLE else ''}://" + f"{settings.HOSTNAME}:{settings.FRONTEND_PORT}" + f"/reset-password?token={quote(reset_token, safe='')}" + ) + + # Render email template with user name and app name + user_name = f"{user.first_name} {user.last_name}".strip() + app_name = settings.PROJECT_NAME + + email_content = PASSWORD_RESET_TEMPLATE.render( + reset_url=reset_url, + user_name=user_name, + app_name=app_name, + ) + + mailer.send_text( + to_emails=[email], + subject=email_content["subject"], + body=email_content["body"], + html_body=email_content.get("html_body"), + ) + + # Set cooldown period in Redis + await redis_client.setex( + cooldown_key, + settings.PASSWORD_RESET_EMAIL_COOLDOWN_SECONDS, + "1" + ) + + return {**token_meta, "reset_url": reset_url} + + except (NotFoundException, AuthenticationException, SMTPNotConfiguredException, ValidationException): + raise + except Exception as e: + raise ServerException(f"Failed to send password reset email: {str(e)}") + + +async def get_password_reset_cooldown( + email: str, + redis_client: redis.Redis, +) -> dict: + """ + Get remaining cooldown time for password reset email. + + Returns: + Dict with 'cooldown_seconds' (0 if no cooldown active) + """ + cooldown_key = f"password_reset_cooldown:{email}" + remaining_seconds = await redis_client.ttl(cooldown_key) + + # TTL returns -1 if key exists but has no expiry, -2 if key doesn't exist + if remaining_seconds < 0: + remaining_seconds = 0 + + return {"cooldown_seconds": remaining_seconds} + async def _update_session_expiry(db: AsyncSession, session_id: str) -> None: """Update session expiry time in database""" try: @@ -439,4 +528,38 @@ async def _log_login_attempt( failure_reason=failure_reason ) db.add(log) - await db.commit() \ No newline at end of file + await db.commit() + +async def _get_user_by_email_for_password_reset(db: AsyncSession, email: str) -> Users: + result = await db.execute(select(Users).where(Users.email == email)) + user = result.scalar_one_or_none() + if not user: + raise NotFoundException("User not registered") + if not user.status: + raise AuthenticationException("Account is disabled") + return user + +async def _request_password_reset_email( + db: AsyncSession, + user: Users, +) -> dict: + """ + Create a password reset token record for the user and return token metadata. + """ + now = datetime.now().astimezone() + expires_at = now + timedelta(minutes=settings.PASSWORD_RESET_TOKEN_EXPIRE_MINUTES) + + reset_token = await create_password_reset_token(user.id, user.email) + reset_token_record = PasswordResetTokens( + user_id=user.id, + token=reset_token, + expires_at=expires_at, + ) + db.add(reset_token_record) + await db.commit() + + return { + "reset_token": reset_token, + "expires_at": expires_at, + "user_id": user.id, + } \ No newline at end of file diff --git a/backend/utils/email_templates.py b/backend/utils/email_templates.py new file mode 100644 index 0000000..333cc88 --- /dev/null +++ b/backend/utils/email_templates.py @@ -0,0 +1,83 @@ +""" +Email templates for the application. +Supports variable substitution using Python string formatting. +Supports both plain text and HTML formats with modern design. +""" +from typing import Dict, Any, Optional +from core.config import settings + + +class EmailTemplate: + """Email template with subject, plain text body, and optional HTML body""" + + def __init__( + self, + subject: str, + body: str, + html_body: Optional[str] = None + ): + self.subject = subject + self.body = body + self.html_body = html_body + + def render(self, **kwargs: Any) -> Dict[str, str]: + """ + Render template with variables. + + Args: + **kwargs: Variables to substitute in template + + Returns: + Dict with 'subject', 'body', and optionally 'html_body' keys + """ + result = { + "subject": self.subject.format(**kwargs), + "body": self.body.format(**kwargs), + } + + if self.html_body: + result["html_body"] = self.html_body.format(**kwargs) + + return result + + +# Password Reset Email Template +PASSWORD_RESET_TEMPLATE = EmailTemplate( + subject="Reset your password - {app_name}", + body=( + "Hi {user_name},\n\n" + "You requested a password reset for your {app_name} account.\n\n" + "Please click the link below to set a new password:\n{reset_url}\n\n" + "This link will expire in 30 minutes.\n\n" + "If you did not request this, you can safely ignore this email." + ), + html_body=( + "" + "" + "" + "" + "" + "" + "" + "
" + "

Hi {user_name},

" + "

" + "You requested a password reset for your {app_name} account.
Click the button below to set a new password. This link will expire in 30 minutes." + "

" + "

" + "" + "Reset Password" + "" + "

" + "

" + "If you did not request this password reset, you can safely ignore this email. Your account remains secure." + "

" + "
" + "" + "" + ), +) From 082a10439ad3cf30001cc569931a472ab0cf2254 Mon Sep 17 00:00:00 2001 From: CWei Date: Tue, 13 Jan 2026 13:51:18 +0800 Subject: [PATCH 03/12] feat(test): improve password reset validation and test coverage - Add tests for forgot password flow and SMTP mailer - Validate cooldown and account status checks - Update schema tests for reset-related requests and responses --- backend/tests/api/auth/test_controller.py | 106 +++- backend/tests/api/auth/test_schema.py | 34 ++ backend/tests/api/auth/test_service.py | 164 +++++- backend/tests/extensions/__init__.py | 0 backend/tests/extensions/test_smtp.py | 630 ++++++++++++++++++++++ 5 files changed, 920 insertions(+), 14 deletions(-) create mode 100644 backend/tests/extensions/__init__.py create mode 100644 backend/tests/extensions/test_smtp.py diff --git a/backend/tests/api/auth/test_controller.py b/backend/tests/api/auth/test_controller.py index 5e99f7c..78fceaa 100644 --- a/backend/tests/api/auth/test_controller.py +++ b/backend/tests/api/auth/test_controller.py @@ -1,12 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch from httpx import AsyncClient -from fastapi import HTTPException from api.auth.schema import ( - UserRegister, - UserLogin, - ResetPasswordRequest, - LogoutRequest, PasswordResetRequiredResponse, ) from utils.custom_exception import ( @@ -14,11 +9,11 @@ AuthenticationException, PasswordResetRequiredException, NotFoundException, + SMTPNotConfiguredException, + ValidationException, ) from core.security import ( - create_password_reset_token, verify_password_reset_token, - get_token, verify_token, ) from main import app @@ -559,4 +554,99 @@ async def mock_verify_password_reset_token(): assert response.status_code == 200 assert "session_id" in response.cookies finally: - app.dependency_overrides.pop(verify_password_reset_token, None) \ No newline at end of file + app.dependency_overrides.pop(verify_password_reset_token, None) + + @pytest.mark.asyncio + async def test_forgot_password_send_email_success(self, client: AsyncClient): + """Test forgot password sends reset email""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.return_value = {"reset_url": "http://localhost:3000/reset-password?token=test"} + + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "Reset password email sent" + mock_send.assert_called_once() + + @pytest.mark.asyncio + async def test_forgot_password_user_not_found(self, client: AsyncClient): + """Test forgot password returns error if user not found""" + req_data = {"email": "not-exist@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = NotFoundException("User not found") + + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 404 + data = response.json() + assert data["code"] == 404 + assert data["message"] == "User not registered" + + @pytest.mark.asyncio + async def test_forgot_password_cooldown_active(self, client: AsyncClient): + """Test forgot password returns 400 when cooldown is active""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = ValidationException( + "Please wait 60 seconds before requesting another password reset email", + details={"cooldown_seconds": 60} + ) + + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 400 + data = response.json() + assert data["code"] == 400 + assert data["message"] == "Please wait before requesting another password reset email" + + @pytest.mark.asyncio + async def test_forgot_password_account_disabled(self, client: AsyncClient): + """Test forgot password returns 403 when account is disabled""" + req_data = {"email": "disabled@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = AuthenticationException("Account is disabled") + + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 403 + data = response.json() + assert data["code"] == 403 + assert data["message"] == "Account is disabled" + + @pytest.mark.asyncio + async def test_forgot_password_smtp_disabled(self, client: AsyncClient): + """Test forgot password returns 503 if SMTP disabled""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = SMTPNotConfiguredException("SMTP is disabled") + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 503 + data = response.json() + assert data["code"] == 503 + assert data["message"] == "SMTP is disabled" + mock_send.assert_called_once() + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_success(self, client: AsyncClient): + """Test get password reset cooldown returns remaining time""" + with patch("api.auth.controller.get_password_reset_cooldown", new_callable=AsyncMock) as mock_cooldown: + mock_cooldown.return_value = {"cooldown_seconds": 120} + + response = await client.get("/api/auth/forgot-password/cooldown?email=test@example.com") + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "Cooldown status retrieved" + assert data["data"]["cooldown_seconds"] == 120 + mock_cooldown.assert_called_once() + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_no_cooldown(self, client: AsyncClient): + """Test get password reset cooldown returns 0 when no cooldown""" + with patch("api.auth.controller.get_password_reset_cooldown", new_callable=AsyncMock) as mock_cooldown: + mock_cooldown.return_value = {"cooldown_seconds": 0} + + response = await client.get("/api/auth/forgot-password/cooldown?email=test@example.com") + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["data"]["cooldown_seconds"] == 0 \ No newline at end of file diff --git a/backend/tests/api/auth/test_schema.py b/backend/tests/api/auth/test_schema.py index 584d775..1f656c8 100644 --- a/backend/tests/api/auth/test_schema.py +++ b/backend/tests/api/auth/test_schema.py @@ -11,6 +11,8 @@ LogoutRequest, ResetPasswordRequest, TokenValidationResponse, + ForgotPasswordRequest, + PasswordResetCooldownResponse, LoginResult, SessionResult, ) @@ -302,6 +304,38 @@ def test_token_validation_response_invalid_type(self): errors = exc_info.value.errors() assert len(errors) == 1 + def test_forgot_password_request_valid(self): + """Test forgot password request schema""" + data = {"email": "john.doe@example.com"} + req = ForgotPasswordRequest(**data) + assert req.email == "john.doe@example.com" + + def test_forgot_password_request_invalid_email(self): + """Test forgot password request invalid email""" + data = {"email": "invalid-email"} + with pytest.raises(ValidationError): + ForgotPasswordRequest(**data) + + def test_password_reset_cooldown_response_valid(self): + """Test password reset cooldown response with valid data""" + data = {"cooldown_seconds": 120} + cooldown_response = PasswordResetCooldownResponse(**data) + assert cooldown_response.cooldown_seconds == 120 + + def test_password_reset_cooldown_response_zero(self): + """Test password reset cooldown response with zero cooldown""" + data = {"cooldown_seconds": 0} + cooldown_response = PasswordResetCooldownResponse(**data) + assert cooldown_response.cooldown_seconds == 0 + + def test_password_reset_cooldown_response_missing_field(self): + """Test password reset cooldown response with missing field""" + data = {} + with pytest.raises(ValidationError) as exc_info: + PasswordResetCooldownResponse(**data) + errors = exc_info.value.errors() + assert len(errors) == 1 + def test_login_result_typing(self): """Test LoginResult TypedDict""" user_data = { diff --git a/backend/tests/api/auth/test_service.py b/backend/tests/api/auth/test_service.py index 679ad7c..d64141a 100644 --- a/backend/tests/api/auth/test_service.py +++ b/backend/tests/api/auth/test_service.py @@ -1,11 +1,12 @@ import pytest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from models.users import Users from models.user_sessions import UserSessions from models.password_reset_tokens import PasswordResetTokens from core.security import hash_password, create_access_token +from extensions.smtp import SMTPMailer from api.auth.services import ( register, login, @@ -14,6 +15,8 @@ token, reset_password, validate_password_reset_token, + forgot_password, + get_password_reset_cooldown, ) from api.auth.schema import UserRegister, UserLogin from utils.custom_exception import ( @@ -22,6 +25,8 @@ PasswordResetRequiredException, ServerException, NotFoundException, + SMTPNotConfiguredException, + ValidationException, ) @@ -520,16 +525,16 @@ async def test_validate_password_reset_token_user_not_found( with pytest.raises(AuthenticationException) as exc_info: await validate_password_reset_token(test_db_session, token_data) - assert "User not found or password reset not required" in str(exc_info.value) + assert "User not found or account disabled" in str(exc_info.value) @pytest.mark.asyncio async def test_validate_password_reset_token_not_required( self, test_db_session: AsyncSession ): - """Test password reset token validation when not required""" + """Test password reset token validation when password reset not required""" user_id = "test-no-reset-validate-user" hashed_pwd = await hash_password("TestPassword123!") - + no_reset_user = Users( id=user_id, email="noresetvalidate@example.com", @@ -553,7 +558,154 @@ async def test_validate_password_reset_token_not_required( token_data = {"sub": user_id, "token": "test_token_123"} + # validate_password_reset_token does not check password_reset_required, + # it only validates token existence and user status + result = await validate_password_reset_token(test_db_session, token_data) + assert result.is_valid is True + + @pytest.mark.asyncio + async def test_forgot_password_success( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test successful forgot password flow""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 # No cooldown + mock_redis.setex.return_value = True + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text = MagicMock() + + result = await forgot_password( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + + assert "reset_token" in result + assert "reset_url" in result + assert "expires_at" in result + mock_mailer.send_text.assert_called_once() + mock_redis.setex.assert_called_once() + + @pytest.mark.asyncio + async def test_forgot_password_user_not_found(self, test_db_session: AsyncSession): + """Test forgot password with non-existent user""" + mock_redis = AsyncMock() + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + + with pytest.raises(NotFoundException): + await forgot_password( + test_db_session, + "notfound@example.com", + mock_mailer, + mock_redis, + ) + + @pytest.mark.asyncio + async def test_forgot_password_account_disabled( + self, test_db_session: AsyncSession + ): + """Test forgot password with disabled account""" + hashed_pwd = await hash_password("TestPassword123!") + disabled_user = Users( + id="test-disabled-user", + email="disabled@example.com", + first_name="Disabled", + last_name="User", + phone="+1234567890", + hash_password=hashed_pwd, + status=False, + password_reset_required=False, + ) + test_db_session.add(disabled_user) + await test_db_session.commit() + + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + with pytest.raises(AuthenticationException) as exc_info: - await validate_password_reset_token(test_db_session, token_data) + await forgot_password( + test_db_session, + disabled_user.email, + mock_mailer, + mock_redis, + ) + assert "Account is disabled" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_forgot_password_cooldown_active( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test forgot password when cooldown is active""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 60 # 60 seconds remaining + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + + with pytest.raises(ValidationException) as exc_info: + await forgot_password( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + assert "Please wait" in str(exc_info.value) + assert exc_info.value.details.get("cooldown_seconds") == 60 + + @pytest.mark.asyncio + async def test_forgot_password_smtp_disabled( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test forgot password when SMTP is disabled""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = False + + with pytest.raises(SMTPNotConfiguredException): + await forgot_password( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_with_cooldown(self): + """Test get password reset cooldown when cooldown is active""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 180 # 180 seconds remaining + + result = await get_password_reset_cooldown("test@example.com", mock_redis) + + assert result["cooldown_seconds"] == 180 + mock_redis.ttl.assert_called_once_with("password_reset_cooldown:test@example.com") + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_no_cooldown(self): + """Test get password reset cooldown when no cooldown is active""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -2 # Key doesn't exist + + result = await get_password_reset_cooldown("test@example.com", mock_redis) + + assert result["cooldown_seconds"] == 0 + mock_redis.ttl.assert_called_once_with("password_reset_cooldown:test@example.com") + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_expired(self): + """Test get password reset cooldown when key exists but expired""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 # Key exists but no expiry + + result = await get_password_reset_cooldown("test@example.com", mock_redis) - assert "User not found or password reset not required" in str(exc_info.value) \ No newline at end of file + assert result["cooldown_seconds"] == 0 \ No newline at end of file diff --git a/backend/tests/extensions/__init__.py b/backend/tests/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/extensions/test_smtp.py b/backend/tests/extensions/test_smtp.py new file mode 100644 index 0000000..dd66386 --- /dev/null +++ b/backend/tests/extensions/test_smtp.py @@ -0,0 +1,630 @@ +import pytest +from unittest.mock import MagicMock, patch +from fastapi import FastAPI +import extensions.smtp +from utils.custom_exception import SMTPNotConfiguredException +from extensions.smtp import ( + SMTPSettings, + SMTPMailer, + build_smtp_settings, + get_mailer, + add_smtp, +) + + +class TestSMTPSettings: + """Test SMTPSettings dataclass""" + + def test_smtp_settings_creation(self): + """Test creating SMTPSettings with all fields""" + settings = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user@example.com", + password="password123", + from_email="from@example.com", + from_name="Test App", + encryption="tls", + ) + assert settings.enabled is True + assert settings.host == "smtp.example.com" + assert settings.port == 587 + assert settings.username == "user@example.com" + assert settings.password == "password123" + assert settings.from_email == "from@example.com" + assert settings.from_name == "Test App" + assert settings.encryption == "tls" + + def test_smtp_settings_optional_fields(self): + """Test SMTPSettings with optional None fields""" + settings = SMTPSettings( + enabled=False, + host="", + port=25, + username=None, + password=None, + from_email=None, + from_name="App", + encryption="none", + ) + assert settings.enabled is False + assert settings.username is None + assert settings.password is None + assert settings.from_email is None + + +class TestSMTPMailer: + """Test SMTPMailer class""" + + def test_enabled_property(self): + """Test enabled property""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + assert mailer.enabled is True + + cfg_disabled = SMTPSettings( + enabled=False, + host="", + port=25, + username=None, + password=None, + from_email=None, + from_name="App", + encryption="none", + ) + mailer_disabled = SMTPMailer(cfg_disabled) + assert mailer_disabled.enabled is False + + def test_validate_disabled(self): + """Test validation when SMTP is disabled""" + cfg = SMTPSettings( + enabled=False, + host="", + port=25, + username=None, + password=None, + from_email=None, + from_name="App", + encryption="none", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP is disabled" in str(exc_info.value) + + def test_validate_missing_host(self): + """Test validation when host is missing""" + cfg = SMTPSettings( + enabled=True, + host="", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_HOST" in str(exc_info.value) + + def test_validate_missing_port(self): + """Test validation when port is missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=0, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_PORT" in str(exc_info.value) + + def test_validate_missing_username(self): + """Test validation when username is missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username=None, + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_USERNAME" in str(exc_info.value) + + def test_validate_missing_password(self): + """Test validation when password is missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password=None, + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_PASSWORD" in str(exc_info.value) + + def test_validate_missing_from_email(self): + """Test validation when from_email is missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email=None, + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_FROM_EMAIL" in str(exc_info.value) + + def test_validate_invalid_encryption(self): + """Test validation with invalid encryption""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="invalid", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_ENCRYPTION must be tls, ssl, or none" in str(exc_info.value) + + def test_validate_success(self): + """Test successful validation""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + # Should not raise exception + mailer._validate() + + @patch("extensions.smtp.smtplib.SMTP") + @patch("extensions.smtp.ssl.create_default_context") + def test_open_tls(self, mock_ssl_context, mock_smtp): + """Test opening SMTP connection with TLS""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_smtp.return_value = mock_client + mock_ssl_context.return_value = MagicMock() + + client = mailer._open() + + mock_smtp.assert_called_once_with("smtp.example.com", 587, timeout=30) + mock_client.ehlo.assert_called() + assert mock_client.ehlo.call_count == 2 # Called before and after starttls + mock_client.starttls.assert_called_once() + mock_client.login.assert_called_once_with("user", "pass") + assert client == mock_client + + @patch("extensions.smtp.smtplib.SMTP_SSL") + @patch("extensions.smtp.ssl.create_default_context") + def test_open_ssl(self, mock_ssl_context, mock_smtp_ssl): + """Test opening SMTP connection with SSL""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=465, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="ssl", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_smtp_ssl.return_value = mock_client + mock_ssl_context.return_value = MagicMock() + + client = mailer._open() + + mock_smtp_ssl.assert_called_once_with( + "smtp.example.com", 465, timeout=30, context=mock_ssl_context.return_value + ) + mock_client.ehlo.assert_called_once() + mock_client.login.assert_called_once_with("user", "pass") + assert client == mock_client + + @patch("extensions.smtp.smtplib.SMTP") + def test_open_none_encryption(self, mock_smtp): + """Test opening SMTP connection with no encryption""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=25, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="none", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_smtp.return_value = mock_client + + client = mailer._open() + + mock_smtp.assert_called_once_with("smtp.example.com", 25, timeout=30) + mock_client.ehlo.assert_called_once() + mock_client.starttls.assert_not_called() + mock_client.login.assert_called_once_with("user", "pass") + assert client == mock_client + + def test_open_no_auth_validation_error(self): + """Test that _open raises error when username/password are missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=25, + username=None, + password=None, + from_email="from@example.com", + from_name="App", + encryption="none", + ) + mailer = SMTPMailer(cfg) + + # _validate() is called in _open(), and it requires username and password + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._open() + assert "SMTP_USERNAME" in str(exc_info.value) or "SMTP_PASSWORD" in str(exc_info.value) + + @patch("extensions.smtp.smtplib.SMTP") + def test_open_connection_error(self, mock_smtp): + """Test handling connection errors""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_client.ehlo.side_effect = Exception("Connection failed") + mock_smtp.return_value = mock_client + + with pytest.raises(Exception) as exc_info: + mailer._open() + assert "Connection failed" in str(exc_info.value) + mock_client.quit.assert_called_once() + + @patch.object(SMTPMailer, "_open") + def test_send_text_plain(self, mock_open): + """Test sending plain text email""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="Test App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_open.return_value.__enter__.return_value = mock_client + + mailer.send_text( + to_emails=["to@example.com"], + subject="Test Subject", + body="Test body", + ) + + mock_open.assert_called_once() + mock_client.send_message.assert_called_once() + msg = mock_client.send_message.call_args[0][0] + assert msg["Subject"] == "Test Subject" + assert msg["From"] == "Test App " + assert msg["To"] == "to@example.com" + + @patch.object(SMTPMailer, "_open") + def test_send_text_html(self, mock_open): + """Test sending HTML email""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="Test App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_open.return_value.__enter__.return_value = mock_client + + mailer.send_text( + to_emails=["to1@example.com", "to2@example.com"], + subject="Test Subject", + body="Plain text", + html_body="HTML content", + ) + + mock_open.assert_called_once() + mock_client.send_message.assert_called_once() + msg = mock_client.send_message.call_args[0][0] + assert msg["Subject"] == "Test Subject" + assert msg["From"] == "Test App " + assert msg["To"] == "to1@example.com, to2@example.com" + assert msg.is_multipart() is True + + @patch.object(SMTPMailer, "_open") + def test_send_text_custom_from(self, mock_open): + """Test sending email with custom from address""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="default@example.com", + from_name="Default Name", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_open.return_value.__enter__.return_value = mock_client + + mailer.send_text( + to_emails=["to@example.com"], + subject="Test Subject", + body="Test body", + from_email="custom@example.com", + from_name="Custom Name", + ) + + msg = mock_client.send_message.call_args[0][0] + assert msg["From"] == "Custom Name " + + @patch.object(SMTPMailer, "_open") + def test_send_text_custom_timeout(self, mock_open): + """Test sending email with custom timeout""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_open.return_value.__enter__.return_value = mock_client + + mailer.send_text( + to_emails=["to@example.com"], + subject="Test Subject", + body="Test body", + timeout=60, + ) + + mock_open.assert_called_once_with(timeout=60) + + +class TestBuildSMTPSettings: + """Test build_smtp_settings function""" + + @patch("extensions.smtp.settings") + def test_build_smtp_settings_enabled(self, mock_settings): + """Test building SMTP settings when enabled""" + mock_settings.SMTP_ENABLE = True + mock_settings.SMTP_HOST = "smtp.example.com" + mock_settings.SMTP_PORT = 587 + mock_settings.SMTP_USERNAME = "user" + mock_settings.SMTP_PASSWORD = "pass" + mock_settings.SMTP_FROM_EMAIL = "from@example.com" + mock_settings.SMTP_FROM_NAME = "Test App" + mock_settings.SMTP_ENCRYPTION = "tls" + + settings = build_smtp_settings() + + assert settings.enabled is True + assert settings.host == "smtp.example.com" + assert settings.port == 587 + assert settings.username == "user" + assert settings.password == "pass" + assert settings.from_email == "from@example.com" + assert settings.from_name == "Test App" + assert settings.encryption == "tls" + + @patch("extensions.smtp.settings") + def test_build_smtp_settings_disabled(self, mock_settings): + """Test building SMTP settings when disabled""" + mock_settings.SMTP_ENABLE = False + mock_settings.SMTP_HOST = "" + mock_settings.SMTP_PORT = 25 + mock_settings.SMTP_USERNAME = None + mock_settings.SMTP_PASSWORD = None + mock_settings.SMTP_FROM_EMAIL = None + mock_settings.SMTP_FROM_NAME = "App" + mock_settings.SMTP_ENCRYPTION = "none" + + settings = build_smtp_settings() + + assert settings.enabled is False + + @patch("extensions.smtp.settings") + def test_build_smtp_settings_defaults(self, mock_settings): + """Test building SMTP settings with default values""" + mock_settings.SMTP_ENABLE = True + mock_settings.SMTP_HOST = " smtp.example.com " + mock_settings.SMTP_PORT = 587 + mock_settings.SMTP_USERNAME = " user " + mock_settings.SMTP_PASSWORD = "pass" + mock_settings.SMTP_FROM_EMAIL = None + mock_settings.SMTP_FROM_NAME = None + mock_settings.SMTP_ENCRYPTION = None + + settings = build_smtp_settings() + + assert settings.host == "smtp.example.com" # Stripped + assert settings.username == " user " # Not stripped, kept as is + assert settings.from_email is None + assert settings.from_name == "Docker Fullstack Template" # Default + assert settings.encryption == "tls" # Default + + +class TestGetMailer: + """Test get_mailer function""" + + def setup_method(self): + """Reset singleton before each test""" + extensions.smtp._SMTP_MAILER = None + + @patch("extensions.smtp.build_smtp_settings") + @patch("extensions.smtp.logger") + def test_get_mailer_first_call(self, mock_logger, mock_build): + """Test get_mailer on first call creates instance""" + mock_cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mock_build.return_value = mock_cfg + + mailer1 = get_mailer() + + assert mailer1 is not None + mock_build.assert_called_once() + mock_logger.info.assert_called_once() + + @patch("extensions.smtp.build_smtp_settings") + @patch("extensions.smtp.logger") + def test_get_mailer_singleton(self, mock_logger, mock_build): + """Test get_mailer returns same instance (singleton)""" + mock_cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mock_build.return_value = mock_cfg + + mailer1 = get_mailer() + mailer2 = get_mailer() + + assert mailer1 is mailer2 + assert mock_build.call_count == 1 # Only called once + + @patch("extensions.smtp.build_smtp_settings") + @patch("extensions.smtp.logger") + def test_get_mailer_disabled_logging(self, mock_logger, mock_build): + """Test get_mailer logs when SMTP is disabled""" + mock_cfg = SMTPSettings( + enabled=False, + host="", + port=25, + username=None, + password=None, + from_email=None, + from_name="App", + encryption="none", + ) + mock_build.return_value = mock_cfg + + get_mailer() + + mock_logger.info.assert_called_once_with("SMTP disabled") + + +class TestAddSMTP: + """Test add_smtp function""" + + def setup_method(self): + """Reset singleton before each test""" + extensions.smtp._SMTP_MAILER = None + + @patch("extensions.smtp.get_mailer") + def test_add_smtp_registers_to_app(self, mock_get_mailer): + """Test add_smtp registers mailer to app.state""" + app = FastAPI() + mock_mailer = MagicMock() + mock_get_mailer.return_value = mock_mailer + + add_smtp(app) + + mock_get_mailer.assert_called_once() + assert app.state.smtp == mock_mailer From 5dafb9af761e016cd0740eb6e9c1a74c9261cec1 Mon Sep 17 00:00:00 2001 From: CWei Date: Wed, 14 Jan 2026 00:33:18 +0800 Subject: [PATCH 04/12] refactor(auth): improve token refresh flow and permissions loading - Replace isGettingToken with tokenRefreshPromise and failedQueue - Ensure queued requests retry correctly after token refresh - Reset permissions load state to allow reload after password reset --- frontend/src/hooks/useAuth.jsx | 9 ++- frontend/src/services/api.service.js | 89 +++++++++++++++++++++------- 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/frontend/src/hooks/useAuth.jsx b/frontend/src/hooks/useAuth.jsx index 4a2c716..b2806db 100644 --- a/frontend/src/hooks/useAuth.jsx +++ b/frontend/src/hooks/useAuth.jsx @@ -3,7 +3,6 @@ import { useAuth as useAuthContext } from '@/contexts/authContext'; import authService from '@/services/auth.service'; import accountService from '@/services/account.service'; import rolesService from '@/services/roles.service'; -import i18n from '@/i18n'; import { debugError } from '@/lib/utils'; export const useAuth = () => { @@ -260,8 +259,12 @@ export const useAuth = () => { // Fetch profile with new token await fetchAndUpdateProfile(); - // Allow permissions to load + // Reset permissions load ref to allow fresh load + permissionsLoadRef.current = false; + + // Allow permissions to load and actively load them isResettingPasswordRef.current = false; + await loadPermissions(); setLoading(false); return { success: true, data: result }; @@ -277,7 +280,7 @@ export const useAuth = () => { setLoading(false); return { success: false, error: errorMessage, status }; } - }, [setLoading, clearError, loginSuccess, fetchAndUpdateProfile, setError]); + }, [setLoading, clearError, loginSuccess, fetchAndUpdateProfile, setError, loadPermissions]); // Validate password reset token const validateResetToken = useCallback(async (resetToken) => { diff --git a/frontend/src/services/api.service.js b/frontend/src/services/api.service.js index e624e18..f3eb4a1 100755 --- a/frontend/src/services/api.service.js +++ b/frontend/src/services/api.service.js @@ -20,7 +20,8 @@ const apiClient = axios.create({ let getTokenFunction = null; let logoutFunction = null; let getTokenFunctionFromContext = null; -let isGettingToken = false; +let tokenRefreshPromise = null; +let failedQueue = []; export const setTokenGetter = (getToken, logout, getTokenFromContext = null) => { getTokenFunction = getToken; @@ -56,7 +57,7 @@ apiClient.interceptors.request.use( } else { delete config.headers.Authorization; } - } catch (error) { + } catch { delete config.headers.Authorization; } @@ -97,7 +98,7 @@ apiClient.interceptors.response.use( const originalRequest = error.config; const retryOn401 = originalRequest.retryOn401 !== false; // Default to true for backward compatibility - if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.noToken && !isGettingToken && retryOn401) { + if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.noToken && retryOn401) { const errorMessage = error.response?.data?.message || ''; const isPasswordError = errorMessage.includes('Current password is incorrect') || errorMessage.includes('password is incorrect') || @@ -109,34 +110,80 @@ apiClient.interceptors.response.use( } else { // Token expired: try to refresh originalRequest._retry = true; - isGettingToken = true; - try { - if (getTokenFunctionFromContext) { - const tokenResult = await getTokenFunctionFromContext(false, true); - + // If token refresh is already in progress, add to queue and wait for it + if (tokenRefreshPromise) { + failedQueue.push(originalRequest); + + try { + const tokenResult = await tokenRefreshPromise; if (tokenResult?.success && tokenResult?.token) { originalRequest.headers.Authorization = `Bearer ${tokenResult.token}`; - const result = await apiClient(originalRequest); - isGettingToken = false; - return result; + return apiClient(originalRequest); } - } - - if (logoutFunction) { - // Clear local state only (skip API call to avoid loop) - logoutFunction(true); + } catch { + // Token refresh failed, error already handled in tokenRefreshPromise + // Reject the original request to trigger error handling error.config = error.config || {}; error.config.showErrorToast = false; + handleApiError(error, logoutFunction); + return Promise.reject(error); } - } catch (tokenError) { - if (logoutFunction) { - logoutFunction(true); + } else { + failedQueue.push(originalRequest); + + tokenRefreshPromise = (async () => { + try { + if (getTokenFunctionFromContext) { + const tokenResult = await getTokenFunctionFromContext(false, true); + + if (tokenResult?.success && tokenResult?.token) { + // Update all queued requests with new token + failedQueue.forEach(request => { + request.headers.Authorization = `Bearer ${tokenResult.token}`; + }); + + return tokenResult; + } + } + + // Token refresh failed + if (logoutFunction) { + logoutFunction(true); + error.config = error.config || {}; + error.config.showErrorToast = false; + } + + throw new Error('Token refresh failed'); + } catch (tokenError) { + if (logoutFunction) { + logoutFunction(true); + error.config = error.config || {}; + error.config.showErrorToast = false; + } + + throw tokenError; + } finally { + tokenRefreshPromise = null; + failedQueue = []; + } + })(); + + try { + const tokenResult = await tokenRefreshPromise; + if (tokenResult?.success && tokenResult?.token) { + // Retry this request with new token + originalRequest.headers.Authorization = `Bearer ${tokenResult.token}`; + return apiClient(originalRequest); + } + } catch { + // Token refresh failed, error already handled in tokenRefreshPromise + // Reject the original request to trigger error handling error.config = error.config || {}; error.config.showErrorToast = false; + handleApiError(error, logoutFunction); + return Promise.reject(error); } - } finally { - isGettingToken = false; } } } From 30ff32395b93e43a6c80510aa1a9827fa5084664 Mon Sep 17 00:00:00 2001 From: CWei Date: Wed, 14 Jan 2026 09:47:06 +0800 Subject: [PATCH 05/12] fix(auth): correct password reset token handling and reset flow - Invalidate previous unused reset tokens before issuing a new one - Fix reset password URL to use the correct authentication path - Remove redundant password reset requirement check during reset --- backend/api/auth/services.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/backend/api/auth/services.py b/backend/api/auth/services.py index 65b7816..34dd9ea 100644 --- a/backend/api/auth/services.py +++ b/backend/api/auth/services.py @@ -1,7 +1,7 @@ import ast import redis from typing import Optional -from sqlalchemy import select +from sqlalchemy import select, update from urllib.parse import quote from models.users import Users from core.config import settings @@ -262,9 +262,6 @@ async def reset_password( if not user: raise NotFoundException("User not found") - if not user.password_reset_required: - raise AuthenticationException("Password reset not required for this user") - user.hash_password = await hash_password(new_password) user.password_reset_required = False @@ -359,7 +356,7 @@ async def forgot_password( reset_url = ( f"http{'s' if settings.SSL_ENABLE else ''}://" f"{settings.HOSTNAME}:{settings.FRONTEND_PORT}" - f"/reset-password?token={quote(reset_token, safe='')}" + f"/auth/reset-password?token={quote(reset_token, safe='')}" ) # Render email template with user name and app name @@ -545,10 +542,21 @@ async def _request_password_reset_email( ) -> dict: """ Create a password reset token record for the user and return token metadata. + Invalidates all previous unused tokens for this user before creating a new one. """ now = datetime.now().astimezone() expires_at = now + timedelta(minutes=settings.PASSWORD_RESET_TOKEN_EXPIRE_MINUTES) + # Invalidate all previous unused tokens for this user + await db.execute( + update(PasswordResetTokens) + .where( + PasswordResetTokens.user_id == user.id, + PasswordResetTokens.is_used == False + ) + .values(is_used=True) + ) + reset_token = await create_password_reset_token(user.id, user.email) reset_token_record = PasswordResetTokens( user_id=user.id, From 79d796ab575dafdbf2c988d5187d88ba1319bcb5 Mon Sep 17 00:00:00 2001 From: CWei Date: Wed, 14 Jan 2026 10:13:30 +0800 Subject: [PATCH 06/12] feat(auth): add forgot password flow with SMTP integration - Implement forgot password form with email validation and cooldown handling - Add forgot password and reset password routes to auth flow - Add SMTP configuration to Docker and environment settings - Enhance localization for forgot password messages and actions - Update login and register forms to link to forgot password --- docker-compose-prod.yaml | 2 + docker-compose.yaml | 2 + .../components/auth/forgot-password-form.jsx | 373 ++++++++++++++++++ frontend/src/components/auth/login-form.jsx | 18 +- .../src/components/auth/register-form.jsx | 2 +- .../components/auth/reset-password-form.jsx | 2 +- frontend/src/components/core/layout.jsx | 7 +- .../src/components/core/protected-route.jsx | 8 +- frontend/src/config/env.config.js | 2 + frontend/src/i18n/locales/en.json | 33 +- frontend/src/i18n/locales/zh-TW.json | 33 +- frontend/src/pages/Auth/index.jsx | 59 ++- frontend/src/router/routes.js | 18 +- frontend/src/services/auth.service.js | 30 ++ 14 files changed, 561 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/auth/forgot-password-form.jsx diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 9797c7c..70206e3 100755 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -67,6 +67,8 @@ services: # API settings - VITE_API_HOST=${API_HOST} - VITE_API_PORT=${API_PORT} + # SMTP settings + - VITE_SMTP_ENABLE=${SMTP_ENABLE} command: ["sh", "./init-prod.sh"] depends_on: backend: diff --git a/docker-compose.yaml b/docker-compose.yaml index 0f0393c..2cd06bc 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -66,6 +66,8 @@ services: # API settings - VITE_API_HOST=${API_HOST:-localhost} - VITE_API_PORT=${API_PORT:-5000} + # SMTP settings + - VITE_SMTP_ENABLE=${SMTP_ENABLE:-false} command: ["sh", "./init-dev.sh"] depends_on: backend: diff --git a/frontend/src/components/auth/forgot-password-form.jsx b/frontend/src/components/auth/forgot-password-form.jsx new file mode 100644 index 0000000..25437b5 --- /dev/null +++ b/frontend/src/components/auth/forgot-password-form.jsx @@ -0,0 +1,373 @@ +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Link, useLocation } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { cn, debugWarn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { MailCheck } from 'lucide-react' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { authService } from '@/services/auth.service' +import { debugError } from '@/lib/utils' +import { Spinner } from '@/components/ui/spinner' +import { useIsMobile } from '@/hooks/useMobile' + +const SubmitButton = React.memo(({ onSubmit, t, className, isSubmitting }) => { + return ( + + ) +}) + +SubmitButton.displayName = 'SubmitButton' + +export const ForgotPasswordForm = ({ className, onStateChange, ...props }) => { + const location = useLocation() + const { t } = useTranslation() + const isMobile = useIsMobile() + const [isSubmitting, setIsSubmitting] = useState(false) + const [isResending, setIsResending] = useState(false) + const [cooldownSeconds, setCooldownSeconds] = useState(0) + const [isLoadingCooldown, setIsLoadingCooldown] = useState(false) + const [showConfirmation, setShowConfirmation] = useState(false) + const [email, setEmail] = useState('') + const intervalRef = useRef(null) + + const fetchCooldown = useCallback(async (emailToCheck) => { + if (!emailToCheck) return + + setIsLoadingCooldown(true) + try { + const result = await authService.getPasswordResetCooldown(emailToCheck) + if (result.status === 'success' && result.data?.data) { + setCooldownSeconds(result.data.data.cooldown_seconds || 0) + } else if (result.status === 'success' && result.data?.cooldown_seconds !== undefined) { + setCooldownSeconds(result.data.cooldown_seconds || 0) + } + } catch (error) { + debugError('Failed to fetch cooldown:', error) + } finally { + setIsLoadingCooldown(false) + } + }, []) + + // Initialize email from location state (only when coming from other pages) + useEffect(() => { + const stateEmail = location.state?.email + + if (stateEmail) { + setEmail(stateEmail) + setShowConfirmation(true) + // Notify parent component about confirmation state + if (onStateChange) { + onStateChange(true) + } + // Fetch cooldown status + fetchCooldown(stateEmail) + } + }, [fetchCooldown, location.state?.email, onStateChange]) + + // Countdown timer - only countdown, stop at 0 + useEffect(() => { + if (cooldownSeconds > 0) { + intervalRef.current = setInterval(() => { + setCooldownSeconds((prev) => { + if (prev <= 1) { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + return 0 + } + return prev - 1 + }) + }, 1000) + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [cooldownSeconds]) + + const formSchema = useMemo(() => { + return z.object({ + email: z + .string() + .min(1, t('pages.auth.forgotPassword.fields.email.validation.required', { defaultValue: 'Please enter your email' })) + .refine((val) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(val) + }, { + message: t('pages.auth.forgotPassword.fields.email.validation.invalid', { defaultValue: 'Please enter a valid email format' }), + }), + }) + }, [t]) + + const stableDefaultValues = useMemo(() => ({ + email: email || '', + }), [email]) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: stableDefaultValues, + }) + + // Update form when email changes + useEffect(() => { + if (email && form.getValues('email') !== email) { + form.setValue('email', email, { shouldValidate: false }) + } + }, [email, form]) + + useEffect(() => { + try { + form.clearErrors() + const newResolver = zodResolver(formSchema) + + if (form._options && typeof form._options === 'object' && 'resolver' in form._options) { + form._options.resolver = newResolver + } + + if ('_resolver' in form && form._resolver !== undefined) { + form._resolver = newResolver + } + + if (form.formState.isSubmitted) { + setTimeout(() => { + form.trigger() + }, 0) + } + } catch (error) { + debugWarn('Failed to update form resolver:', error) + form.clearErrors() + if (form.formState.isSubmitted) { + setTimeout(() => { + form.trigger() + }, 0) + } + } + }, [formSchema, form]) + + const formMethodsRef = useRef(form) + useEffect(() => { + formMethodsRef.current = form + }, [form]) + + const handleSubmit = useCallback(async (formValues) => { + setIsSubmitting(true) + + try { + await authService.forgotPassword(formValues.email, { + showErrorToast: true, + showSuccessToast: true, + }) + + // Success - switch to confirmation mode + const submittedEmail = formValues.email + setEmail(submittedEmail) + setShowConfirmation(true) + + // Notify parent component about confirmation state + if (onStateChange) { + onStateChange(true) + } + + // Fetch cooldown status + await fetchCooldown(submittedEmail) + + setIsSubmitting(false) + } catch (error) { + debugError('Forgot password error:', error) + // If error is due to cooldown, switch to confirmation mode and fetch cooldown + if (error.response?.status === 400 && formValues.email) { + const submittedEmail = formValues.email + setEmail(submittedEmail) + setShowConfirmation(true) + + // Notify parent component about confirmation state + if (onStateChange) { + onStateChange(true) + } + + await fetchCooldown(submittedEmail) + } + setIsSubmitting(false) + } + }, [fetchCooldown, onStateChange]) + + const handleResend = useCallback(async () => { + if (cooldownSeconds > 0 || isResending || !email) { + return + } + + setIsResending(true) + + try { + await authService.forgotPassword(email, { + showErrorToast: true, + showSuccessToast: true, + }) + + // Fetch new cooldown after successful send + await fetchCooldown(email) + } catch (error) { + debugError('Resend email error:', error) + // If error is due to cooldown, fetch the current cooldown status + if (error.response?.status === 400) { + await fetchCooldown(email) + } + } finally { + setIsResending(false) + } + }, [email, cooldownSeconds, isResending, fetchCooldown]) + + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + const onSubmitHandler = useCallback((e) => { + e?.preventDefault?.() + formMethodsRef.current.handleSubmit(handleSubmit)() + }, [handleSubmit]) + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Enter' && !isSubmitting && !showConfirmation) { + e.preventDefault() + onSubmitHandler(e) + } + }, [onSubmitHandler, isSubmitting, showConfirmation]) + + const emailInputRef = useRef(null) + useEffect(() => { + if (!isMobile && emailInputRef.current && !showConfirmation) { + emailInputRef.current.focus() + } + }, [isMobile, showConfirmation]) + + // Confirmation view + if (showConfirmation && email) { + return ( +
+
+
+ +
+ +
+

+ {t('pages.auth.forgotPasswordConfirmation.description', { + defaultValue: 'We\'ve sent a password reset link to {{email}}', + email + })} +

+
+
+ +
+ ) + } + + // Input form view + return ( +
+ + + ( + + {t('pages.auth.forgotPassword.fields.email.label', { defaultValue: 'Email' })} + + { + emailInputRef.current = e + field.ref(e) + }} + disabled={isSubmitting} + /> + + + + )} + /> + + + +
+ + {t('pages.auth.forgotPassword.actions.backToLogin', { defaultValue: 'Back to Login' })} + +
+ + + ) +} + +export default ForgotPasswordForm diff --git a/frontend/src/components/auth/login-form.jsx b/frontend/src/components/auth/login-form.jsx index e116d06..71dea79 100644 --- a/frontend/src/components/auth/login-form.jsx +++ b/frontend/src/components/auth/login-form.jsx @@ -1,4 +1,5 @@ import React, { useState, useCallback, useMemo, useEffect, useLayoutEffect, useRef } from 'react' +import ENV from '@/config/env.config' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -223,7 +224,7 @@ export const LoginForm = ({ className, redirectTo = '/', ...props }) => { setEmailValueRef.current('') formMethodsRef.current.reset({ email: '', password: '' }, { keepValues: false }) setTimeout(() => { - navigateRef.current(`/reset-password?token=${encodeURIComponent(result.resetToken)}`, { replace: true }) + navigateRef.current(`/auth/reset-password?token=${encodeURIComponent(result.resetToken)}`, { replace: true }) }, 0) } else { const emailToKeep = formValues.email || emailRef.current || emailPersistRef.current @@ -314,7 +315,18 @@ export const LoginForm = ({ className, redirectTo = '/', ...props }) => { name="password" render={({ field }) => ( - {t('pages.auth.login.fields.password.label', { defaultValue: 'Password' })} +
+ {t('pages.auth.login.fields.password.label', { defaultValue: 'Password' })} + {ENV.SMTP_ENABLE && ( + + {t('pages.auth.login.links.forgotPassword', { defaultValue: 'Forgot password?' })} + + )} +
{
{t('pages.auth.login.links.newUser', { defaultValue: 'New user? ' })} diff --git a/frontend/src/components/auth/register-form.jsx b/frontend/src/components/auth/register-form.jsx index 7a5700d..de75a82 100644 --- a/frontend/src/components/auth/register-form.jsx +++ b/frontend/src/components/auth/register-form.jsx @@ -333,7 +333,7 @@ export const RegisterForm = ({ className, redirectTo = '/', ...props }) => {
{t('pages.auth.register.links.existingUser', { defaultValue: 'Already have an account? ' })} diff --git a/frontend/src/components/auth/reset-password-form.jsx b/frontend/src/components/auth/reset-password-form.jsx index 986a353..33449e7 100644 --- a/frontend/src/components/auth/reset-password-form.jsx +++ b/frontend/src/components/auth/reset-password-form.jsx @@ -252,7 +252,7 @@ export const ResetPasswordForm = ({ className, token, ...props }) => {
+
+
+ ) + } + + if (verificationStatus === 'pending' && email) { + return ( +
+
+

+ {t('pages.auth.verifyEmail.messages.pending', { defaultValue: 'A verification email has been sent to your email address. Please check your inbox and click the verification link.' })} +

+
+ +
+ +
+ +
+ +
+
+ ) + } + + if (verificationStatus === 'error') { + return ( +
+
+

+ {errorMessage || t('pages.auth.verifyEmail.messages.invalidToken', { defaultValue: 'The verification link has expired or is invalid. Please request a new one.' })} +

+
+ + {email && ( +
+ +
+ )} + +
+ +
+
+ ) + } + + return null +} + +export default VerifyEmailForm \ No newline at end of file diff --git a/frontend/src/components/core/protected-route.jsx b/frontend/src/components/core/protected-route.jsx index c900de5..ce9ccc1 100644 --- a/frontend/src/components/core/protected-route.jsx +++ b/frontend/src/components/core/protected-route.jsx @@ -22,8 +22,10 @@ export const ProtectedRoute = ({ children, requireAuth = true, permissions = nul } }, [isLoading]); - // Handle reset password page - logout authenticated users - const isResetPasswordPage = location.pathname === '/reset-password'; + const isResetPasswordPage = location.pathname === '/auth/reset-password'; + const isVerificationPage = location.pathname === '/auth/verify-email'; + const isForgotPasswordPage = location.pathname === '/auth/forgot-password'; + const isAuthFlowPage = isResetPasswordPage || isVerificationPage || isForgotPasswordPage; // Auto logout authenticated users visiting reset password page (except during password reset) useEffect(() => { @@ -34,11 +36,11 @@ export const ProtectedRoute = ({ children, requireAuth = true, permissions = nul // Redirect to login when user becomes unauthenticated useEffect(() => { - if (wasAuthenticatedRef.current && !isAuthenticated && location.pathname !== '/auth/login' && !isResetPasswordPage) { + if (wasAuthenticatedRef.current && !isAuthenticated && location.pathname !== '/auth/login' && !isAuthFlowPage) { navigate('/auth/login', { state: { from: location }, replace: true }); } wasAuthenticatedRef.current = isAuthenticated; - }, [isAuthenticated, location, navigate, isResetPasswordPage]); + }, [isAuthenticated, location, navigate, isAuthFlowPage]); // Check if user has required permissions const hasPermission = React.useMemo(() => { @@ -81,8 +83,9 @@ export const ProtectedRoute = ({ children, requireAuth = true, permissions = nul return ; } - // Redirect authenticated users away from login/register pages - if (!requireAuth && isAuthenticated && (location.pathname === "/auth/login" || location.pathname === "/auth/register")) { + // Redirect authenticated users away from login/register pages (but not verify-email, reset-password, or forgot-password) + const isAuthPage = location.pathname === "/auth/login" || location.pathname === "/auth/register"; + if (!requireAuth && isAuthenticated && isAuthPage && !isAuthFlowPage) { const from = location.state?.from?.pathname; return ; } diff --git a/frontend/src/components/profile/update-profile-form.jsx b/frontend/src/components/profile/update-profile-form.jsx index d890d47..7f3c618 100644 --- a/frontend/src/components/profile/update-profile-form.jsx +++ b/frontend/src/components/profile/update-profile-form.jsx @@ -3,7 +3,6 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; import { cn, debugError } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { @@ -17,12 +16,16 @@ import { import { Input } from '@/components/ui/input'; import { Spinner } from '@/components/ui/spinner'; import { useAuth } from '@/hooks/useAuth'; -import { useIsMobile } from '@/hooks/useMobile'; -export function UpdateProfileForm({ user, onSuccess, onClose, onSubmittingChange }) { +export function UpdateProfileForm({ + user, + onSuccess, + onClose, + onSubmittingChange, + onRequiresEmailVerification, +}) { const { t } = useTranslation(); const { updateUserProfile, isLoading } = useAuth(); - const isMobile = useIsMobile(); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { @@ -80,29 +83,33 @@ export function UpdateProfileForm({ user, onSuccess, onClose, onSubmittingChange const handleSubmit = useCallback(async (formValues) => { setIsSubmitting(true); - + try { const result = await updateUserProfile({ first_name: formValues.first_name, last_name: formValues.last_name, email: formValues.email, phone: formValues.phone, - }); - - if (result.success) { + }, {returnStatus: true}); + + if (result?.status === 'success') { if (onSuccess) { await onSuccess(result.data); } - } else { - toast.error(result.error || t('common.status.error')); + } else if (result?.requiresEmailVerification) { + if (onRequiresEmailVerification) { + await onRequiresEmailVerification({ + email: result.email || formValues.email, + profile: result.data, + }); + } } } catch (error) { debugError('Update profile error:', error); - toast.error(error.message || t('common.status.error')); } finally { setIsSubmitting(false); } - }, [updateUserProfile, onSuccess]); + }, [updateUserProfile, onSuccess, onRequiresEmailVerification]); return (
diff --git a/frontend/src/hooks/useAuth.jsx b/frontend/src/hooks/useAuth.jsx index b2806db..6cff73c 100644 --- a/frontend/src/hooks/useAuth.jsx +++ b/frontend/src/hooks/useAuth.jsx @@ -98,15 +98,51 @@ export const useAuth = () => { const result = await authService.login(credentials); - // Check if response indicates password reset is required (202 status) - if (result?._statusCode === 202 || result?.reset_token) { - const resetToken = result?.reset_token || result?.data?.reset_token; + // Check if response indicates action is required (202 status) + if (result?._statusCode === 202) { + const actionData = result; + const actionType = actionData?.action_type; + const resetToken = actionData?.token; setLoading(false); + + if (!actionType) { + if (resetToken) { + return { + success: false, + requiresPasswordReset: true, + resetToken: resetToken, + data: result + }; + } else { + return { + success: false, + requiresEmailVerification: true, + data: result + }; + } + } + + if (actionType === 'password_reset' || resetToken) { + return { + success: false, + requiresPasswordReset: true, + resetToken: resetToken, + data: result + }; + } else if (actionType === 'email_verification') { + return { + success: false, + requiresEmailVerification: true, + data: result + }; + } + + // Fallback for unknown action types return { success: false, - requiresPasswordReset: true, - resetToken: resetToken, + requiresAction: true, + actionType: actionType, data: result }; } @@ -124,14 +160,34 @@ export const useAuth = () => { throw new Error('Invalid login response format'); } } catch (error) { - // Check if error response indicates password reset is required (202 status) + // Check if error response indicates action is required (202 status) if (error.response?.status === 202) { - const resetToken = error.response?.data?.data?.reset_token || error.response?.data?.reset_token; + // For 202 status, data is in error.response.data.data + const actionData = error.response?.data?.data || error.response?.data; + const actionType = actionData?.action_type; + const resetToken = actionData?.token; + setLoading(false); + + if (actionType === 'password_reset' || resetToken) { + return { + success: false, + requiresPasswordReset: true, + resetToken: resetToken, + data: error.response?.data + }; + } else if (actionType === 'email_verification') { + return { + success: false, + requiresEmailVerification: true, + data: error.response?.data + }; + } + return { success: false, - requiresPasswordReset: true, - resetToken: resetToken, + requiresAction: true, + actionType: actionType, data: error.response?.data }; } @@ -153,6 +209,17 @@ export const useAuth = () => { const result = await authService.register(userData); + // Check if response indicates action is required (202 status) + if (result?._statusCode === 202) { + // For 202 status, email verification is required + setLoading(false); + return { + success: false, + requiresEmailVerification: true, + data: result + }; + } + if (result?.user && result?.access_token) { const { user, access_token: token } = result; @@ -164,6 +231,17 @@ export const useAuth = () => { setLoading(false); return { success: true, data: result }; } catch (error) { + // Check if error response indicates action is required (202 status) + if (error.response?.status === 202) { + // For 202 status, email verification is required + setLoading(false); + return { + success: false, + requiresEmailVerification: true, + data: error.response?.data + }; + } + const errorMessage = error.response?.data?.message || error.message || 'Registration failed'; setError(errorMessage); setLoading(false); @@ -312,14 +390,49 @@ export const useAuth = () => { try { clearError(); - const result = await accountService.updateProfile(userData, { ...config }); - setUser(result); - - return { success: true, data: result }; + const { returnStatus, ...restConfig } = config || {}; + const result = await accountService.updateProfile(userData, { ...restConfig, returnStatus }); + const responseData = returnStatus ? result?.data : result; + + if (returnStatus && result?.status === 'error') { + const errorMessage = result?.error?.message || 'Failed to update user profile'; + setError(errorMessage); + return { + ...result, + success: false, + error: result?.error || { message: errorMessage }, + }; + } + + if (responseData?._statusCode === 202) { + const { _statusCode: _ignored, ...profileData } = responseData; + if (profileData) { + setUser(profileData); + } + const baseResult = { + success: false, + requiresEmailVerification: true, + data: profileData, + email: userData?.email, + }; + return returnStatus + ? { ...result, ...baseResult, data: profileData } + : baseResult; + } + + if (responseData) { + setUser(responseData); + } + + return returnStatus + ? { ...result, success: true, data: responseData } + : { success: true, data: responseData }; } catch (error) { const errorMessage = error.response?.data?.message || error.message || 'Failed to update user profile'; setError(errorMessage); - return { success: false, error: errorMessage }; + return config?.returnStatus + ? { data: null, status: 'error', error: { message: errorMessage }, success: false } + : { success: false, error: errorMessage }; } }, [clearError, setUser, setError]); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index de205d8..e752be1 100755 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -338,6 +338,23 @@ "resend": "Resend email", "resendCooldown": "Resend email ({{time}})" } + }, + "verifyEmail": { + "messages": { + "noToken": "No verification token provided", + "success": "Email verified successfully", + "pending": "A verification email has been sent to your email address. Please check your inbox and click the verification link.", + "invalidToken": "The verification link has expired or is invalid. Please request a new one.", + "verificationFailed": "Email verification failed", + "userNotFound": "User not found", + "emailExists": "Email already exists" + }, + "actions": { + "backToHome": "Go to Home", + "resend": "Resend Verification Email", + "resendCooldown": "Resend Email ({{cooldownSeconds}}s)", + "backToLogin": "Back to Login" + } } }, "profile": { @@ -398,7 +415,9 @@ "submitting": "Updating..." }, "messages": { - "success": "Profile updated successfully" + "success": "Profile updated successfully", + "emailVerificationRequired": "Email verification required", + "emailAlreadyExists": "Email already exists" } }, "security": { diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index a9a77b2..265dacf 100755 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -337,6 +337,23 @@ "resend": "重新寄送", "resendCooldown": "重新寄送 ({{time}})" } + }, + "verifyEmail": { + "messages": { + "noToken": "未提供驗證令牌", + "success": "電子信箱驗證成功", + "pending": "已寄送驗證郵件至您的電子信箱,請查看信箱並點擊驗證連結。", + "invalidToken": "驗證連結已失效或無效,請重新申請。", + "verificationFailed": "電子信箱驗證失敗", + "userNotFound": "使用者不存在", + "emailExists": "此電子信箱已被使用" + }, + "actions": { + "backToHome": "回到首頁", + "resend": "重新寄送驗證郵件", + "resendCooldown": "重新寄送 ({{cooldownSeconds}}秒)", + "backToLogin": "返回登入" + } } }, "profile": { @@ -393,7 +410,9 @@ } }, "messages": { - "success": "個人資料更新成功" + "success": "個人資料更新成功", + "emailVerificationRequired": "需要進行電子信箱驗證", + "emailAlreadyExists": "此電子信箱已被使用" } }, "security": { diff --git a/frontend/src/pages/Auth/index.jsx b/frontend/src/pages/Auth/index.jsx index 739458a..622ebfa 100644 --- a/frontend/src/pages/Auth/index.jsx +++ b/frontend/src/pages/Auth/index.jsx @@ -15,6 +15,7 @@ import { LoginForm } from '@/components/auth/login-form' import { RegisterForm } from '@/components/auth/register-form' import { ResetPasswordForm } from '@/components/auth/reset-password-form' import { ForgotPasswordForm } from '@/components/auth/forgot-password-form' +import { VerifyEmailForm } from '@/components/auth/verify-email-form' const arePropsEqual = (prevProps, nextProps) => { const prevPath = prevProps.location?.pathname @@ -23,12 +24,15 @@ const arePropsEqual = (prevProps, nextProps) => { const nextSearch = nextProps.location?.search const prevState = prevProps.location?.state?.from?.pathname const nextState = nextProps.location?.state?.from?.pathname + const prevStateEmail = prevProps.location?.state?.email + const nextStateEmail = nextProps.location?.state?.email const prevLanguage = prevProps.language const nextLanguage = nextProps.language const areEqual = prevPath === nextPath && prevSearch === nextSearch && prevState === nextState && + prevStateEmail === nextStateEmail && prevLanguage === nextLanguage return areEqual @@ -47,7 +51,14 @@ const AuthComponent = React.memo(({ t, location, language }) => { return new URLSearchParams(location.search).get('token') }, [location.search]) + const verifyToken = React.useMemo(() => { + return new URLSearchParams(location.search).get('token') + }, [location.search]) + const activeTab = React.useMemo(() => { + if (location.pathname === '/auth/verify-email') { + return 'verify-email' + } if (location.pathname === '/auth/reset-password') { return 'reset-password' } @@ -111,7 +122,9 @@ const AuthComponent = React.memo(({ t, location, language }) => { - {activeTab === 'reset-password' + {activeTab === 'verify-email' + ? t("pages.auth.verifyEmail.title", { defaultValue: "Verify Email" }) + : activeTab === 'reset-password' ? t("pages.auth.resetPassword.title", { defaultValue: "Reset Password" }) : activeTab === 'forgot-password' ? t("pages.auth.forgotPassword.title", { defaultValue: "Forgot Password" }) @@ -132,9 +145,11 @@ const AuthComponent = React.memo(({ t, location, language }) => { - {activeTab === 'reset-password' ? ( + {activeTab === 'verify-email' ? ( + + ) : activeTab === 'reset-password' ? ( - ) : activeTab === 'forgot-password' ? ( + ) : activeTab === 'forgot-password' ? ( ) : activeTab === 'register' ? ( diff --git a/frontend/src/pages/Profile/index.jsx b/frontend/src/pages/Profile/index.jsx index 93277c1..00e4dd6 100644 --- a/frontend/src/pages/Profile/index.jsx +++ b/frontend/src/pages/Profile/index.jsx @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Edit, Lock, AlertTriangle } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -14,6 +15,7 @@ import { ChangePasswordForm } from '@/components/profile/change-password-form'; export function Profile() { const { t } = useTranslation(); + const navigate = useNavigate(); const isMobile = useIsMobile(); const { user, isLoading, loadProfile } = useAuth(); @@ -27,6 +29,14 @@ export function Profile() { await loadProfile(); }, [t, loadProfile]); + const handleEmailVerificationRequired = useCallback(({ email }) => { + setIsEditingProfile(false); + navigate('/auth/verify-email', { + state: { email }, + replace: false, + }); + }, [navigate]); + const handlePasswordChange = useCallback(async () => { setIsEditingSecurity(false); await loadProfile(); @@ -106,6 +116,7 @@ export function Profile() { onSuccess={handleProfileUpdate} onClose={cancelProfileEdit} onSubmittingChange={setIsSubmittingProfile} + onRequiresEmailVerification={handleEmailVerificationRequired} /> )} @@ -195,6 +206,7 @@ export function Profile() { onSuccess={handleProfileUpdate} onClose={cancelProfileEdit} onSubmittingChange={setIsSubmittingProfile} + onRequiresEmailVerification={handleEmailVerificationRequired} /> )} diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js index c24b1f6..e5f018c 100755 --- a/frontend/src/router/routes.js +++ b/frontend/src/router/routes.js @@ -92,6 +92,16 @@ export const routes = [ showInSidebar: false, }, }] : []), + // Only include verify-email route when SMTP is enabled + ...(ENV.SMTP_ENABLE ? [{ + path: "/auth/verify-email", + element: "Auth", + requireAuth: false, + permissions: [], + sidebar: { + showInSidebar: false, + }, + }] : []), { path: "/profile", element: "Profile", diff --git a/frontend/src/services/account.service.js b/frontend/src/services/account.service.js index 8f62a3a..1466b3b 100644 --- a/frontend/src/services/account.service.js +++ b/frontend/src/services/account.service.js @@ -24,6 +24,8 @@ export const accountService = { showSuccessToast: true, messageMap: { success: i18n.t('pages.profile.profile.messages.success'), + 202: i18n.t('pages.profile.profile.messages.emailVerificationRequired'), + 409: i18n.t('pages.profile.profile.messages.emailAlreadyExists'), ...config.messageMap, }, ...config, diff --git a/frontend/src/services/api.service.js b/frontend/src/services/api.service.js index f3eb4a1..c78ec54 100755 --- a/frontend/src/services/api.service.js +++ b/frontend/src/services/api.service.js @@ -75,11 +75,19 @@ apiClient.interceptors.response.use( const messageMap = config?.messageMap; const successMessage = config?.successMessage || (messageMap && messageMap.success); - // 202 indicates password reset is required + // 202 indicates password reset or email verification is required if (response.status === 202) { - // Don't show success toast for 202, as it requires special handling if (response.data) { - const responseData = response.data.data !== undefined ? response.data.data : response.data; + let responseData; + if (response.data.data !== undefined && response.data.data !== null) { + responseData = response.data.data; + } else if (response.data.action_type !== undefined) { + responseData = response.data; + } else { + const { code: _code, message: _message, ...rest } = response.data; + responseData = rest; + } + return { ...responseData, _statusCode: 202 }; } return { _statusCode: 202 }; diff --git a/frontend/src/services/auth.service.js b/frontend/src/services/auth.service.js index 52c17ed..2b0d552 100644 --- a/frontend/src/services/auth.service.js +++ b/frontend/src/services/auth.service.js @@ -119,6 +119,53 @@ export const authService = { returnStatus: true, ...config, }), + + // Verify email address + verifyEmail: (verificationToken, config = {}) => + apiService.get(`${BASE_AUTH}/verify-email`, {}, { + headers: { Authorization: `Bearer ${verificationToken}` }, + noToken: true, + retryOn401: false, + showErrorToast: true, + showSuccessToast: true, + messageMap: { + success: i18n.t('pages.auth.verifyEmail.messages.success', 'Email verified successfully'), + 401: i18n.t('pages.auth.verifyEmail.messages.invalidToken', 'The verification link has expired or is invalid. Please request a new one.'), + 404: i18n.t('pages.auth.verifyEmail.messages.userNotFound', 'User not found'), + 409: i18n.t('pages.auth.verifyEmail.messages.emailExists', 'Email already exists'), + ...config.messageMap, + }, + ...config, + }), + + // Resend verification email + resendVerification: (email, config = {}) => + apiService.post(`${BASE_AUTH}/resend-verification`, { email }, { + noToken: true, + retryOn401: false, + showErrorToast: true, + showSuccessToast: true, + messageMap: { + success: i18n.t('pages.auth.verifyEmail.messages.emailSent', 'Verification email sent'), + 400: i18n.t('pages.auth.verifyEmail.messages.cooldown', 'Please wait before requesting another verification email'), + 403: i18n.t('pages.auth.verifyEmail.messages.accountDisabled', 'Account is disabled'), + 404: i18n.t('pages.auth.verifyEmail.messages.emailNotRegistered', 'This email is not registered'), + 503: i18n.t('pages.auth.verifyEmail.messages.smtpDisabled', 'SMTP is disabled'), + ...config.messageMap, + }, + ...config, + }), + + // Get email verification cooldown status + getEmailVerificationCooldown: (email, config = {}) => + apiService.get(`${BASE_AUTH}/resend-verification/cooldown`, { email }, { + noToken: true, + retryOn401: false, + showErrorToast: false, + showSuccessToast: false, + returnStatus: true, + ...config, + }), }; export default authService; \ No newline at end of file From ab26a419e3d8e685f07c172c17bf4bb1424b79a6 Mon Sep 17 00:00:00 2001 From: CWei Date: Thu, 22 Jan 2026 17:53:37 +0800 Subject: [PATCH 12/12] fix(config): provide default SMTP configuration values - Set default SMTP host, port, credentials, sender info, and encryption - Ensure email-related flows work without manual SMTP setup --- backend/core/config.py | 14 +++++++------- backend/tests/api/auth/test_service.py | 15 ++++++++------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/backend/core/config.py b/backend/core/config.py index b045b74..01039d0 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -64,13 +64,13 @@ class Settings(BaseSettings): # SMTP setting SMTP_ENABLE: bool = False - SMTP_HOST: str - SMTP_PORT: int - SMTP_USERNAME: str - SMTP_PASSWORD: str - SMTP_FROM_EMAIL: str - SMTP_FROM_NAME: str - SMTP_ENCRYPTION: str + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USERNAME: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_EMAIL: str = "" + SMTP_FROM_NAME: str = "Docker Fullstack Template" + SMTP_ENCRYPTION: str = "tls" # Default admin user settings DEFAULT_ADMIN_EMAIL: str = "admin@example.com" diff --git a/backend/tests/api/auth/test_service.py b/backend/tests/api/auth/test_service.py index 18e687b..9b5b478 100644 --- a/backend/tests/api/auth/test_service.py +++ b/backend/tests/api/auth/test_service.py @@ -1295,11 +1295,12 @@ async def test_resend_verification_email_cooldown_active( mock_mailer = MagicMock(spec=SMTPMailer) mock_mailer.enabled = True - with pytest.raises(ValidationException) as exc_info: - await resend_verification_email( - test_db_session, - test_user.email, - mock_mailer, - mock_redis, - ) + with patch.object(settings, "SMTP_ENABLE", True): + with pytest.raises(ValidationException) as exc_info: + await resend_verification_email( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) assert "Please wait" in str(exc_info.value) \ No newline at end of file