mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-01 16:53:14 +00:00
fix(team-members): delete invites with their member, re-invite on email change, trust only 2xx SendGrid responses
This commit is contained in:
parent
e09ef061d1
commit
f491d4c721
7 changed files with 279 additions and 19 deletions
105
Api.SeaHavenIndustries.Tests/SendMessageTests.cs
Normal file
105
Api.SeaHavenIndustries.Tests/SendMessageTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
106
SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs
Normal file
106
SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue