fix(team-members): delete invites with their member, re-invite on email change, trust only 2xx SendGrid responses

This commit is contained in:
Alexandre Brandizzi 2026-09-25 12:45:04 -03:00
parent e09ef061d1
commit f491d4c721
7 changed files with 279 additions and 19 deletions

View file

@ -0,0 +1,105 @@
using System.Net;
using Api.SeaHavenIndustries.Helper;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using SendGrid;
using SendGrid.Helpers.Mail;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class SendMessageTests
{
private const string Recipient = "taylor@example.com";
private const string Subject = "You're invited to Seahaven";
private const string RejectionBody = "{\"errors\":[{\"message\":\"taylor@example.com does not exist\"}]}";
[Theory]
[InlineData(HttpStatusCode.OK, true)]
[InlineData(HttpStatusCode.Accepted, true)]
[InlineData(HttpStatusCode.BadRequest, false)]
[InlineData(HttpStatusCode.Unauthorized, false)]
[InlineData(HttpStatusCode.TooManyRequests, false)]
[InlineData(HttpStatusCode.InternalServerError, false)]
public async Task EverySendPath_ReportsDeliveryOnlyForASuccessStatus(HttpStatusCode status, bool delivered)
{
var sender = new StubbedSendMessage(status, new CapturingLogger());
Assert.Equal(delivered, await sender.SendEMail(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 }));
Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "<p>Hi</p>", null));
}
[Fact]
public async Task ARejectedSend_LogsOnlyTheStatusCode()
{
var logger = new CapturingLogger();
var sender = new StubbedSendMessage(HttpStatusCode.BadRequest, logger);
Assert.False(await sender.SendEMail(Recipient, Subject, "<p>secret link</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains("400", entry);
Assert.DoesNotContain(Recipient, entry);
Assert.DoesNotContain(Subject, entry);
Assert.DoesNotContain("secret link", entry);
Assert.DoesNotContain("errors", entry);
}
[Fact]
public async Task AFailedSend_LogsOnlyTheExceptionType()
{
var logger = new CapturingLogger();
var sender = new StubbedSendMessage(
new HttpRequestException($"could not reach SendGrid for {Recipient}"),
logger);
Assert.False(await sender.SendEMail(Recipient, Subject, "<p>Hi</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains(nameof(HttpRequestException), entry);
Assert.DoesNotContain(Recipient, entry);
}
private sealed class StubbedSendMessage : SendMessage
{
private readonly Mock<ISendGridClient> _client = new();
public StubbedSendMessage(HttpStatusCode status, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null));
}
public StubbedSendMessage(Exception failure, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(failure);
}
protected override ISendGridClient CreateClient(string? apiKey) => _client.Object;
}
private sealed class CapturingLogger : ILogger<SendMessage>
{
public List<string> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add($"{formatter(state, exception)} {exception}");
}
}

View file

@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper
public class SendMessage : IEmailSender
{
private IConfiguration _configuration;
private readonly ILogger<SendMessage> _logger;
//string keysapi = "";
public SendMessage(IConfiguration configuration)
public SendMessage(IConfiguration configuration, ILogger<SendMessage> logger)
{
_configuration = configuration;
_logger = logger;
//keysapi = _configuration.GetValue<string>("SendGrid:ApiKey");
}
protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey);
/// <summary>
/// SendGrid reports a rejected message through the status code, not an exception.
/// Only the status is logged: never the recipient, subject or response body.
/// </summary>
private bool Delivered(Response response)
{
if (response.IsSuccessStatusCode)
return true;
_logger.LogWarning("SendGrid rejected an email with status {StatusCode}.", (int)response.StatusCode);
return false;
}
private bool Failed(Exception ex)
{
_logger.LogWarning("SendGrid email send failed with {ExceptionType}.", ex.GetType().Name);
return false;
}
public async Task<bool> SendEMail(string emailTo, string subject, string body)
{
try
@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
var client = new SendGridClient(apiKey);
var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@ -51,12 +74,11 @@ namespace Api.SeaHavenIndustries.Helper
var htmlContent = body;
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
var response = await client.SendEmailAsync(msg);
var dd = response.Body.ReadAsStringAsync();
return true;
return Delivered(response);
}
catch (Exception ex)
{
return false;
return Failed(ex);
}
}
public async Task<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
var client = new SendGridClient(apiKey);
var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@ -92,13 +114,11 @@ namespace Api.SeaHavenIndustries.Helper
msg.Attachments = new List<SendGrid.Helpers.Mail.Attachment> { attachment };
var response = await client.SendEmailAsync(msg);
var dd = response.Body.ReadAsStringAsync();
return true;
return Delivered(response);
}
catch (Exception ex)
{
return false;
return Failed(ex);
}
}
public async Task<bool> SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo)
@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper
try
{
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
var client = new SendGridClient(apiKey);
var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea Haven Industries");
var to = new EmailAddress(emailTo, "");
@ -116,11 +136,11 @@ namespace Api.SeaHavenIndustries.Helper
msg.SetReplyTo(new EmailAddress(replyTo));
var response = await client.SendEmailAsync(msg);
return true;
return Delivered(response);
}
catch (Exception ex)
{
return false;
return Failed(ex);
}
}
}

View file

@ -159,6 +159,10 @@ namespace SeaHaven.DataServices.Implementation
.Where(permissionOverride => permissionOverride.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.TeamMemberInvites
.Where(invite => invite.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.Users
.Where(existingUser => existingUser.Id == id)
.ExecuteDeleteAsync(cancellationToken);

View file

@ -89,16 +89,28 @@ public sealed class TeamMemberInviteService : ITeamMemberInviteService
if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email))
return ResendFailure("Only pending team members can be re-invited.");
var (invite, issued) = NewInvite(user.Id);
await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken);
var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim();
if (!await SendAsync(issued, user.Email, name, cancellationToken))
if (!await ReissueAsync(user, cancellationToken))
return ResendFailure("The invite could not be emailed. Try again.");
return new TeamMemberInviteResendOutcomeDTO { Success = true };
}
public async Task<bool> ReissueAsync(ApplicationUser user, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentException.ThrowIfNullOrWhiteSpace(user.Email);
var (invite, issued) = NewInvite(user.Id);
await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken);
// A deactivated member cannot register; the admin re-sends the invite after reactivating them.
if (user.IsDeleted == true)
return true;
var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim();
return await SendAsync(issued, user.Email, name, cancellationToken);
}
private (TeamMemberInvite Invite, IssuedTeamMemberInvite Issued) NewInvite(string userId)
{
var now = NowUtc();

View file

@ -199,7 +199,8 @@ public sealed class TeamMemberService : ITeamMemberService
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
user.IsDeleted = !request.IsActive;
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase);
if (emailChanged)
{
var emailResult = await _userManager.SetEmailAsync(user, email!);
if (!emailResult.Succeeded)
@ -237,6 +238,10 @@ public sealed class TeamMemberService : ITeamMemberService
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
}
// An invite sent to the old address, and any code confirmed through it, must not register the new one.
if (emailChanged && user.PendingRegistration == true)
await _inviteService.ReissueAsync(user, cancellationToken);
return await OperationSuccessAsync(user, caller, cancellationToken);
}

View file

@ -1,4 +1,5 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces;
@ -15,6 +16,13 @@ public interface ITeamMemberInviteService
string name,
CancellationToken cancellationToken);
/// <summary>
/// Revokes the member's open invites, including any email confirmation made through them,
/// and emails a new invite to their current address unless they are deactivated.
/// Returns false when that email failed.
/// </summary>
Task<bool> ReissueAsync(ApplicationUser user, CancellationToken cancellationToken);
/// <summary>Admin-only: revokes a pending member's open invites and emails a new one.</summary>
Task<TeamMemberInviteResendOutcomeDTO> ResendAsync(
string userId,

View file

@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Data.SeaHavenIndustries;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>What happens to a member's invites when an admin edits or deletes that member.</summary>
public sealed class TeamMemberInviteLifecycleTests
{
private const string Email = "taylor@example.com";
private const string CorrectedEmail = "taylor.reed@example.com";
private const string Password = "Abc1!x";
[Fact]
public async Task DeletingAnInvitedMember_RemovesTheMemberAndTheirInvites()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.SendCodeAsync(token, Email);
Assert.True((await ResendInviteAsync(host, userId)).Success);
Assert.Equal(2, (await host.InvitesAsync(userId)).Count);
var deleted = await host.InScopeAsync(provider => provider.GetRequiredService<IUserService>()
.DeleteUserAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
Assert.True(deleted.Success, deleted.Error);
Assert.Empty(await host.InvitesAsync(userId));
Assert.False(await host.InScopeAsync(provider => provider.GetRequiredService<ApplicationDbContext>()
.Users.AnyAsync(user => user.Id == userId)));
}
[Fact]
public async Task ChangingAPendingMembersEmail_RevokesTheOldInviteAndItsConfirmation()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, oldToken) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(oldToken, Email);
var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "admin");
Assert.True(updated.Success, updated.Error);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.SendCodeAsync(oldToken, CancellationToken.None))).Status);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, (await CompleteAsync(host, oldToken)).Status);
var user = await host.ReloadUserAsync(userId);
Assert.False(user.EmailConfirmed);
Assert.True(user.PendingRegistration);
// The new address gets its own invite, and it must be confirmed again before finishing.
var newToken = host.Sent.LatestTokenFor(CorrectedEmail);
Assert.NotEqual(oldToken, newToken);
Assert.Equal(TeamMemberRegistrationStatus.EmailNotConfirmed, (await CompleteAsync(host, newToken)).Status);
await host.ConfirmEmailAsync(newToken, CorrectedEmail);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, newToken)).Status);
}
[Fact]
public async Task EditingAPendingMemberWithoutChangingTheirEmail_KeepsTheirInvite()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
var emailsBefore = host.Sent.Messages.Count;
var updated = await UpdateAsync(host, userId, Email.ToUpperInvariant(), role: "dispatcher");
Assert.True(updated.Success, updated.Error);
Assert.Equal(emailsBefore, host.Sent.Messages.Count);
var invite = Assert.Single(await host.InvitesAsync(userId));
Assert.Null(invite.RevokedAt);
Assert.Equal(TeamMemberRegistrationStatus.Ok,
(await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status);
}
private static Task<TeamMemberOperationOutcomeDTO> UpdateAsync(
TeamMemberInviteTestHost host,
string userId,
string email,
string role) =>
host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberService>().UpdateAsync(
userId,
new UpdateTeamMemberRequestDTO
{
Name = "Taylor Reed",
Role = role,
Color = "#0D9488",
Email = email,
Phone = "555-0100",
ServiceAreas = role == "dispatcher" ? new[] { "East" } : Array.Empty<string>()
},
TeamMemberInviteTestHost.Admin(),
CancellationToken.None));
private static Task<TeamMemberRegistrationOutcomeDTO> CompleteAsync(TeamMemberInviteTestHost host, string token) =>
host.RegistrationAsync(service => service.CompleteAsync(
new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password },
CancellationToken.None));
private static Task<TeamMemberInviteResendOutcomeDTO> ResendInviteAsync(TeamMemberInviteTestHost host, string userId) =>
host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteService>()
.ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
}