Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions API.IntegrationTests/Tests/MailTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenShockContext>();

// 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<OpenShockContext>();
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<OpenShockContext>();
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<OpenShockContext>();
var enqueued = await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email);
await Assert.That(enqueued).IsEqualTo(0);
}

// --- Password Reset ---

[Test]
Expand Down
32 changes: 32 additions & 0 deletions API/Controller/Account/ResendActivation.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Resend the account activation email
/// </summary>
/// <response code="200">Activation email sent if the email is associated to an unactivated account</response>
[HttpPost("activate/resend")]
[EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)]
[MapToApiVersion("1")]
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<IActionResult> 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; }
}
}
34 changes: 34 additions & 0 deletions API/Services/Account/AccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,40 @@ public async Task<bool> TryActivateAccountAsync(string secret, CancellationToken
return true;
}

/// <inheritdoc />
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();
}

/// <inheritdoc />
public async Task<OneOf<Success, CannotDeactivatePrivilegedAccount, AccountDeactivationAlreadyInProgress, Unauthorized, NotFound>> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater)
{
Expand Down
9 changes: 9 additions & 0 deletions API/Services/Account/IAccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ public interface IAccountService
/// <returns></returns>
Task<bool> TryActivateAccountAsync(string token, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
/// <param name="email">The email address of the account to resend the activation email for.</param>
/// <param name="cancellationToken"></param>
Task ResendActivationEmailAsync(string email, CancellationToken cancellationToken = default);

public Task<OneOf<Success, CannotDeactivatePrivilegedAccount, AccountDeactivationAlreadyInProgress, Unauthorized, NotFound>> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater = true);

public Task<OneOf<Success, Unauthorized, NotFound>> ReactivateAccountAsync(Guid executingUserId, Guid userId);
Expand Down
Loading