From 9084b7f642d0b3a8557336b4bb908670cc23177c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 16:40:54 -0300 Subject: [PATCH] fix(team-members): answer invalid_invite when send-code finds the invite closed SendCodeAsync validates the invite, then starts the code with a conditional update that also requires the invite to still be open. When an admin revoked the link (or it was used) between those two reads, the refusal was reported as resend_too_soon with a Retry-After, although the link was already dead. On a refused start the invite is now read again: a closed invite gets the same generic invalid_invite response as any other dead link, and a cooldown or send limit refusal is computed from the fresh row. --- .../TeamMemberRegistrationService.cs | 9 ++- .../TeamMemberInviteRegistrationTests.cs | 70 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) 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() {