diff --git a/Api.SeaHavenIndustries.Tests/SendMessageTests.cs b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs new file mode 100644 index 0000000..806bcd1 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs @@ -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, "

Hi

")); + Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "

Hi

")); + Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 })); + Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "

Hi

", 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, "

secret link

")); + + 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, "

Hi

")); + + var entry = Assert.Single(logger.Entries); + Assert.Contains(nameof(HttpRequestException), entry); + Assert.DoesNotContain(Recipient, entry); + } + + private sealed class StubbedSendMessage : SendMessage + { + private readonly Mock _client = new(); + + public StubbedSendMessage(HttpStatusCode status, ILogger logger) + : base(new ConfigurationBuilder().Build(), logger) + { + _client + .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null)); + } + + public StubbedSendMessage(Exception failure, ILogger logger) + : base(new ConfigurationBuilder().Build(), logger) + { + _client + .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(failure); + } + + protected override ISendGridClient CreateClient(string? apiKey) => _client.Object; + } + + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Entries.Add($"{formatter(state, exception)} {exception}"); + } +} diff --git a/Api.SeaHavenIndustries/Helper/SendMessage.cs b/Api.SeaHavenIndustries/Helper/SendMessage.cs index 08db640..5bab7ee 100644 --- a/Api.SeaHavenIndustries/Helper/SendMessage.cs +++ b/Api.SeaHavenIndustries/Helper/SendMessage.cs @@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper public class SendMessage : IEmailSender { private IConfiguration _configuration; + private readonly ILogger _logger; //string keysapi = ""; - public SendMessage(IConfiguration configuration) + public SendMessage(IConfiguration configuration, ILogger logger) { _configuration = configuration; + _logger = logger; //keysapi = _configuration.GetValue("SendGrid:ApiKey"); } + protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey); + + /// + /// SendGrid reports a rejected message through the status code, not an exception. + /// Only the status is logged: never the recipient, subject or response body. + /// + 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 SendEMail(string emailTo, string subject, string body) { try @@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper var apiKey = _configuration.GetValue("SendGrid:ApiKey"); //var apiKey = ""; - 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 SendEmailAsync(string emailTo, string subject, string htmlBody) @@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper var apiKey = _configuration.GetValue("SendGrid:ApiKey"); //var apiKey = ""; - 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 { 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 SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo) @@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper try { var apiKey = _configuration.GetValue("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); } } } diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 32ac851..dce7af1 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -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); diff --git a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs index 330254b..3b355ba 100644 --- a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs @@ -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 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(); diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index 2f62818..d15a347 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -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); } diff --git a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs index 9b12928..54eb702 100644 --- a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs +++ b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs @@ -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); + /// + /// 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. + /// + Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken); + /// Admin-only: revokes a pending member's open invites and emails a new one. Task ResendAsync( string userId, diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs new file mode 100644 index 0000000..521d7a4 --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs @@ -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; + +/// What happens to a member's invites when an admin edits or deletes that member. +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() + .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() + .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 UpdateAsync( + TeamMemberInviteTestHost host, + string userId, + string email, + string role) => + host.InScopeAsync(provider => provider.GetRequiredService().UpdateAsync( + userId, + new UpdateTeamMemberRequestDTO + { + Name = "Taylor Reed", + Role = role, + Color = "#0D9488", + Email = email, + Phone = "555-0100", + ServiceAreas = role == "dispatcher" ? new[] { "East" } : Array.Empty() + }, + TeamMemberInviteTestHost.Admin(), + CancellationToken.None)); + + private static Task CompleteAsync(TeamMemberInviteTestHost host, string token) => + host.RegistrationAsync(service => service.CompleteAsync( + new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }, + CancellationToken.None)); + + private static Task ResendInviteAsync(TeamMemberInviteTestHost host, string userId) => + host.InScopeAsync(provider => provider.GetRequiredService() + .ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None)); +}