diff --git a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs index de7ae0a..b83abd8 100644 --- a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs @@ -87,7 +87,14 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat }, cancellationToken); if (!started) - return ResendRefusal(context.Invite, now); + { + // The refusal may be the invite closing since it was read, not the cooldown or limit: + // read it again so a revoked or used link gets the same answer as any other dead link. + var current = await LoadAsync(token, now, cancellationToken); + return current is null + ? Outcome(TeamMemberRegistrationStatus.InvalidInvite) + : ResendRefusal(current.Invite, now); + } // Read the address at send time so an admin's correction is honoured. var body = diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs index f0a4c49..1a986a1 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs @@ -8,6 +8,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; @@ -259,6 +262,73 @@ public sealed class TeamMemberInviteRegistrationTests host.Sent.Messages.Count(message => message.Subject == "Your Seahaven confirmation code")); } + [Theory] + [InlineData("revoked")] + [InlineData("used")] + public async Task SendCode_ForAnInviteClosedMidRequest_GetsTheGenericInvalidInviteResponse(string closedBy) + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(services => + services.Replace(ServiceDescriptor.Scoped(provider => + new ClosesInviteBeforeCodeStart( + new TeamMemberInviteDataService(provider.GetRequiredService()), + provider.GetRequiredService(), + closedBy)))); + var (userId, token) = await host.AddPendingMemberAsync(Email); + + var response = await InvokeControllerAsync(host, controller => controller.SendCode( + new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None)); + + Assert.Equal( + "400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}", + response.Body); + Assert.Null(response.RetryAfter); + Assert.DoesNotContain(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code"); + Assert.Equal(0, Assert.Single(await host.InvitesAsync(userId)).CodeSendCount); + } + + /// Closes the invite after the service has read it and before the conditional code start runs. + private sealed class ClosesInviteBeforeCodeStart : ITeamMemberInviteDataService + { + private readonly ITeamMemberInviteDataService _inner; + private readonly ApplicationDbContext _context; + private readonly string _closedBy; + + public ClosesInviteBeforeCodeStart(ITeamMemberInviteDataService inner, ApplicationDbContext context, string closedBy) + { + _inner = inner; + _context = context; + _closedBy = closedBy; + } + + public async Task TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken) + { + var invite = _context.TeamMemberInvites.Where(existing => existing.Id == command.InviteId); + if (_closedBy == "revoked") + await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.RevokedAt, command.Now), cancellationToken); + else + await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.UsedAt, command.Now), cancellationToken); + return await _inner.TryStartCodeAsync(command, cancellationToken); + } + + public Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken) => + _inner.AddAsync(invite, cancellationToken); + + public Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken) => + _inner.ReplaceOpenForUserAsync(invite, now, cancellationToken); + + public Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) => + _inner.GetByTokenHashAsync(tokenHash, cancellationToken); + + public Task TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken) => + _inner.TryReserveCodeAttemptAsync(inviteId, maxAttempts, now, cancellationToken); + + public Task TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken) => + _inner.TryConfirmEmailAsync(inviteId, codeHash, now, cancellationToken); + + public Task TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken) => + _inner.TryClaimAsync(inviteId, now, cancellationToken); + } + [Fact] public async Task Complete_RequiresConfirmedEmail() {