mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 00:02:10 +00:00
fix(team-members): keep omitted phone, report invite email failures, never echo invite errors
This commit is contained in:
parent
c0ae8479ce
commit
385229c64d
9 changed files with 164 additions and 6 deletions
|
|
@ -1,5 +1,11 @@
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
using Api.SeaHavenIndustries.Controllers;
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Api.SeaHavenIndustries.Filters;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
@ -44,6 +50,51 @@ public class TeamMemberInviteControllerTests
|
||||||
cache!.NoStore.Should().BeTrue();
|
cache!.NoStore.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter()
|
||||||
|
{
|
||||||
|
var filter = typeof(TeamMemberInviteController).GetCustomAttribute<TypeFilterAttribute>();
|
||||||
|
|
||||||
|
filter.Should().NotBeNull();
|
||||||
|
filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail()
|
||||||
|
{
|
||||||
|
var context = ExceptionContextFor(new ArgumentException(
|
||||||
|
"SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'"));
|
||||||
|
|
||||||
|
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
|
||||||
|
.OnException(context);
|
||||||
|
|
||||||
|
context.ExceptionHandled.Should().BeTrue();
|
||||||
|
var result = context.Result.Should().BeOfType<ObjectResult>().Subject;
|
||||||
|
result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||||
|
var body = JsonSerializer.Serialize(result.Value);
|
||||||
|
body.Should().Be(
|
||||||
|
"{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}");
|
||||||
|
body.Should().NotContain("SELECT").And.NotContain("leak-me");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExceptionFilter_LeavesCancellationToTheHost()
|
||||||
|
{
|
||||||
|
var context = ExceptionContextFor(new OperationCanceledException());
|
||||||
|
|
||||||
|
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
|
||||||
|
.OnException(context);
|
||||||
|
|
||||||
|
context.ExceptionHandled.Should().BeFalse();
|
||||||
|
context.Result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExceptionContext ExceptionContextFor(Exception exception)
|
||||||
|
{
|
||||||
|
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
|
||||||
|
return new ExceptionContext(actionContext, new List<IFilterMetadata>()) { Exception = exception };
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(TeamMemberRegistrationStatus.InvalidInvite)]
|
[InlineData(TeamMemberRegistrationStatus.InvalidInvite)]
|
||||||
[InlineData(TeamMemberRegistrationStatus.Ok)]
|
[InlineData(TeamMemberRegistrationStatus.Ok)]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Api.SeaHavenIndustries.DTOs;
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
using Api.SeaHavenIndustries.Filters;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
@ -14,6 +15,7 @@ namespace Api.SeaHavenIndustries.Controllers;
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/team-member-invites")]
|
[Route("api/team-member-invites")]
|
||||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||||
|
[TypeFilter(typeof(InviteRegistrationExceptionFilter))]
|
||||||
public sealed class TeamMemberInviteController : ControllerBase
|
public sealed class TeamMemberInviteController : ControllerBase
|
||||||
{
|
{
|
||||||
public const string InvalidInviteMessage =
|
public const string InvalidInviteMessage =
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Filters
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Anonymous invite endpoints never echo an exception: controller-scoped exception
|
||||||
|
/// filters run before the global ones, so no message, stack or SQL reaches the caller.
|
||||||
|
/// Cancellation is left to the host.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InviteRegistrationExceptionFilter : IExceptionFilter
|
||||||
|
{
|
||||||
|
public const string Message = "Something went wrong. Try again in a minute.";
|
||||||
|
|
||||||
|
private readonly ILogger<InviteRegistrationExceptionFilter> _logger;
|
||||||
|
|
||||||
|
public InviteRegistrationExceptionFilter(ILogger<InviteRegistrationExceptionFilter> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnException(ExceptionContext context)
|
||||||
|
{
|
||||||
|
if (context.Exception is OperationCanceledException)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_logger.LogError(context.Exception, "Invite registration request failed.");
|
||||||
|
|
||||||
|
context.Result = new ObjectResult(new { code = "server_error", message = Message, retryAfterSeconds = (int?)null })
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status500InternalServerError
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
namespace SeaHaven.Services.DTOs;
|
namespace SeaHaven.Services.DTOs;
|
||||||
|
|
@ -23,6 +24,10 @@ public class TeamMemberCreatedDTO
|
||||||
public string? Phone { get; init; }
|
public string? Phone { get; init; }
|
||||||
public required IReadOnlyList<string> ServiceAreas { get; init; }
|
public required IReadOnlyList<string> ServiceAreas { get; init; }
|
||||||
public bool PendingRegistration { get; init; }
|
public bool PendingRegistration { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Set only on create: false means the member exists but the invite email failed; re-invite from their details.</summary>
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public bool? InviteEmailSent { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TeamMemberDetailDTO : TeamMemberCreatedDTO
|
public sealed class TeamMemberDetailDTO : TeamMemberCreatedDTO
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
UserRole = userRoles.FirstOrDefault(),
|
UserRole = userRoles.FirstOrDefault(),
|
||||||
PhoneNumber = user.PhoneNumber,
|
PhoneNumber = user.PhoneNumber,
|
||||||
Fullname = user.FirstName + " " + user.LastName,
|
Fullname = $"{user.FirstName} {user.LastName}".Trim(),
|
||||||
Id = user.Id
|
Id = user.Id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,8 @@ public sealed class TeamMemberInviteService : ITeamMemberInviteService
|
||||||
var body =
|
var body =
|
||||||
$"<p>Hi {WebUtility.HtmlEncode(name)},</p>" +
|
$"<p>Hi {WebUtility.HtmlEncode(name)},</p>" +
|
||||||
"<p>You've been added to Seahaven. Set your password and confirm your email to finish creating your account.</p>" +
|
"<p>You've been added to Seahaven. Set your password and confirm your email to finish creating your account.</p>" +
|
||||||
$"<p><a href=\"{WebUtility.HtmlEncode(link)}\">Finish registration</a></p>" +
|
// clicktracking=off stops SendGrid rewriting the link, so the token never passes through its redirect.
|
||||||
|
$"<p><a clicktracking=off href=\"{WebUtility.HtmlEncode(link)}\">Finish registration</a></p>" +
|
||||||
$"<p>This link expires in {InviteLifetime.Days} days and can be used once.</p>";
|
$"<p>This link expires in {InviteLifetime.Days} days and can be used once.</p>";
|
||||||
|
|
||||||
var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body);
|
var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body);
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,8 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat
|
||||||
if (context.Invite.EmailConfirmedAt is null)
|
if (context.Invite.EmailConfirmedAt is null)
|
||||||
return Outcome(TeamMemberRegistrationStatus.EmailNotConfirmed);
|
return Outcome(TeamMemberRegistrationStatus.EmailNotConfirmed);
|
||||||
|
|
||||||
|
// An omitted phone keeps what the admin entered; an explicit empty value clears it.
|
||||||
|
var updatePhone = request.Phone is not null;
|
||||||
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
||||||
if (phone is not null && !PhonePattern().IsMatch(phone))
|
if (phone is not null && !PhonePattern().IsMatch(phone))
|
||||||
return Outcome(TeamMemberRegistrationStatus.InvalidPhone);
|
return Outcome(TeamMemberRegistrationStatus.InvalidPhone);
|
||||||
|
|
@ -165,8 +167,11 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat
|
||||||
user.EmailConfirmed = true;
|
user.EmailConfirmed = true;
|
||||||
user.PendingRegistration = false;
|
user.PendingRegistration = false;
|
||||||
user.UniqueName = "Active";
|
user.UniqueName = "Active";
|
||||||
user.PhoneNumber = phone;
|
if (updatePhone)
|
||||||
user.Contact = phone;
|
{
|
||||||
|
user.PhoneNumber = phone;
|
||||||
|
user.Contact = phone;
|
||||||
|
}
|
||||||
|
|
||||||
// Identity applies the shared password policy and persists the user.
|
// Identity applies the shared password policy and persists the user.
|
||||||
var result = await _userManager.AddPasswordAsync(user, request.Password ?? "");
|
var result = await _userManager.AddPasswordAsync(user, request.Password ?? "");
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
|
|
||||||
// The member and their invite commit together; the email goes out only after
|
// The member and their invite commit together; the email goes out only after
|
||||||
// commit, so a rolled-back member never receives a link.
|
// commit, so a rolled-back member never receives a link.
|
||||||
await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken);
|
var inviteEmailSent = await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken);
|
||||||
|
|
||||||
return new CreateTeamMemberOutcomeDTO
|
return new CreateTeamMemberOutcomeDTO
|
||||||
{
|
{
|
||||||
|
|
@ -129,7 +129,8 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
Phone = user.PhoneNumber,
|
Phone = user.PhoneNumber,
|
||||||
ServiceAreas = areas!,
|
ServiceAreas = areas!,
|
||||||
PendingRegistration = true
|
PendingRegistration = true,
|
||||||
|
InviteEmailSent = inviteEmailSent
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ public sealed class TeamMemberInviteRegistrationTests
|
||||||
var sent = Assert.Single(host.Sent.Messages);
|
var sent = Assert.Single(host.Sent.Messages);
|
||||||
Assert.Equal(Email, sent.To);
|
Assert.Equal(Email, sent.To);
|
||||||
Assert.Contains($"{TeamMemberInviteTestHost.FrontendBaseUrl}/invite#{token}", sent.Body);
|
Assert.Contains($"{TeamMemberInviteTestHost.FrontendBaseUrl}/invite#{token}", sent.Body);
|
||||||
|
Assert.Contains("<a clicktracking=off href=", sent.Body);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -58,6 +59,7 @@ public sealed class TeamMemberInviteRegistrationTests
|
||||||
Assert.False(string.IsNullOrWhiteSpace(completed.Session!.Token));
|
Assert.False(string.IsNullOrWhiteSpace(completed.Session!.Token));
|
||||||
Assert.Equal(Email, completed.Session.Email);
|
Assert.Equal(Email, completed.Session.Email);
|
||||||
Assert.Equal(userId, completed.Session.Id);
|
Assert.Equal(userId, completed.Session.Id);
|
||||||
|
Assert.Equal("Taylor Reed", completed.Session.Fullname);
|
||||||
Assert.Contains("Dispatcher", completed.Session.UserRole);
|
Assert.Contains("Dispatcher", completed.Session.UserRole);
|
||||||
|
|
||||||
var user = await host.ReloadUserAsync(userId);
|
var user = await host.ReloadUserAsync(userId);
|
||||||
|
|
@ -290,6 +292,59 @@ public sealed class TeamMemberInviteRegistrationTests
|
||||||
Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration);
|
Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_WithoutPhone_KeepsThePhoneTheAdminEntered()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
await host.ConfirmEmailAsync(token, Email);
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password, phone: null)).Status);
|
||||||
|
|
||||||
|
var user = await host.ReloadUserAsync(userId);
|
||||||
|
Assert.Equal("555-0100", user.PhoneNumber);
|
||||||
|
Assert.Equal("555-0100", user.Contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_WithAnEmptyPhone_ClearsIt()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
await host.ConfirmEmailAsync(token, Email);
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password, phone: "")).Status);
|
||||||
|
|
||||||
|
var user = await host.ReloadUserAsync(userId);
|
||||||
|
Assert.Null(user.PhoneNumber);
|
||||||
|
Assert.Null(user.Contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task CreatingPendingMember_ReportsWhetherTheInviteEmailWentOut(bool delivered)
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
host.Sent.Succeeds = delivered;
|
||||||
|
|
||||||
|
var outcome = await host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberService>().CreateAsync(
|
||||||
|
new CreateTeamMemberRequestDTO
|
||||||
|
{
|
||||||
|
Name = "Taylor Reed",
|
||||||
|
Role = "dispatcher",
|
||||||
|
Color = "#0D9488",
|
||||||
|
Email = Email,
|
||||||
|
ServiceAreas = new[] { "East" }
|
||||||
|
},
|
||||||
|
TeamMemberInviteTestHost.Admin(),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.True(outcome.Success);
|
||||||
|
Assert.Equal(delivered, outcome.Member!.InviteEmailSent);
|
||||||
|
Assert.Single(await host.InvitesAsync(outcome.Member.Id));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Complete_InvalidPhone_IsRejectedWithoutConsumingTheInvite()
|
public async Task Complete_InvalidPhone_IsRejectedWithoutConsumingTheInvite()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue