mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
Add resend confirmation email feature (#166)
This commit is contained in:
36
src/Core/API/API.Specs/Features/ResendConfirmation.feature
Normal file
36
src/Core/API/API.Specs/Features/ResendConfirmation.feature
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
Feature: Resend Confirmation Email
|
||||||
|
As a user who did not receive the confirmation email
|
||||||
|
I want to request a resend of the confirmation email
|
||||||
|
So that I can obtain a working confirmation link while preventing abuse
|
||||||
|
|
||||||
|
Scenario: Legitimate resend for an unconfirmed user
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a resend confirmation request for my account
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
|
Scenario: Resend is a no-op for an already confirmed user
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid confirmation token for my account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
And I have confirmed my account
|
||||||
|
When I submit a resend confirmation request for my account
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
|
Scenario: Resend is a no-op for a non-existent user
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a resend confirmation request for a non-existent user
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
|
Scenario: Resend requires authentication
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
When I submit a resend confirmation request without an access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
@@ -7,6 +7,8 @@ public class MockEmailService : IEmailService
|
|||||||
{
|
{
|
||||||
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
||||||
|
|
||||||
|
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
|
||||||
|
|
||||||
public Task SendRegistrationEmailAsync(
|
public Task SendRegistrationEmailAsync(
|
||||||
UserAccount createdUser,
|
UserAccount createdUser,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
@@ -24,9 +26,27 @@ public class MockEmailService : IEmailService
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SendResendConfirmationEmailAsync(
|
||||||
|
UserAccount user,
|
||||||
|
string confirmationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
SentResendConfirmationEmails.Add(
|
||||||
|
new ResendConfirmationEmail
|
||||||
|
{
|
||||||
|
UserAccount = user,
|
||||||
|
ConfirmationToken = confirmationToken,
|
||||||
|
SentAt = DateTime.UtcNow,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
SentRegistrationEmails.Clear();
|
SentRegistrationEmails.Clear();
|
||||||
|
SentResendConfirmationEmails.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RegistrationEmail
|
public class RegistrationEmail
|
||||||
@@ -35,4 +55,11 @@ public class MockEmailService : IEmailService
|
|||||||
public string ConfirmationToken { get; init; } = string.Empty;
|
public string ConfirmationToken { get; init; } = string.Empty;
|
||||||
public DateTime SentAt { get; init; }
|
public DateTime SentAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ResendConfirmationEmail
|
||||||
|
{
|
||||||
|
public UserAccount UserAccount { get; init; } = null!;
|
||||||
|
public string ConfirmationToken { get; init; } = string.Empty;
|
||||||
|
public DateTime SentAt { get; init; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1124,4 +1124,91 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
refreshToken.Should().NotBe(previousRefreshToken);
|
refreshToken.Should().NotBe(previousRefreshToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Given("I have confirmed my account")]
|
||||||
|
public async Task GivenIHaveConfirmedMyAccount()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: throw new InvalidOperationException("confirmation token not found");
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a resend confirmation request for my account")]
|
||||||
|
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: throw new InvalidOperationException("registered user ID not found");
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm/resend?userId={userId}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a resend confirmation request for a non-existent user")]
|
||||||
|
public async Task WhenISubmitAResendConfirmationRequestForANonExistentUser()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var fakeUserId = Guid.NewGuid();
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm/resend?userId={fakeUserId}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a resend confirmation request without an access token")]
|
||||||
|
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: Guid.NewGuid();
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm/resend?userId={userId}"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
@using Infrastructure.Email.Templates.Components
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<title>Resend Confirmation - The Biergarten App</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style>
|
||||||
|
* { font-family: Arial, sans-serif !important; }
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="margin:0; padding:0; background-color:#f4f4f4; width:100%;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f4f4f4;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:40px 10px;">
|
||||||
|
<!--[if mso]>
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width:600px;">
|
||||||
|
<tr><td>
|
||||||
|
<![endif]-->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||||
|
style="max-width:600px; background:#ffffff; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
||||||
|
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 40px 16px 40px; text-align:center;">
|
||||||
|
<h1 style="margin:0; color:#333333; font-size:26px; font-weight:700;">
|
||||||
|
New Confirmation Link
|
||||||
|
</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 40px 20px 40px; text-align:center;">
|
||||||
|
<p style="margin:0; color:#666666; font-size:16px; line-height:24px;">
|
||||||
|
Hi <strong style="color:#333333;">@Username</strong>, you requested another email confirmation
|
||||||
|
link.
|
||||||
|
Use the button below to verify your account.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 40px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!--[if mso]>
|
||||||
|
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||||
|
href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:260px;"
|
||||||
|
arcsize="10%" stroke="f" fillcolor="#f59e0b">
|
||||||
|
<w:anchorlock/>
|
||||||
|
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
|
||||||
|
Confirm Email Again
|
||||||
|
</center>
|
||||||
|
</v:roundrect>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="display:inline-block; padding:16px 40px; background:#d97706; color:#ffffff; text-decoration:none; border-radius:6px; font-size:16px; font-weight:700;">
|
||||||
|
Confirm Email Again
|
||||||
|
</a>
|
||||||
|
<!--<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 40px 8px 40px; text-align:center;">
|
||||||
|
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
|
||||||
|
This replacement link expires in 24 hours.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 40px 28px 40px; text-align:center;">
|
||||||
|
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
|
||||||
|
If you did not request this, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
|
||||||
|
</table>
|
||||||
|
<!--[if mso]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string ConfirmationLink { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -30,6 +30,23 @@ public class EmailTemplateProvider(
|
|||||||
return await RenderComponentAsync<UserRegistration>(parameters);
|
return await RenderComponentAsync<UserRegistration>(parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the ResendConfirmation template with the specified parameters.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> RenderResendConfirmationEmailAsync(
|
||||||
|
string username,
|
||||||
|
string confirmationLink
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
{ nameof(ResendConfirmation.Username), username },
|
||||||
|
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
|
||||||
|
};
|
||||||
|
|
||||||
|
return await RenderComponentAsync<ResendConfirmation>(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generic method to render any Razor component to HTML.
|
/// Generic method to render any Razor component to HTML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -15,4 +15,15 @@ public interface IEmailTemplateProvider
|
|||||||
string username,
|
string username,
|
||||||
string confirmationLink
|
string confirmationLink
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the ResendConfirmation template with the specified parameters.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">The username to include in the email</param>
|
||||||
|
/// <param name="confirmationLink">The new confirmation link</param>
|
||||||
|
/// <returns>The rendered HTML string</returns>
|
||||||
|
Task<string> RenderResendConfirmationEmailAsync(
|
||||||
|
string username,
|
||||||
|
string confirmationLink
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await GetUserByIdAsync(userAccountId);
|
return await GetUserByIdAsync(userAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using var connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using var command = connection.CreateCommand();
|
||||||
|
|||||||
@@ -75,4 +75,11 @@ public interface IAuthRepository
|
|||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a user account has been verified.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
|
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||||
|
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,151 +5,155 @@ using Domain.Exceptions;
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.Repository.Auth;
|
using Infrastructure.Repository.Auth;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using Service.Emails;
|
||||||
|
|
||||||
namespace Service.Auth.Tests;
|
namespace Service.Auth.Tests;
|
||||||
|
|
||||||
public class ConfirmationServiceTest
|
public class ConfirmationServiceTest
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
private readonly ConfirmationService _confirmationService;
|
private readonly Mock<IEmailService> _emailServiceMock;
|
||||||
|
private readonly ConfirmationService _confirmationService;
|
||||||
|
|
||||||
public ConfirmationServiceTest()
|
public ConfirmationServiceTest()
|
||||||
{
|
{
|
||||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
_tokenServiceMock = new Mock<ITokenService>();
|
_tokenServiceMock = new Mock<ITokenService>();
|
||||||
|
_emailServiceMock = new Mock<IEmailService>();
|
||||||
|
|
||||||
_confirmationService = new ConfirmationService(
|
_confirmationService = new ConfirmationService(
|
||||||
_authRepositoryMock.Object,
|
_authRepositoryMock.Object,
|
||||||
_tokenServiceMock.Object
|
_tokenServiceMock.Object,
|
||||||
);
|
_emailServiceMock.Object
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
|
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
const string username = "testuser";
|
const string username = "testuser";
|
||||||
const string confirmationToken = "valid-confirmation-token";
|
const string confirmationToken = "valid-confirmation-token";
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
var validatedToken = new ValidatedToken(userId, username, principal);
|
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||||
var userAccount = new UserAccount
|
var userAccount = new UserAccount
|
||||||
{
|
{
|
||||||
UserAccountId = userId,
|
UserAccountId = userId,
|
||||||
Username = username,
|
Username = username,
|
||||||
FirstName = "Test",
|
FirstName = "Test",
|
||||||
LastName = "User",
|
LastName = "User",
|
||||||
Email = "test@example.com",
|
Email = "test@example.com",
|
||||||
DateOfBirth = new DateTime(1990, 1, 1),
|
DateOfBirth = new DateTime(1990, 1, 1),
|
||||||
};
|
};
|
||||||
|
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||||
.ReturnsAsync(validatedToken);
|
.ReturnsAsync(validatedToken);
|
||||||
|
|
||||||
_authRepositoryMock
|
_authRepositoryMock
|
||||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||||
.ReturnsAsync(userAccount);
|
.ReturnsAsync(userAccount);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result =
|
var result =
|
||||||
await _confirmationService.ConfirmUserAsync(confirmationToken);
|
await _confirmationService.ConfirmUserAsync(confirmationToken);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.UserId.Should().Be(userId);
|
result.UserId.Should().Be(userId);
|
||||||
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
_tokenServiceMock.Verify(
|
_tokenServiceMock.Verify(
|
||||||
x => x.ValidateConfirmationTokenAsync(confirmationToken),
|
x => x.ValidateConfirmationTokenAsync(confirmationToken),
|
||||||
Times.Once
|
Times.Once
|
||||||
);
|
);
|
||||||
|
|
||||||
_authRepositoryMock.Verify(
|
_authRepositoryMock.Verify(
|
||||||
x => x.ConfirmUserAccountAsync(userId),
|
x => x.ConfirmUserAccountAsync(userId),
|
||||||
Times.Once
|
Times.Once
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
|
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
const string invalidToken = "invalid-confirmation-token";
|
const string invalidToken = "invalid-confirmation-token";
|
||||||
|
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||||
.ThrowsAsync(new UnauthorizedException(
|
.ThrowsAsync(new UnauthorizedException(
|
||||||
"Invalid confirmation token"
|
"Invalid confirmation token"
|
||||||
));
|
));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _confirmationService.ConfirmUserAsync(invalidToken)
|
await _confirmationService.ConfirmUserAsync(invalidToken)
|
||||||
).Should().ThrowAsync<UnauthorizedException>();
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
|
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
const string expiredToken = "expired-confirmation-token";
|
const string expiredToken = "expired-confirmation-token";
|
||||||
|
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
|
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
|
||||||
.ThrowsAsync(new UnauthorizedException(
|
.ThrowsAsync(new UnauthorizedException(
|
||||||
"Confirmation token has expired"
|
"Confirmation token has expired"
|
||||||
));
|
));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _confirmationService.ConfirmUserAsync(expiredToken)
|
await _confirmationService.ConfirmUserAsync(expiredToken)
|
||||||
).Should().ThrowAsync<UnauthorizedException>();
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
const string username = "nonexistent";
|
const string username = "nonexistent";
|
||||||
const string confirmationToken = "valid-token-for-nonexistent-user";
|
const string confirmationToken = "valid-token-for-nonexistent-user";
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
var validatedToken = new ValidatedToken(userId, username, principal);
|
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||||
|
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||||
.ReturnsAsync(validatedToken);
|
.ReturnsAsync(validatedToken);
|
||||||
|
|
||||||
_authRepositoryMock
|
_authRepositoryMock
|
||||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||||
.ReturnsAsync((UserAccount?)null);
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _confirmationService.ConfirmUserAsync(confirmationToken)
|
await _confirmationService.ConfirmUserAsync(confirmationToken)
|
||||||
).Should().ThrowAsync<UnauthorizedException>()
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
.WithMessage("*User account not found*");
|
.WithMessage("*User account not found*");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,53 @@
|
|||||||
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Infrastructure.Repository.Auth;
|
using Infrastructure.Repository.Auth;
|
||||||
|
using Service.Emails;
|
||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
public class ConfirmationService(
|
public class ConfirmationService(
|
||||||
IAuthRepository authRepository,
|
IAuthRepository authRepository,
|
||||||
ITokenService tokenService
|
ITokenService tokenService,
|
||||||
|
IEmailService emailService
|
||||||
) : IConfirmationService
|
) : IConfirmationService
|
||||||
{
|
{
|
||||||
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
||||||
confirmationToken
|
confirmationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
var user = await authRepository.ConfirmUserAccountAsync(
|
var user = await authRepository.ConfirmUserAccountAsync(
|
||||||
validatedToken.UserId
|
validatedToken.UserId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
throw new UnauthorizedException("User account not found");
|
throw new UnauthorizedException("User account not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ConfirmationServiceReturn(
|
return new ConfirmationServiceReturn(
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
user.UserAccountId
|
user.UserAccountId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ResendConfirmationEmailAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await authRepository.GetUserByIdAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return; // Silent return to prevent user enumeration
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await authRepository.IsUserVerifiedAsync(userId))
|
||||||
|
{
|
||||||
|
return; // Already confirmed, no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||||
|
await emailService.SendResendConfirmationEmailAsync(user, confirmationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
|
|||||||
public interface IConfirmationService
|
public interface IConfirmationService
|
||||||
{
|
{
|
||||||
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
||||||
|
Task ResendConfirmationEmailAsync(Guid userId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ public interface IEmailService
|
|||||||
UserAccount createdUser,
|
UserAccount createdUser,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public Task SendResendConfirmationEmailAsync(
|
||||||
|
UserAccount user,
|
||||||
|
string confirmationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EmailService(
|
public class EmailService(
|
||||||
@@ -42,4 +47,26 @@ public class EmailService(
|
|||||||
isHtml: true
|
isHtml: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendResendConfirmationEmailAsync(
|
||||||
|
UserAccount user,
|
||||||
|
string confirmationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var confirmationLink =
|
||||||
|
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||||
|
|
||||||
|
var emailHtml =
|
||||||
|
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
|
||||||
|
user.FirstName,
|
||||||
|
confirmationLink
|
||||||
|
);
|
||||||
|
|
||||||
|
await emailProvider.SendAsync(
|
||||||
|
user.Email,
|
||||||
|
"Confirm Your Email - The Biergarten App",
|
||||||
|
emailHtml,
|
||||||
|
isHtml: true
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user