diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 7aa6a2f4..b679b830 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -48,6 +48,102 @@ public async Task V2Signup_EnqueuesActivationOutbox() await Assert.That(row.Payload[EmailOutboxPayloadKeys.UserId]).IsEqualTo(user.Id.ToString()); } + // --- Resend Activation --- + + [Test] + public async Task ResendActivation_UnactivatedUser_EnqueuesActivationOutbox() + { + var email = TestHelper.UniqueEmail("mail-resend-activate"); + var username = TestHelper.UniqueUsername("mailresendactivate"); + + // Unactivated user with no existing activation request — exercises the create-request path. + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // The missing activation request was created, and a fresh activation email was enqueued. + var hasRequest = await db.UserActivationRequests.AsNoTracking().AnyAsync(r => r.UserId == userId); + await Assert.That(hasRequest).IsTrue(); + + var row = await db.EmailOutbox.AsNoTracking().SingleAsync(m => m.Recipient == email); + await Assert.That(row.Type).IsEqualTo(EmailType.AccountActivation); + await Assert.That(row.Status).IsEqualTo(EmailStatus.Pending); + await Assert.That(row.CoalesceKey).IsEqualTo(EmailOutboxCoalesceKeys.AccountActivation(userId)); + } + + [Test] + public async Task ResendActivation_WithExistingRequest_EnqueuesAnotherActivationOutbox() + { + var email = TestHelper.UniqueEmail("mail-resend-rotate"); + var username = TestHelper.UniqueUsername("mailresendrotate"); + using var client = WebApplicationFactory.CreateClient(); + + // Sign up (V2) — creates an unactivated user, its activation request, and the first enqueue. + var signupResponse = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username, + password = "SecurePassword123#", + email, + turnstileResponse = "valid-token" + })); + await Assert.That(signupResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Resend enqueues another activation email. The delivery job re-mints the token and supersedes the + // older row at send time (coalesce key) - that token rotation is covered in Cron.IntegrationTests. + var resendResponse = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + await Assert.That(resendResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.AsNoTracking().FirstAsync(u => u.Email == email); + + var rows = await db.EmailOutbox.AsNoTracking().Where(m => m.Recipient == email).ToListAsync(); + await Assert.That(rows.Count).IsEqualTo(2); + await Assert.That(rows.All(r => r.Type == EmailType.AccountActivation)).IsTrue(); + await Assert.That(rows.All(r => r.CoalesceKey == EmailOutboxCoalesceKeys.AccountActivation(user.Id))).IsTrue(); + } + + [Test] + public async Task ResendActivation_AlreadyActivatedUser_Returns200_AndEnqueuesNothing() + { + var email = TestHelper.UniqueEmail("mail-resend-activated"); + var username = TestHelper.UniqueUsername("mailresendactivated"); + + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: true); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + + // Generic 200 (no account-state leak), but nothing is enqueued for an already-activated account. + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var enqueued = await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email); + await Assert.That(enqueued).IsEqualTo(0); + } + + [Test] + public async Task ResendActivation_UnknownEmail_Returns200_AndEnqueuesNothing() + { + var email = TestHelper.UniqueEmail("mail-resend-unknown"); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var enqueued = await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email); + await Assert.That(enqueued).IsEqualTo(0); + } + // --- Password Reset --- [Test] diff --git a/API/Controller/Account/ResendActivation.cs b/API/Controller/Account/ResendActivation.cs new file mode 100644 index 00000000..a8e9c02e --- /dev/null +++ b/API/Controller/Account/ResendActivation.cs @@ -0,0 +1,32 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using OpenShock.Common.Models; +using Asp.Versioning; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.Common.DataAnnotations; + +namespace OpenShock.API.Controller.Account; + +public sealed partial class AccountController +{ + /// + /// Resend the account activation email + /// + /// Activation email sent if the email is associated to an unactivated account + [HttpPost("activate/resend")] + [EnableRateLimiting("auth")] + [Consumes(MediaTypeNames.Application.Json)] + [MapToApiVersion("1")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task ResendActivation([FromBody] ResendActivationRequest body, CancellationToken cancellationToken) + { + await _accountService.ResendActivationEmailAsync(body.Email, cancellationToken); + return LegacyEmptyOk("Activation email has been sent if the email is associated to an unactivated account"); + } + + public sealed class ResendActivationRequest + { + [EmailAddress(true)] + public required string Email { get; init; } + } +} diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 1ab0968a..9e2d4183 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -246,6 +246,40 @@ public async Task TryActivateAccountAsync(string secret, CancellationToken return true; } + /// + public async Task ResendActivationEmailAsync(string email, CancellationToken cancellationToken = default) + { + var lowerCaseEmail = email.ToLowerInvariant(); + + var user = await _db.Users + .Include(u => u.UserDeactivation) + .Include(u => u.UserActivationRequest) + .FirstOrDefaultAsync(u => u.Email == lowerCaseEmail, cancellationToken); + + // Silently no-op when there is nothing to send, so this endpoint can't be used to probe + // which emails are registered, already activated, or deactivated. + if (user is null || user.ActivatedAt is not null || user.UserDeactivation is not null) return; + + // Ensure an activation request exists (legacy data or a failed initial send may lack one). The + // real token is minted lazily by the outbox delivery job at send time, so we only seed the hash + // here; the seed is overwritten on send. + if (user.UserActivationRequest is null) + { + user.UserActivationRequest = new UserActivationRequest + { + UserId = user.Id, + TokenHash = SeedTokenHash() + }; + } + + // Durably enqueue a fresh activation email. Its coalesce key supersedes any still-pending + // activation for this user, and the delivery job re-mints the token on send - invalidating any + // previously emailed link. + _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, user.Email, user.Name)); + await _db.SaveChangesAsync(cancellationToken); + await NotifyEmailOutboxAsync(); + } + /// public async Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater) { diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index feb389cf..72e25d47 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -46,6 +46,15 @@ public interface IAccountService /// Task TryActivateAccountAsync(string token, CancellationToken cancellationToken = default); + /// + /// Resends the account activation email for an unactivated account, rotating the activation token. + /// Silently does nothing when no email is needed (unknown email, already activated, or deactivated) + /// to avoid leaking account state. + /// + /// The email address of the account to resend the activation email for. + /// + Task ResendActivationEmailAsync(string email, CancellationToken cancellationToken = default); + public Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater = true); public Task> ReactivateAccountAsync(Guid executingUserId, Guid userId);