diff --git a/.env.example b/.env.example index 5a32404..de6af12 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,6 @@ MONGO_DB=fantasyrealm_logs # pgAdmin Configuration PGADMIN_EMAIL=contact@fantasy-realm.com PGADMIN_PASSWORD=__PGADMIN_PASSWORD__ + +# Frontend URL (used for email links) +FRONTEND_URL=https://fantasy-realm.com diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 78d88a3..fc22cbd 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -63,6 +63,7 @@ services: ConnectionStrings__PostgreSQL: "Host=postgres;Port=5432;Database=${POSTGRES_DB:-fantasyrealm};Username=${POSTGRES_USER:-fantasyrealm};Password=${POSTGRES_PASSWORD}" ConnectionStrings__MongoDB: "mongodb://${MONGO_USER:-fantasyrealm}:${MONGO_PASSWORD}@mongodb:27017/${MONGO_DB:-fantasyrealm_logs}?authSource=admin" Email__Password: ${EMAIL_PASSWORD} + Email__BaseUrl: ${FRONTEND_URL:-http://localhost:5173} depends_on: postgres: condition: service_healthy diff --git a/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs b/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs index d9c5b66..f1ac787 100644 --- a/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs +++ b/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs @@ -1,5 +1,7 @@ +using System.IdentityModel.Tokens.Jwt; using FantasyRealm.Application.DTOs; using FantasyRealm.Application.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace FantasyRealm.Api.Controllers @@ -9,15 +11,8 @@ namespace FantasyRealm.Api.Controllers /// [ApiController] [Route("api/[controller]")] - public sealed class AuthController : ControllerBase + public sealed class AuthController(IAuthService authService) : ControllerBase { - private readonly IAuthService _authService; - - public AuthController(IAuthService authService) - { - _authService = authService; - } - /// /// Registers a new user account. /// @@ -33,7 +28,7 @@ public AuthController(IAuthService authService) [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) { - var result = await _authService.RegisterAsync(request, cancellationToken); + var result = await authService.RegisterAsync(request, cancellationToken); if (result.IsFailure) { @@ -58,7 +53,7 @@ public async Task Register([FromBody] RegisterRequest request, Ca [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Login([FromBody] LoginRequest request, CancellationToken cancellationToken) { - var result = await _authService.LoginAsync(request, cancellationToken); + var result = await authService.LoginAsync(request, cancellationToken); if (result.IsFailure) { @@ -85,7 +80,7 @@ public async Task Login([FromBody] LoginRequest request, Cancella [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken cancellationToken) { - var result = await _authService.ForgotPasswordAsync(request, cancellationToken); + var result = await authService.ForgotPasswordAsync(request, cancellationToken); if (result.IsFailure) { @@ -94,5 +89,40 @@ public async Task ForgotPassword([FromBody] ForgotPasswordRequest return Ok(new { message = "Un nouveau mot de passe a été envoyé à votre adresse email." }); } + + /// + /// Changes the password for the authenticated user. + /// + /// The current and new password details. + /// Cancellation token. + /// A new JWT token upon successful password change. + /// Password changed successfully. + /// Invalid request data or password validation failed. + /// Not authenticated or current password is incorrect. + /// Account suspended. + [HttpPost("change-password")] + [Authorize] + [ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken) + { + var userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Token invalide." }); + } + + var result = await authService.ChangePasswordAsync(userId, request, cancellationToken); + + if (result.IsFailure) + { + return StatusCode(result.ErrorCode ?? 400, new { message = result.Error }); + } + + return Ok(result.Value); + } } } diff --git a/src/backend/src/FantasyRealm.Api/Program.cs b/src/backend/src/FantasyRealm.Api/Program.cs index 108fdbd..ff66c59 100644 --- a/src/backend/src/FantasyRealm.Api/Program.cs +++ b/src/backend/src/FantasyRealm.Api/Program.cs @@ -44,6 +44,7 @@ private static void Main(string[] args) }) .AddJwtBearer(options => { + options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, diff --git a/src/backend/src/FantasyRealm.Api/appsettings.json b/src/backend/src/FantasyRealm.Api/appsettings.json index 8318bb8..c365572 100644 --- a/src/backend/src/FantasyRealm.Api/appsettings.json +++ b/src/backend/src/FantasyRealm.Api/appsettings.json @@ -20,7 +20,8 @@ "Username": "noreply@fantasy-realm.com", "Password": "", "FromAddress": "noreply@fantasy-realm.com", - "FromName": "FantasyRealm" + "FromName": "FantasyRealm", + "BaseUrl": "http://localhost:5173" }, "Jwt": { "Secret": "CHANGE_ME_IN_PRODUCTION_THIS_IS_A_DEV_SECRET_KEY_MIN_32_CHARS", diff --git a/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordRequest.cs b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordRequest.cs new file mode 100644 index 0000000..c093096 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordRequest.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace FantasyRealm.Application.DTOs +{ + /// + /// Request payload for changing user password. + /// + public sealed record ChangePasswordRequest( + [Required(ErrorMessage = "Le mot de passe actuel est requis.")] + string CurrentPassword, + + [Required(ErrorMessage = "Le nouveau mot de passe est requis.")] + string NewPassword, + + [Required(ErrorMessage = "La confirmation du mot de passe est requise.")] + string ConfirmNewPassword + ); +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordResponse.cs b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordResponse.cs new file mode 100644 index 0000000..4e7f0b3 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordResponse.cs @@ -0,0 +1,11 @@ +namespace FantasyRealm.Application.DTOs +{ + /// + /// Response payload for successful password change. + /// + public sealed record ChangePasswordResponse( + string Token, + DateTime ExpiresAt, + UserInfo User + ); +} diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs index 1dd20c1..abb94b3 100644 --- a/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs @@ -31,5 +31,14 @@ public interface IAuthService /// Cancellation token. /// A result indicating success or an error. Task> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken = default); + + /// + /// Changes the password for an authenticated user. + /// + /// The ID of the authenticated user. + /// The change password request containing current and new passwords. + /// Cancellation token. + /// A result containing the new token or an error. + Task> ChangePasswordAsync(int userId, ChangePasswordRequest request, CancellationToken cancellationToken = default); } } diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs index 7ef2976..81ca503 100644 --- a/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs @@ -36,6 +36,14 @@ public interface IUserRepository /// The user with their role, or null if not found. Task GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default); + /// + /// Retrieves a user by ID, including their role. + /// + /// The user ID. + /// Cancellation token. + /// The user with their role, or null if not found. + Task GetByIdWithRoleAsync(int id, CancellationToken cancellationToken = default); + /// /// Retrieves a user by email and pseudo combination, including their role. /// Used for password reset verification. diff --git a/src/backend/src/FantasyRealm.Application/Services/AuthService.cs b/src/backend/src/FantasyRealm.Application/Services/AuthService.cs index 0c8cd42..e83881a 100644 --- a/src/backend/src/FantasyRealm.Application/Services/AuthService.cs +++ b/src/backend/src/FantasyRealm.Application/Services/AuthService.cs @@ -10,36 +10,19 @@ namespace FantasyRealm.Application.Services /// /// Service implementation for authentication operations. /// - public sealed class AuthService : IAuthService + public sealed class AuthService( + IUserRepository userRepository, + IPasswordHasher passwordHasher, + IPasswordGenerator passwordGenerator, + IEmailService emailService, + IJwtService jwtService, + ILogger logger) : IAuthService { private const string DefaultRole = "User"; private const string InvalidCredentialsMessage = "Identifiants incorrects."; private const string AccountSuspendedMessage = "Votre compte a été suspendu."; private const string UserNotFoundMessage = "Aucun compte ne correspond à ces informations."; - private readonly IUserRepository _userRepository; - private readonly IPasswordHasher _passwordHasher; - private readonly IPasswordGenerator _passwordGenerator; - private readonly IEmailService _emailService; - private readonly IJwtService _jwtService; - private readonly ILogger _logger; - - public AuthService( - IUserRepository userRepository, - IPasswordHasher passwordHasher, - IPasswordGenerator passwordGenerator, - IEmailService emailService, - IJwtService jwtService, - ILogger logger) - { - _userRepository = userRepository; - _passwordHasher = passwordHasher; - _passwordGenerator = passwordGenerator; - _emailService = emailService; - _jwtService = jwtService; - _logger = logger; - } - /// public async Task> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default) { @@ -55,19 +38,19 @@ public async Task> RegisterAsync(RegisterRequest reques return Result.Failure(errorMessage, 400); } - var emailExists = await _userRepository.ExistsByEmailAsync(request.Email, cancellationToken); + var emailExists = await userRepository.ExistsByEmailAsync(request.Email, cancellationToken); if (emailExists) { return Result.Failure("Cette adresse email est déjà utilisée.", 409); } - var pseudoExists = await _userRepository.ExistsByPseudoAsync(request.Pseudo, cancellationToken); + var pseudoExists = await userRepository.ExistsByPseudoAsync(request.Pseudo, cancellationToken); if (pseudoExists) { return Result.Failure("Ce pseudo est déjà utilisé.", 409); } - var role = await _userRepository.GetRoleByLabelAsync(DefaultRole, cancellationToken); + var role = await userRepository.GetRoleByLabelAsync(DefaultRole, cancellationToken); if (role is null) { return Result.Failure("Configuration error: default role not found.", 500); @@ -77,24 +60,24 @@ public async Task> RegisterAsync(RegisterRequest reques { Email = request.Email.ToLowerInvariant().Trim(), Pseudo = request.Pseudo.Trim(), - PasswordHash = _passwordHasher.Hash(request.Password), + PasswordHash = passwordHasher.Hash(request.Password), RoleId = role.Id, IsSuspended = false, MustChangePassword = false }; - var createdUser = await _userRepository.CreateAsync(user, cancellationToken); + var createdUser = await userRepository.CreateAsync(user, cancellationToken); _ = Task.Run(async () => { try { - await _emailService.SendWelcomeEmailAsync(createdUser.Email, createdUser.Pseudo, CancellationToken.None); - _logger.LogInformation("Welcome email sent to {Email}", createdUser.Email); + await emailService.SendWelcomeEmailAsync(createdUser.Email, createdUser.Pseudo, CancellationToken.None); + logger.LogInformation("Welcome email sent to {Email}", createdUser.Email); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to send welcome email to {Email}", createdUser.Email); + logger.LogWarning(ex, "Failed to send welcome email to {Email}", createdUser.Email); } }, CancellationToken.None); @@ -112,30 +95,30 @@ public async Task> LoginAsync(LoginRequest request, Cancel { var normalizedEmail = request.Email.ToLowerInvariant().Trim(); - var user = await _userRepository.GetByEmailWithRoleAsync(normalizedEmail, cancellationToken); + var user = await userRepository.GetByEmailWithRoleAsync(normalizedEmail, cancellationToken); if (user is null) { - _logger.LogWarning("Login failed: user not found for email {Email}", normalizedEmail); + logger.LogWarning("Login failed: user not found for email {Email}", normalizedEmail); return Result.Failure(InvalidCredentialsMessage, 401); } - if (!_passwordHasher.Verify(request.Password, user.PasswordHash)) + if (!passwordHasher.Verify(request.Password, user.PasswordHash)) { - _logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id); + logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id); return Result.Failure(InvalidCredentialsMessage, 401); } if (user.IsSuspended) { - _logger.LogWarning("Login failed: account suspended for user {UserId}", user.Id); + logger.LogWarning("Login failed: account suspended for user {UserId}", user.Id); return Result.Failure(AccountSuspendedMessage, 403); } - var token = _jwtService.GenerateToken(user.Id, user.Email, user.Pseudo, user.Role.Label); - var expiresAt = _jwtService.GetExpirationDate(); + var token = jwtService.GenerateToken(user.Id, user.Email, user.Pseudo, user.Role.Label); + var expiresAt = jwtService.GetExpirationDate(); - _logger.LogInformation("Login successful for user {UserId} ({Email})", user.Id, user.Email); + logger.LogInformation("Login successful for user {UserId} ({Email})", user.Id, user.Email); var userInfo = new UserInfo(user.Id, user.Email, user.Pseudo, user.Role.Label); @@ -153,42 +136,101 @@ public async Task> ForgotPasswordAsync(ForgotPasswordRequest reques var normalizedEmail = request.Email.ToLowerInvariant().Trim(); var normalizedPseudo = request.Pseudo.Trim(); - var user = await _userRepository.GetByEmailAndPseudoAsync(normalizedEmail, normalizedPseudo, cancellationToken); + var user = await userRepository.GetByEmailAndPseudoAsync(normalizedEmail, normalizedPseudo, cancellationToken); if (user is null) { - _logger.LogWarning("Password reset failed: no user found for email {Email} and pseudo {Pseudo}", normalizedEmail, normalizedPseudo); + logger.LogWarning("Password reset failed: no user found for email {Email} and pseudo {Pseudo}", normalizedEmail, normalizedPseudo); return Result.Failure(UserNotFoundMessage, 404); } if (user.IsSuspended) { - _logger.LogWarning("Password reset failed: account suspended for user {UserId}", user.Id); + logger.LogWarning("Password reset failed: account suspended for user {UserId}", user.Id); return Result.Failure(AccountSuspendedMessage, 403); } - var temporaryPassword = _passwordGenerator.GenerateSecurePassword(); - user.PasswordHash = _passwordHasher.Hash(temporaryPassword); + var temporaryPassword = passwordGenerator.GenerateSecurePassword(); + user.PasswordHash = passwordHasher.Hash(temporaryPassword); user.MustChangePassword = true; - await _userRepository.UpdateAsync(user, cancellationToken); + await userRepository.UpdateAsync(user, cancellationToken); _ = Task.Run(async () => { try { - await _emailService.SendTemporaryPasswordEmailAsync(user.Email, user.Pseudo, temporaryPassword, CancellationToken.None); - _logger.LogInformation("Temporary password email sent to {Email}", user.Email); + await emailService.SendTemporaryPasswordEmailAsync(user.Email, user.Pseudo, temporaryPassword, CancellationToken.None); + logger.LogInformation("Temporary password email sent to {Email}", user.Email); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send temporary password email to {Email}", user.Email); + logger.LogError(ex, "Failed to send temporary password email to {Email}", user.Email); } }, CancellationToken.None); - _logger.LogInformation("Password reset successful for user {UserId} ({Email})", user.Id, user.Email); + logger.LogInformation("Password reset successful for user {UserId} ({Email})", user.Id, user.Email); return Result.Success(Unit.Value); } + + /// + public async Task> ChangePasswordAsync(int userId, ChangePasswordRequest request, CancellationToken cancellationToken = default) + { + var user = await userRepository.GetByIdWithRoleAsync(userId, cancellationToken); + + if (user is null) + { + logger.LogWarning("Change password failed: user not found for ID {UserId}", userId); + return Result.Failure(InvalidCredentialsMessage, 401); + } + + if (user.IsSuspended) + { + logger.LogWarning("Change password failed: account suspended for user {UserId}", userId); + return Result.Failure(AccountSuspendedMessage, 403); + } + + if (!passwordHasher.Verify(request.CurrentPassword, user.PasswordHash)) + { + logger.LogWarning("Change password failed: invalid current password for user {UserId}", userId); + return Result.Failure("Le mot de passe actuel est incorrect.", 401); + } + + if (request.NewPassword != request.ConfirmNewPassword) + { + return Result.Failure("Les nouveaux mots de passe ne correspondent pas.", 400); + } + + var passwordValidation = PasswordValidator.Validate(request.NewPassword); + if (!passwordValidation.IsValid) + { + var errorMessage = string.Join(" ", passwordValidation.Errors); + return Result.Failure(errorMessage, 400); + } + + if (passwordHasher.Verify(request.NewPassword, user.PasswordHash)) + { + return Result.Failure("Le nouveau mot de passe doit être différent de l'ancien.", 400); + } + + user.PasswordHash = passwordHasher.Hash(request.NewPassword); + user.MustChangePassword = false; + + await userRepository.UpdateAsync(user, cancellationToken); + + var token = jwtService.GenerateToken(user.Id, user.Email, user.Pseudo, user.Role.Label); + var expiresAt = jwtService.GetExpirationDate(); + + logger.LogInformation("Password changed successfully for user {UserId} ({Email})", user.Id, user.Email); + + var userInfo = new UserInfo(user.Id, user.Email, user.Pseudo, user.Role.Label); + + return Result.Success(new ChangePasswordResponse( + token, + expiresAt, + userInfo + )); + } } } diff --git a/src/backend/src/FantasyRealm.Domain/Interfaces/.gitkeep b/src/backend/src/FantasyRealm.Domain/Interfaces/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/backend/src/FantasyRealm.Infrastructure/Data/.gitkeep b/src/backend/src/FantasyRealm.Infrastructure/Data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs index 72dce71..3a84836 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs @@ -44,5 +44,10 @@ public class EmailSettings /// Gets or sets the sender display name. /// public string FromName { get; set; } = string.Empty; + + /// + /// Gets or sets the base URL for links in email templates. + /// + public string BaseUrl { get; set; } = string.Empty; } } diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs index 66aae67..dd5b275 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs @@ -8,9 +8,6 @@ namespace FantasyRealm.Infrastructure.Email /// public static class EmailTemplates { - private const string BaseUrl = "https://fantasy-realm.com"; - - // Dark fantasy color palette - aligned with official style guide v3.0 private const string DarkBg = "#0D0D0F"; // --dark-900 private const string CardBg = "#121110"; // --dark-800 private const string CardBorder = "#18181B"; // --dark-700 @@ -24,8 +21,9 @@ public static class EmailTemplates /// Generates a welcome email template for new users. /// /// The user's display name. + /// The base URL for links in the email. /// The HTML email body. - public static string GetWelcomeTemplate(string pseudo) + public static string GetWelcomeTemplate(string pseudo, string baseUrl) { return WrapInLayout( "Bienvenue dans FantasyRealm !", @@ -74,7 +72,7 @@ Partager vos créations avec la communauté
- {GetPrimaryButton("Créer mon premier personnage", $"{BaseUrl}/characters/create", "🛡️")} + {GetPrimaryButton("Créer mon premier personnage", $"{baseUrl}/characters/create", "🛡️")}

@@ -88,10 +86,11 @@ Partager vos créations avec la communauté /// /// The user's display name. /// The password reset token. + /// The base URL for links in the email. /// The HTML email body. - public static string GetPasswordResetTemplate(string pseudo, string resetToken) + public static string GetPasswordResetTemplate(string pseudo, string resetToken, string baseUrl) { - var resetUrl = $"{BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}"; + var resetUrl = $"{baseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}"; return WrapInLayout( "Réinitialisation de mot de passe", $@" @@ -130,8 +129,9 @@ Nous avons reçu une demande de réinitialisation de votre mot de passe. /// /// The user's display name. /// The generated temporary password. + /// The base URL for links in the email. /// The HTML email body. - public static string GetTemporaryPasswordTemplate(string pseudo, string temporaryPassword) + public static string GetTemporaryPasswordTemplate(string pseudo, string temporaryPassword, string baseUrl) { return WrapInLayout( "Nouveau mot de passe temporaire", @@ -172,7 +172,7 @@ Ce mot de passe est temporaire et ne devrait pas être réutilisé.

- {GetPrimaryButton("Se connecter", $"{BaseUrl}/login", "🔑")} + {GetPrimaryButton("Se connecter", $"{baseUrl}/login", "🔑")}
@@ -188,8 +188,9 @@ Ce mot de passe est temporaire et ne devrait pas être réutilisé. /// /// The user's display name. /// The approved character's name. + /// The base URL for links in the email. /// The HTML email body. - public static string GetCharacterApprovedTemplate(string pseudo, string characterName) + public static string GetCharacterApprovedTemplate(string pseudo, string characterName, string baseUrl) { return WrapInLayout( "Personnage approuvé !", @@ -218,7 +219,7 @@ et approuvé par notre équipe de modération.
- {GetPrimaryButton("Voir mes personnages", $"{BaseUrl}/characters", "👥")} + {GetPrimaryButton("Voir mes personnages", $"{baseUrl}/characters", "👥")}
"); } @@ -229,8 +230,9 @@ et approuvé par notre équipe de modération. /// The user's display name. /// The rejected character's name. /// The rejection reason. + /// The base URL for links in the email. /// The HTML email body. - public static string GetCharacterRejectedTemplate(string pseudo, string characterName, string reason) + public static string GetCharacterRejectedTemplate(string pseudo, string characterName, string reason, string baseUrl) { return WrapInLayout( "Personnage non approuvé", @@ -266,7 +268,7 @@ Vous pouvez modifier votre personnage et le soumettre à nouveau pour examen.

- {GetPrimaryButton("Modifier mon personnage", $"{BaseUrl}/characters", "✏️")} + {GetPrimaryButton("Modifier mon personnage", $"{baseUrl}/characters", "✏️")}
"); } @@ -276,8 +278,9 @@ Vous pouvez modifier votre personnage et le soumettre à nouveau pour examen. /// /// The user's display name. /// The character's name that was commented on. + /// The base URL for links in the email. /// The HTML email body. - public static string GetCommentApprovedTemplate(string pseudo, string characterName) + public static string GetCommentApprovedTemplate(string pseudo, string characterName, string baseUrl) { return WrapInLayout( "Commentaire publié !", @@ -304,7 +307,7 @@ Commentaire publié !

- {GetPrimaryButton("Explorer la galerie", $"{BaseUrl}/gallery", "🖼️")} + {GetPrimaryButton("Explorer la galerie", $"{baseUrl}/gallery", "🖼️")}
"); } @@ -425,94 +428,94 @@ private static string GetPrimaryButton(string text, string url, string icon = "" private static string WrapInLayout(string title, string content) { return $@" - - - - - - {Encode(title)} - FantasyRealm - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - -
- - - - -
- - - - - -
🏰 - - FANTASYREALM - -
- - Character Manager - -
-
-
- {content} -
- - - - -
- -
- -

- © {DateTime.UtcNow.Year} FantasyRealm par PixelVerse Studios -

-

- Ceci est un message automatique. Merci de ne pas répondre à cet email. -

-
-
- -
- - -"; + + + + + + {Encode(title)} - FantasyRealm + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
🏰 + + FANTASYREALM + +
+ + Character Manager + +
+
+
+ {content} +
+ + + + +
+ +
+ +

+ © {DateTime.UtcNow.Year} FantasyRealm par PixelVerse Studios +

+

+ Ceci est un message automatique. Merci de ne pas répondre à cet email. +

+
+
+ +
+ + + "; } } } diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs index 07928be..f92aeb5 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs @@ -1,5 +1,4 @@ using FantasyRealm.Application.Interfaces; -using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -10,33 +9,24 @@ namespace FantasyRealm.Infrastructure.Email /// /// SMTP-based implementation of the email service using MailKit. /// - public class SmtpEmailService : IEmailService + /// + /// Initializes a new instance of the class. + /// + /// The email configuration settings. + /// The factory for creating SMTP clients. + /// The logger instance. + public class SmtpEmailService( + IOptions settings, + ISmtpClientFactory smtpClientFactory, + ILogger logger) : IEmailService { - private readonly EmailSettings _settings; - private readonly ISmtpClientFactory _smtpClientFactory; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The email configuration settings. - /// The factory for creating SMTP clients. - /// The logger instance. - public SmtpEmailService( - IOptions settings, - ISmtpClientFactory smtpClientFactory, - ILogger logger) - { - _settings = settings.Value; - _smtpClientFactory = smtpClientFactory; - _logger = logger; - } + private readonly EmailSettings _settings = settings.Value; /// public async Task SendWelcomeEmailAsync(string toEmail, string pseudo, CancellationToken cancellationToken = default) { var subject = "Welcome to FantasyRealm!"; - var body = EmailTemplates.GetWelcomeTemplate(pseudo); + var body = EmailTemplates.GetWelcomeTemplate(pseudo, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -44,7 +34,7 @@ public async Task SendWelcomeEmailAsync(string toEmail, string pseudo, Cancellat public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, string resetToken, CancellationToken cancellationToken = default) { var subject = "Reset your FantasyRealm password"; - var body = EmailTemplates.GetPasswordResetTemplate(pseudo, resetToken); + var body = EmailTemplates.GetPasswordResetTemplate(pseudo, resetToken, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -52,7 +42,7 @@ public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, str public async Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo, string temporaryPassword, CancellationToken cancellationToken = default) { var subject = "Votre nouveau mot de passe FantasyRealm"; - var body = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword); + var body = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -60,7 +50,7 @@ public async Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo, public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default) { var subject = $"Your character {characterName} has been approved!"; - var body = EmailTemplates.GetCharacterApprovedTemplate(pseudo, characterName); + var body = EmailTemplates.GetCharacterApprovedTemplate(pseudo, characterName, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -68,7 +58,7 @@ public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo, public async Task SendCharacterRejectedEmailAsync(string toEmail, string pseudo, string characterName, string reason, CancellationToken cancellationToken = default) { var subject = $"Your character {characterName} was not approved"; - var body = EmailTemplates.GetCharacterRejectedTemplate(pseudo, characterName, reason); + var body = EmailTemplates.GetCharacterRejectedTemplate(pseudo, characterName, reason, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -76,7 +66,7 @@ public async Task SendCharacterRejectedEmailAsync(string toEmail, string pseudo, public async Task SendCommentApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default) { var subject = "Your comment has been approved!"; - var body = EmailTemplates.GetCommentApprovedTemplate(pseudo, characterName); + var body = EmailTemplates.GetCommentApprovedTemplate(pseudo, characterName, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -111,7 +101,7 @@ private async Task SendEmailAsync(string toEmail, string subject, string htmlBod try { - using var client = _smtpClientFactory.Create(); + using var client = smtpClientFactory.Create(); var secureSocketOptions = _settings.UseSsl ? SecureSocketOptions.StartTls @@ -122,11 +112,11 @@ private async Task SendEmailAsync(string toEmail, string subject, string htmlBod await client.SendAsync(message, cancellationToken); await client.DisconnectAsync(true, cancellationToken); - _logger.LogInformation("Email sent successfully to {Email} with subject '{Subject}'", toEmail, subject); + logger.LogInformation("Email sent successfully to {Email} with subject '{Subject}'", toEmail, subject); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email to {Email} with subject '{Subject}'", toEmail, subject); + logger.LogError(ex, "Failed to send email to {Email} with subject '{Subject}'", toEmail, subject); throw; } } diff --git a/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs index 8834183..d90b85a 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs @@ -8,20 +8,13 @@ namespace FantasyRealm.Infrastructure.Repositories /// /// Repository implementation for User entity data access operations. /// - public sealed class UserRepository : IUserRepository + public sealed class UserRepository(FantasyRealmDbContext context) : IUserRepository { - private readonly FantasyRealmDbContext _context; - - public UserRepository(FantasyRealmDbContext context) - { - _context = context; - } - /// public async Task ExistsByEmailAsync(string email, CancellationToken cancellationToken = default) { var normalizedEmail = email.ToLowerInvariant().Trim(); - return await _context.Users + return await context.Users .AnyAsync(u => u.Email == normalizedEmail, cancellationToken); } @@ -29,22 +22,22 @@ public async Task ExistsByEmailAsync(string email, CancellationToken cance public async Task ExistsByPseudoAsync(string pseudo, CancellationToken cancellationToken = default) { var normalizedPseudo = pseudo.Trim(); - return await _context.Users + return await context.Users .AnyAsync(u => u.Pseudo == normalizedPseudo, cancellationToken); } /// public async Task CreateAsync(User user, CancellationToken cancellationToken = default) { - _context.Users.Add(user); - await _context.SaveChangesAsync(cancellationToken); + context.Users.Add(user); + await context.SaveChangesAsync(cancellationToken); return user; } /// public async Task GetRoleByLabelAsync(string label, CancellationToken cancellationToken = default) { - return await _context.Roles + return await context.Roles .FirstOrDefaultAsync(r => r.Label == label, cancellationToken); } @@ -52,17 +45,25 @@ public async Task CreateAsync(User user, CancellationToken cancellationTok public async Task GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default) { var normalizedEmail = email.ToLowerInvariant().Trim(); - return await _context.Users + return await context.Users .Include(u => u.Role) .FirstOrDefaultAsync(u => u.Email == normalizedEmail, cancellationToken); } + /// + public async Task GetByIdWithRoleAsync(int id, CancellationToken cancellationToken = default) + { + return await context.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + /// public async Task GetByEmailAndPseudoAsync(string email, string pseudo, CancellationToken cancellationToken = default) { var normalizedEmail = email.ToLowerInvariant().Trim(); var normalizedPseudo = pseudo.Trim(); - return await _context.Users + return await context.Users .Include(u => u.Role) .FirstOrDefaultAsync(u => u.Email == normalizedEmail && u.Pseudo == normalizedPseudo, cancellationToken); } @@ -71,8 +72,8 @@ public async Task CreateAsync(User user, CancellationToken cancellationTok public async Task UpdateAsync(User user, CancellationToken cancellationToken = default) { user.UpdatedAt = DateTime.UtcNow; - _context.Users.Update(user); - await _context.SaveChangesAsync(cancellationToken); + context.Users.Update(user); + await context.SaveChangesAsync(cancellationToken); return user; } } diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs b/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs index b2c2083..cd75ccb 100644 --- a/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs +++ b/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs @@ -408,6 +408,383 @@ public async Task Login_WithEmptyFields_ReturnsBadRequest(string email, string p #endregion + #region ChangePassword Tests + + [Fact] + public async Task ChangePassword_WithValidData_ReturnsOkAndNewToken() + { + // Arrange - Register and login + var email = $"changepwd_{Guid.NewGuid():N}@example.com"; + var oldPassword = "MySecure@Pass123"; + var newPassword = "NewSecure@Pass456"; + var pseudo = $"ChgPwd{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = oldPassword, + ConfirmPassword = oldPassword + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = oldPassword }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = oldPassword, + NewPassword = newPassword, + ConfirmNewPassword = newPassword + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Token.Should().NotBeNullOrEmpty(); + result.Token.Should().NotBe(loginResult.Token); + result.ExpiresAt.Should().BeAfter(DateTime.UtcNow); + result.User.Email.Should().Be(email.ToLowerInvariant()); + } + + [Fact] + public async Task ChangePassword_WithNewToken_CanLoginWithNewPassword() + { + // Arrange - Register, login, and change password + var email = $"newlogin_{Guid.NewGuid():N}@example.com"; + var oldPassword = "MySecure@Pass123"; + var newPassword = "NewSecure@Pass456"; + var pseudo = $"NewLog{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = oldPassword, + ConfirmPassword = oldPassword + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = oldPassword }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + using var changeRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + changeRequest.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + changeRequest.Content = JsonContent.Create(new + { + CurrentPassword = oldPassword, + NewPassword = newPassword, + ConfirmNewPassword = newPassword + }); + + await _client.SendAsync(changeRequest); + + // Act - Try to login with new password + var newLoginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = newPassword }); + + // Assert + newLoginResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ChangePassword_WithoutAuthentication_ReturnsUnauthorized() + { + // Arrange + var request = new + { + CurrentPassword = "OldPassword@123", + NewPassword = "NewPassword@456", + ConfirmNewPassword = "NewPassword@456" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/change-password", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ChangePassword_WithInvalidToken_ReturnsUnauthorized() + { + // Arrange + var request = new + { + CurrentPassword = "OldPassword@123", + NewPassword = "NewPassword@456", + ConfirmNewPassword = "NewPassword@456" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", "Bearer invalid.token.here"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ChangePassword_WithWrongCurrentPassword_ReturnsUnauthorized() + { + // Arrange + var email = $"wrongcur_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"WrngCur{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = "WrongPassword@123", + NewPassword = "NewSecure@Pass456", + ConfirmNewPassword = "NewSecure@Pass456" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("mot de passe actuel"); + } + + [Fact] + public async Task ChangePassword_WithMismatchedNewPasswords_ReturnsBadRequest() + { + // Arrange + var email = $"mismatch_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"MisMat{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = password, + NewPassword = "NewSecure@Pass456", + ConfirmNewPassword = "DifferentPass@789" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("correspondent"); + } + + [Fact] + public async Task ChangePassword_WithWeakNewPassword_ReturnsBadRequest() + { + // Arrange + var email = $"weaknew_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"WeakNw{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = password, + NewPassword = "weak", + ConfirmNewPassword = "weak" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ChangePassword_WithSameAsOldPassword_ReturnsBadRequest() + { + // Arrange + var email = $"sameold_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"SameOl{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = password, + NewPassword = password, + ConfirmNewPassword = password + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("différent"); + } + + [Fact] + public async Task ChangePassword_WithSuspendedAccount_ReturnsForbidden() + { + // Arrange + var email = $"suspchg_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"SusChg{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + // Suspend the user directly in DB + using var scope = _factory.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant()); + user.IsSuspended = true; + await context.SaveChangesAsync(); + + var request = new + { + CurrentPassword = password, + NewPassword = "NewSecure@Pass456", + ConfirmNewPassword = "NewSecure@Pass456" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("suspendu"); + } + + [Fact] + public async Task ChangePassword_SetsMustChangePasswordToFalse() + { + // Arrange + var email = $"mustchg_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"MustCh{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + // Set MustChangePassword to true in DB + using (var scope = _factory.Services.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant()); + user.MustChangePassword = true; + await context.SaveChangesAsync(); + } + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + loginResult!.MustChangePassword.Should().BeTrue(); + + var request = new + { + CurrentPassword = password, + NewPassword = "NewSecure@Pass456", + ConfirmNewPassword = "NewSecure@Pass456" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + await _client.SendAsync(requestMessage); + + // Assert - Check DB + using var verifyScope = _factory.Services.CreateScope(); + var verifyContext = verifyScope.ServiceProvider.GetRequiredService(); + var updatedUser = await verifyContext.Users.FirstAsync(u => u.Email == email.ToLowerInvariant()); + updatedUser.MustChangePassword.Should().BeFalse(); + } + + #endregion + private record ErrorResponse(string Message); } } diff --git a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs index 209da21..7ddb3a1 100644 --- a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs +++ b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs @@ -4,6 +4,8 @@ namespace FantasyRealm.Tests.Unit.Email { public class EmailTemplatesTests { + private const string TestBaseUrl = "https://test.fantasy-realm.com"; + [Fact] public void GetWelcomeTemplate_ContainsPseudo() { @@ -11,7 +13,7 @@ public void GetWelcomeTemplate_ContainsPseudo() var pseudo = "TestPlayer"; // Act - var result = EmailTemplates.GetWelcomeTemplate(pseudo); + var result = EmailTemplates.GetWelcomeTemplate(pseudo, TestBaseUrl); // Assert Assert.Contains(pseudo, result); @@ -24,19 +26,30 @@ public void GetWelcomeTemplate_EscapesHtmlCharacters() { var pseudo = ""; - var result = EmailTemplates.GetWelcomeTemplate(pseudo); + var result = EmailTemplates.GetWelcomeTemplate(pseudo, TestBaseUrl); Assert.DoesNotContain(""; var temporaryPassword = "TempPass@123!"; - var result = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword); + var result = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword, TestBaseUrl); Assert.DoesNotContain("