mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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.
This commit is contained in:
parent
bb41bfd8ed
commit
9084b7f642
2 changed files with 78 additions and 1 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<ITeamMemberInviteDataService>(provider =>
|
||||
new ClosesInviteBeforeCodeStart(
|
||||
new TeamMemberInviteDataService(provider.GetRequiredService<ApplicationDbContext>()),
|
||||
provider.GetRequiredService<ApplicationDbContext>(),
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Closes the invite after the service has read it and before the conditional code start runs.</summary>
|
||||
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<bool> 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<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) =>
|
||||
_inner.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||
|
||||
public Task<bool> TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken) =>
|
||||
_inner.TryReserveCodeAttemptAsync(inviteId, maxAttempts, now, cancellationToken);
|
||||
|
||||
public Task<bool> TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken) =>
|
||||
_inner.TryConfirmEmailAsync(inviteId, codeHash, now, cancellationToken);
|
||||
|
||||
public Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken) =>
|
||||
_inner.TryClaimAsync(inviteId, now, cancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_RequiresConfirmedEmail()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue