2026-09-25 11:49:10 -03:00
using System.Security.Cryptography ;
using System.Text ;
using System.Text.Json ;
using Api.SeaHavenIndustries.Controllers ;
using Data.SeaHavenIndustries ;
using Microsoft.AspNetCore.Http ;
using Microsoft.AspNetCore.Identity ;
using Microsoft.AspNetCore.Mvc ;
using Microsoft.EntityFrameworkCore ;
using Microsoft.Extensions.DependencyInjection ;
2026-09-25 16:40:54 -03:00
using Microsoft.Extensions.DependencyInjection.Extensions ;
using SeaHaven.DataServices.Dto ;
using SeaHaven.DataServices.Implementation ;
2026-09-25 11:49:10 -03:00
using SeaHaven.DataServices.Interfaces ;
using SeaHaven.Services.DTOs ;
using SeaHaven.Services.Implementation ;
using SeaHaven.Services.Interfaces ;
namespace SeaHavenIndustries.Tests ;
public sealed class TeamMemberInviteRegistrationTests
{
private const string Email = "taylor@example.com" ;
private const string Password = "Abc1!x" ;
[Fact]
public async Task CreatingPendingMember_EmailsHighEntropyInviteAndStoresOnlyItsHash ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
var invite = Assert . Single ( await host . InvitesAsync ( userId ) ) ;
Assert . True ( Base64UrlDecode ( token ) . Length > = 16 ) ;
Assert . Equal ( Sha256Hex ( token ) , invite . TokenHash ) ;
Assert . DoesNotContain ( token , JsonSerializer . Serialize ( invite ) ) ;
Assert . Equal ( host . Time . Now . UtcDateTime . AddDays ( 7 ) , invite . ExpiresAt ) ;
Assert . Null ( invite . UsedAt ) ;
var sent = Assert . Single ( host . Sent . Messages ) ;
Assert . Equal ( Email , sent . To ) ;
Assert . Contains ( $"{TeamMemberInviteTestHost.FrontendBaseUrl}/invite#{token}" , sent . Body ) ;
2026-09-25 12:11:52 -03:00
Assert . Contains ( "<a clicktracking=off href=" , sent . Body ) ;
2026-09-25 11:49:10 -03:00
}
[Fact]
public async Task FullFlow_FromEmailedToken_ActivatesMemberConfirmsEmailAndSignsIn ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email , "Taylor Reed" ) ;
var resolved = await host . RegistrationAsync ( service = > service . ResolveAsync ( token , CancellationToken . None ) ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok , resolved . Status ) ;
Assert . Equal ( "Taylor Reed" , resolved . Details ! . Name ) ;
Assert . Equal ( "Dispatcher" , resolved . Details . Role ) ;
Assert . Equal ( Email , resolved . Details . Email ) ;
await host . ConfirmEmailAsync ( token , Email ) ;
var completed = await CompleteAsync ( host , token , Password , "(555) 010-2000" ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok , completed . Status ) ;
Assert . False ( string . IsNullOrWhiteSpace ( completed . Session ! . Token ) ) ;
Assert . Equal ( Email , completed . Session . Email ) ;
Assert . Equal ( userId , completed . Session . Id ) ;
2026-09-25 12:11:52 -03:00
Assert . Equal ( "Taylor Reed" , completed . Session . Fullname ) ;
2026-09-25 11:49:10 -03:00
Assert . Contains ( "Dispatcher" , completed . Session . UserRole ) ;
var user = await host . ReloadUserAsync ( userId ) ;
Assert . False ( user . PendingRegistration ) ;
Assert . True ( user . EmailConfirmed ) ;
Assert . Equal ( "Active" , user . UniqueName ) ;
Assert . Equal ( "(555) 010-2000" , user . PhoneNumber ) ;
Assert . Equal ( "(555) 010-2000" , user . Contact ) ;
Assert . True ( await host . InScopeAsync ( provider = >
provider . GetRequiredService < UserManager < ApplicationUser > > ( ) . CheckPasswordAsync ( user , Password ) ) ) ;
Assert . NotNull ( Assert . Single ( await host . InvitesAsync ( userId ) ) . UsedAt ) ;
}
[Fact]
public async Task UsedToken_CannotRegisterAgain ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( _ , token ) = await host . AddPendingMemberAsync ( Email ) ;
await host . ConfirmEmailAsync ( token , Email ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok , ( await CompleteAsync ( host , token , Password ) ) . Status ) ;
Assert . Equal ( TeamMemberRegistrationStatus . InvalidInvite ,
( await host . RegistrationAsync ( service = > service . ResolveAsync ( token , CancellationToken . None ) ) ) . Status ) ;
Assert . Equal ( TeamMemberRegistrationStatus . InvalidInvite ,
( await host . RegistrationAsync ( service = > service . SendCodeAsync ( token , CancellationToken . None ) ) ) . Status ) ;
Assert . Equal ( TeamMemberRegistrationStatus . InvalidInvite ,
( await CompleteAsync ( host , token , "Xyz9?q" ) ) . Status ) ;
}
[Fact]
public async Task UnknownExpiredUsedRevokedAndDeactivatedInvites_GetTheSameGenericResponse ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( _ , used ) = await host . AddPendingMemberAsync ( "used@example.com" ) ;
await host . ConfirmEmailAsync ( used , "used@example.com" ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok , ( await CompleteAsync ( host , used , Password ) ) . Status ) ;
var ( revokedUserId , revoked ) = await host . AddPendingMemberAsync ( "revoked@example.com" ) ;
Assert . True ( ( await ResendInviteAsync ( host , revokedUserId ) ) . Success ) ;
var ( deactivatedUserId , deactivated ) = await host . AddPendingMemberAsync ( "deactivated@example.com" ) ;
await host . InScopeAsync ( provider = > provider . GetRequiredService < ApplicationDbContext > ( ) . Users
. Where ( user = > user . Id = = deactivatedUserId )
. ExecuteUpdateAsync ( setters = > setters
. SetProperty ( user = > user . IsDeleted , true )
. SetProperty ( user = > user . UniqueName , "Inactive" ) ) ) ;
var ( _ , expired ) = await host . AddPendingMemberAsync ( "expired@example.com" ) ;
var responses = new List < string >
{
await ResolveResponseAsync ( host , used ) ,
await ResolveResponseAsync ( host , revoked ) ,
await ResolveResponseAsync ( host , deactivated ) ,
await ResolveResponseAsync ( host , TeamMemberInviteSecretsForTests . UnknownToken ( ) ) ,
await ResolveResponseAsync ( host , "" ) ,
await ResolveResponseAsync ( host , new string ( 'a' , 4096 ) )
} ;
host . Time . Advance ( TimeSpan . FromDays ( 7 ) . Add ( TimeSpan . FromSeconds ( 1 ) ) ) ;
responses . Add ( await ResolveResponseAsync ( host , expired ) ) ;
var expected =
"400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}" ;
Assert . All ( responses , response = > Assert . Equal ( expected , response ) ) ;
}
[Fact]
public async Task Invite_ExpiresAfterSevenDays ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( _ , token ) = await host . AddPendingMemberAsync ( Email ) ;
host . Time . Advance ( TimeSpan . FromDays ( 7 ) . Subtract ( TimeSpan . FromSeconds ( 1 ) ) ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok ,
( await host . RegistrationAsync ( service = > service . ResolveAsync ( token , CancellationToken . None ) ) ) . Status ) ;
host . Time . Advance ( TimeSpan . FromSeconds ( 1 ) ) ;
Assert . Equal ( TeamMemberRegistrationStatus . InvalidInvite ,
( await host . RegistrationAsync ( service = > service . ResolveAsync ( token , CancellationToken . None ) ) ) . Status ) ;
}
[Fact]
public async Task Code_IsSixDigitsAndStoredOnlyAsSaltedHash ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
var code = await host . SendCodeAsync ( token , Email ) ;
Assert . Matches ( "^[0-9]{6}$" , code ) ;
var invite = Assert . Single ( await host . InvitesAsync ( userId ) ) ;
Assert . NotNull ( invite . CodeHash ) ;
Assert . NotNull ( invite . CodeSalt ) ;
Assert . NotEqual ( code , invite . CodeHash ) ;
Assert . DoesNotContain ( code , invite . CodeHash ) ;
Assert . Equal ( Sha256Hex ( $"{invite.CodeSalt}:{code}" ) , invite . CodeHash ) ;
Assert . Equal ( host . Time . Now . UtcDateTime . AddMinutes ( 15 ) , invite . CodeExpiresAt ) ;
}
[Fact]
public async Task WrongCodes_AreCountedAndLockAfterFive_UntilANewCodeIsSent ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
var code = await host . SendCodeAsync ( token , Email ) ;
var wrong = code = = "000000" ? "111111" : "000000" ;
for ( var attempt = 1 ; attempt < = 4 ; attempt + + )
{
var outcome = await VerifyAsync ( host , token , wrong ) ;
Assert . Equal ( TeamMemberRegistrationStatus . CodeIncorrect , outcome . Status ) ;
Assert . Equal ( attempt , Assert . Single ( await host . InvitesAsync ( userId ) ) . CodeFailedAttempts ) ;
}
Assert . Equal ( TeamMemberRegistrationStatus . CodeLocked , ( await VerifyAsync ( host , token , wrong ) ) . Status ) ;
Assert . Equal ( TeamMemberRegistrationStatus . CodeLocked , ( await VerifyAsync ( host , token , code ) ) . Status ) ;
Assert . Equal ( 5 , Assert . Single ( await host . InvitesAsync ( userId ) ) . CodeFailedAttempts ) ;
Assert . Null ( Assert . Single ( await host . InvitesAsync ( userId ) ) . EmailConfirmedAt ) ;
host . Time . Advance ( TeamMemberRegistrationService . ResendCooldown ) ;
var fresh = await host . SendCodeAsync ( token , Email ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok , ( await VerifyAsync ( host , token , fresh ) ) . Status ) ;
Assert . NotNull ( Assert . Single ( await host . InvitesAsync ( userId ) ) . EmailConfirmedAt ) ;
}
[Fact]
public async Task AttemptReservation_IsEnforcedByTheDatabaseGuard ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
await host . SendCodeAsync ( token , Email ) ;
var inviteId = Assert . Single ( await host . InvitesAsync ( userId ) ) . Id ;
var granted = 0 ;
for ( var i = 0 ; i < 8 ; i + + )
{
if ( await host . InScopeAsync ( provider = > provider . GetRequiredService < ITeamMemberInviteDataService > ( )
. TryReserveCodeAttemptAsync ( inviteId , TeamMemberRegistrationService . MaxCodeAttempts , host . Time . Now . UtcDateTime , CancellationToken . None ) ) )
granted + + ;
}
Assert . Equal ( TeamMemberRegistrationService . MaxCodeAttempts , granted ) ;
}
[Fact]
public async Task Code_ExpiresAfterFifteenMinutes ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( _ , token ) = await host . AddPendingMemberAsync ( Email ) ;
var code = await host . SendCodeAsync ( token , Email ) ;
host . Time . Advance ( TeamMemberRegistrationService . CodeLifetime ) ;
Assert . Equal ( TeamMemberRegistrationStatus . CodeExpired , ( await VerifyAsync ( host , token , code ) ) . Status ) ;
}
[Fact]
public async Task Resend_IsRateLimitedAndReplacesThePreviousCode ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( _ , token ) = await host . AddPendingMemberAsync ( Email ) ;
var first = await host . SendCodeAsync ( token , Email ) ;
var tooSoon = await host . RegistrationAsync ( service = > service . SendCodeAsync ( token , CancellationToken . None ) ) ;
Assert . Equal ( TeamMemberRegistrationStatus . ResendTooSoon , tooSoon . Status ) ;
Assert . Equal ( 60 , tooSoon . RetryAfterSeconds ) ;
host . Time . Advance ( TimeSpan . FromSeconds ( 45 ) ) ;
var stillTooSoon = await host . RegistrationAsync ( service = > service . SendCodeAsync ( token , CancellationToken . None ) ) ;
Assert . Equal ( TeamMemberRegistrationStatus . ResendTooSoon , stillTooSoon . Status ) ;
Assert . Equal ( 15 , stillTooSoon . RetryAfterSeconds ) ;
Assert . Single ( host . Sent . Messages , message = > message . Subject = = "Your Seahaven confirmation code" ) ;
host . Time . Advance ( TimeSpan . FromSeconds ( 15 ) ) ;
var second = await host . SendCodeAsync ( token , Email ) ;
if ( second ! = first )
Assert . Equal ( TeamMemberRegistrationStatus . CodeIncorrect , ( await VerifyAsync ( host , token , first ) ) . Status ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok , ( await VerifyAsync ( host , token , second ) ) . Status ) ;
}
[Fact]
public async Task Resend_StopsAfterTheSendLimit ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( _ , token ) = await host . AddPendingMemberAsync ( Email ) ;
for ( var send = 0 ; send < TeamMemberRegistrationService . MaxCodeSends ; send + + )
{
await host . SendCodeAsync ( token , Email ) ;
host . Time . Advance ( TeamMemberRegistrationService . ResendCooldown ) ;
}
var refused = await host . RegistrationAsync ( service = > service . SendCodeAsync ( token , CancellationToken . None ) ) ;
Assert . Equal ( TeamMemberRegistrationStatus . ResendLimitReached , refused . Status ) ;
Assert . Equal ( TeamMemberRegistrationService . MaxCodeSends ,
host . Sent . Messages . Count ( message = > message . Subject = = "Your Seahaven confirmation code" ) ) ;
}
2026-09-25 16:40:54 -03:00
[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 ) ;
}
2026-09-25 11:49:10 -03:00
[Fact]
public async Task Complete_RequiresConfirmedEmail ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
await host . SendCodeAsync ( token , Email ) ;
Assert . Equal ( TeamMemberRegistrationStatus . EmailNotConfirmed , ( await CompleteAsync ( host , token , Password ) ) . Status ) ;
var user = await host . ReloadUserAsync ( userId ) ;
Assert . True ( user . PendingRegistration ) ;
Assert . Null ( user . PasswordHash ) ;
}
[Fact]
public async Task Complete_WeakPassword_RollsBackAndARetrySucceeds ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
await host . ConfirmEmailAsync ( token , Email ) ;
Assert . Equal ( TeamMemberRegistrationStatus . PasswordRejected , ( await CompleteAsync ( host , token , "abc12!" ) ) . Status ) ;
var pending = await host . ReloadUserAsync ( userId ) ;
Assert . True ( pending . PendingRegistration ) ;
Assert . False ( pending . EmailConfirmed ) ;
Assert . Null ( pending . PasswordHash ) ;
Assert . Null ( Assert . Single ( await host . InvitesAsync ( userId ) ) . UsedAt ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok , ( await CompleteAsync ( host , token , Password ) ) . Status ) ;
Assert . False ( ( await host . ReloadUserAsync ( userId ) ) . PendingRegistration ) ;
}
2026-09-25 13:05:35 -03:00
[Fact]
public async Task Complete_NonPolicyIdentityFailure_IsNotReportedAsAWeakPasswordAndRollsBack ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( services = >
services . AddScoped < IPasswordValidator < ApplicationUser > , FailingPasswordValidator > ( ) ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
await host . ConfirmEmailAsync ( token , Email ) ;
var failure = await Assert . ThrowsAsync < InvalidOperationException > ( ( ) = > CompleteAsync ( host , token , Password ) ) ;
Assert . Contains ( FailingPasswordValidator . Code , failure . Message ) ;
Assert . DoesNotContain ( Password , failure . Message ) ;
var pending = await host . ReloadUserAsync ( userId ) ;
Assert . True ( pending . PendingRegistration ) ;
Assert . Null ( pending . PasswordHash ) ;
Assert . Null ( Assert . Single ( await host . InvitesAsync ( userId ) ) . UsedAt ) ;
}
/// <summary>Stands in for any Identity failure that is not the password rule itself.</summary>
private sealed class FailingPasswordValidator : IPasswordValidator < ApplicationUser >
{
public const string Code = "ConcurrencyFailure" ;
public Task < IdentityResult > ValidateAsync ( UserManager < ApplicationUser > manager , ApplicationUser user , string? password ) = >
Task . FromResult ( IdentityResult . Failed ( new IdentityError { Code = Code , Description = "Optimistic concurrency failure." } ) ) ;
}
2026-09-25 12:11:52 -03:00
[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 ) ) ;
}
2026-09-25 11:49:10 -03:00
[Fact]
public async Task Complete_InvalidPhone_IsRejectedWithoutConsumingTheInvite ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
await host . ConfirmEmailAsync ( token , Email ) ;
Assert . Equal ( TeamMemberRegistrationStatus . InvalidPhone , ( await CompleteAsync ( host , token , Password , "call me" ) ) . Status ) ;
Assert . Null ( Assert . Single ( await host . InvitesAsync ( userId ) ) . UsedAt ) ;
}
[Fact]
public async Task Invite_OnlyEverChangesItsOwnMember ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( ownerId , ownerToken ) = await host . AddPendingMemberAsync ( Email , "Taylor Reed" ) ;
var ( otherId , otherToken ) = await host . AddPendingMemberAsync ( "jordan@example.com" , "Jordan Lee" ) ;
var otherBefore = await host . ReloadUserAsync ( otherId ) ;
await host . ConfirmEmailAsync ( ownerToken , Email ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok , ( await CompleteAsync ( host , ownerToken , Password , "555-123-4567" ) ) . Status ) ;
Assert . DoesNotContain ( host . Sent . Messages ,
message = > message . To = = "jordan@example.com" & & message . Subject = = "Your Seahaven confirmation code" ) ;
var otherAfter = await host . ReloadUserAsync ( otherId ) ;
Assert . True ( otherAfter . PendingRegistration ) ;
Assert . False ( otherAfter . EmailConfirmed ) ;
Assert . Null ( otherAfter . PasswordHash ) ;
Assert . Equal ( otherBefore . PhoneNumber , otherAfter . PhoneNumber ) ;
Assert . Equal ( otherBefore . SecurityStamp , otherAfter . SecurityStamp ) ;
var otherInvite = Assert . Single ( await host . InvitesAsync ( otherId ) ) ;
Assert . Null ( otherInvite . UsedAt ) ;
Assert . Null ( otherInvite . EmailConfirmedAt ) ;
var otherDetails = await host . RegistrationAsync ( service = > service . ResolveAsync ( otherToken , CancellationToken . None ) ) ;
Assert . Equal ( "Jordan Lee" , otherDetails . Details ! . Name ) ;
Assert . False ( ( await host . ReloadUserAsync ( ownerId ) ) . PendingRegistration ) ;
}
[Fact]
public async Task Reinvite_RevokesOlderTokensAndOnlyAdminsCanSendIt ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , original ) = await host . AddPendingMemberAsync ( Email ) ;
var forbidden = await host . InScopeAsync ( provider = > provider . GetRequiredService < ITeamMemberInviteService > ( )
. ResendAsync ( userId , TeamMemberInviteTestHost . Dispatcher ( ) , CancellationToken . None ) ) ;
Assert . Equal ( "Forbidden" , forbidden . Error ) ;
Assert . True ( ( await ResendInviteAsync ( host , userId ) ) . Success ) ;
var replacement = host . Sent . LatestTokenFor ( Email ) ;
Assert . NotEqual ( original , replacement ) ;
Assert . Equal ( TeamMemberRegistrationStatus . InvalidInvite ,
( await host . RegistrationAsync ( service = > service . ResolveAsync ( original , CancellationToken . None ) ) ) . Status ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok ,
( await host . RegistrationAsync ( service = > service . ResolveAsync ( replacement , CancellationToken . None ) ) ) . Status ) ;
var invites = await host . InvitesAsync ( userId ) ;
Assert . Equal ( 2 , invites . Count ) ;
Assert . NotNull ( invites [ 0 ] . RevokedAt ) ;
Assert . Null ( invites [ 1 ] . RevokedAt ) ;
}
[Fact]
public async Task Reinvite_IsRefusedOnceTheMemberIsActive ( )
{
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 ) ) . Status ) ;
var refused = await ResendInviteAsync ( host , userId ) ;
Assert . False ( refused . Success ) ;
Assert . Equal ( "Only pending team members can be re-invited." , refused . Error ) ;
}
[Fact]
public async Task TokensAndCodes_NeverReachTheLogs ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
var code = await host . SendCodeAsync ( token , Email ) ;
await VerifyAsync ( host , token , code = = "000000" ? "111111" : "000000" ) ;
host . Sent . Succeeds = false ;
host . Time . Advance ( TeamMemberRegistrationService . ResendCooldown ) ;
Assert . Equal ( TeamMemberRegistrationStatus . CodeDeliveryFailed ,
( await host . RegistrationAsync ( service = > service . SendCodeAsync ( token , CancellationToken . None ) ) ) . Status ) ;
Assert . False ( ( await ResendInviteAsync ( host , userId ) ) . Success ) ;
var failedInviteToken = host . Sent . LatestTokenFor ( Email ) ;
host . Sent . Succeeds = true ;
Assert . True ( ( await ResendInviteAsync ( host , userId ) ) . Success ) ;
var liveToken = host . Sent . LatestTokenFor ( Email ) ;
await host . ConfirmEmailAsync ( liveToken , Email ) ;
var liveCode = host . Sent . LatestCodeFor ( Email ) ;
Assert . Equal ( TeamMemberRegistrationStatus . Ok , ( await CompleteAsync ( host , liveToken , Password ) ) . Status ) ;
Assert . NotEmpty ( host . Logged . Entries ) ;
Assert . Contains ( host . Logged . Entries , entry = > entry . Contains ( "could not be sent" , StringComparison . Ordinal ) ) ;
foreach ( var secret in new [ ] { token , failedInviteToken , liveToken , code , liveCode , Password } )
Assert . DoesNotContain ( host . Logged . Entries , entry = > entry . Contains ( secret , StringComparison . Ordinal ) ) ;
}
[Fact]
public async Task RequestObjects_DoNotPrintTheirSecrets ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( _ , token ) = await host . AddPendingMemberAsync ( Email ) ;
var printed = string . Join ( " " ,
new TeamMemberInviteTokenRequestDTO { Token = token } . ToString ( ) ,
new VerifyTeamMemberInviteCodeRequestDTO { Token = token , Code = "123456" } . ToString ( ) ,
new CompleteTeamMemberRegistrationRequestDTO { Token = token , Password = Password } . ToString ( ) ,
new IssuedTeamMemberInvite ( token , DateTime . UtcNow ) . ToString ( ) ) ;
Assert . DoesNotContain ( token , printed ) ;
Assert . DoesNotContain ( "123456" , printed ) ;
Assert . DoesNotContain ( Password , printed ) ;
}
[Fact]
public async Task CancelledRequests_StopBeforeChangingAnything ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
using var cancelled = new CancellationTokenSource ( ) ;
await cancelled . CancelAsync ( ) ;
await Assert . ThrowsAnyAsync < OperationCanceledException > ( ( ) = >
host . RegistrationAsync ( service = > service . SendCodeAsync ( token , cancelled . Token ) ) ) ;
await Assert . ThrowsAnyAsync < OperationCanceledException > ( ( ) = > host . RegistrationAsync ( service = >
service . CompleteAsync ( new CompleteTeamMemberRegistrationRequestDTO { Token = token , Password = Password } , cancelled . Token ) ) ) ;
Assert . DoesNotContain ( host . Sent . Messages , message = > message . Subject = = "Your Seahaven confirmation code" ) ;
Assert . Equal ( 0 , Assert . Single ( await host . InvitesAsync ( userId ) ) . CodeSendCount ) ;
Assert . True ( ( await host . ReloadUserAsync ( userId ) ) . PendingRegistration ) ;
}
[Fact]
public async Task Controller_MapsCodeFailuresToClearMessagesAndRetryAfter ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( _ , token ) = await host . AddPendingMemberAsync ( Email ) ;
var code = await host . SendCodeAsync ( token , Email ) ;
var wrong = await InvokeControllerAsync ( host , controller = > controller . VerifyCode (
new VerifyTeamMemberInviteCodeRequestDTO { Token = token , Code = code = = "000000" ? "111111" : "000000" } ,
CancellationToken . None ) ) ;
Assert . Equal (
"400 {\"code\":\"code_incorrect\",\"message\":\"Incorrect code \\u2014 check your email and try again\",\"retryAfterSeconds\":null}" ,
wrong . Body ) ;
var tooSoon = await InvokeControllerAsync ( host , controller = > controller . SendCode (
new TeamMemberInviteTokenRequestDTO { Token = token } , CancellationToken . None ) ) ;
Assert . StartsWith ( "429 {\"code\":\"resend_too_soon\"" , tooSoon . Body ) ;
Assert . Equal ( "60" , tooSoon . RetryAfter ) ;
}
[Fact]
public async Task Controller_CompleteReturnsTheLoginPayloadShape ( )
{
await using var host = await TeamMemberInviteTestHost . CreateAsync ( ) ;
var ( userId , token ) = await host . AddPendingMemberAsync ( Email ) ;
await host . ConfirmEmailAsync ( token , Email ) ;
var response = await InvokeControllerAsync ( host , controller = > controller . Complete (
new CompleteTeamMemberRegistrationRequestDTO { Token = token , Password = Password , Phone = "555-123-4567" } ,
CancellationToken . None ) ) ;
Assert . StartsWith ( "200 " , response . Body ) ;
using var payload = JsonDocument . Parse ( response . Body [ 4. . ] ) ;
var keys = payload . RootElement . EnumerateObject ( ) . Select ( property = > property . Name ) . ToArray ( ) ;
Assert . Equal ( new [ ] { "token" , "expiration" , "email" , "userRoles" , "phoneNumber" , "fullname" , "id" } , keys ) ;
Assert . Equal ( userId , payload . RootElement . GetProperty ( "id" ) . GetString ( ) ) ;
Assert . Equal ( "555-123-4567" , payload . RootElement . GetProperty ( "phoneNumber" ) . GetString ( ) ) ;
}
private static Task < TeamMemberRegistrationOutcomeDTO > VerifyAsync ( TeamMemberInviteTestHost host , string token , string code ) = >
host . RegistrationAsync ( service = > service . VerifyCodeAsync ( token , code , CancellationToken . None ) ) ;
private static Task < TeamMemberRegistrationOutcomeDTO > CompleteAsync (
TeamMemberInviteTestHost host ,
string token ,
string password ,
string? phone = null ) = >
host . RegistrationAsync ( service = > service . CompleteAsync (
new CompleteTeamMemberRegistrationRequestDTO { Token = token , Password = password , Phone = phone } ,
CancellationToken . None ) ) ;
private static Task < TeamMemberInviteResendOutcomeDTO > ResendInviteAsync ( TeamMemberInviteTestHost host , string userId ) = >
host . InScopeAsync ( provider = > provider . GetRequiredService < ITeamMemberInviteService > ( )
. ResendAsync ( userId , TeamMemberInviteTestHost . Admin ( ) , CancellationToken . None ) ) ;
private static async Task < string > ResolveResponseAsync ( TeamMemberInviteTestHost host , string token ) = >
( await InvokeControllerAsync ( host , controller = > controller . Resolve (
new TeamMemberInviteTokenRequestDTO { Token = token } , CancellationToken . None ) ) ) . Body ;
private static Task < ( string Body , string? RetryAfter ) > InvokeControllerAsync (
TeamMemberInviteTestHost host ,
Func < TeamMemberInviteController , Task < IActionResult > > action ) = >
host . InScopeAsync ( async provider = >
{
var httpContext = new DefaultHttpContext ( ) ;
var controller = new TeamMemberInviteController ( provider . GetRequiredService < ITeamMemberRegistrationService > ( ) )
{
ControllerContext = new ControllerContext { HttpContext = httpContext }
} ;
var result = Assert . IsAssignableFrom < ObjectResult > ( await action ( controller ) ) ;
var status = result . StatusCode ? ? StatusCodes . Status200OK ;
var retryAfter = httpContext . Response . Headers . RetryAfter . ToString ( ) ;
return ( $"{status} {JsonSerializer.Serialize(result.Value)}" , string . IsNullOrEmpty ( retryAfter ) ? null : retryAfter ) ;
} ) ;
private static byte [ ] Base64UrlDecode ( string value )
{
var padded = value . Replace ( '-' , '+' ) . Replace ( '_' , '/' ) ;
padded + = new string ( '=' , ( 4 - padded . Length % 4 ) % 4 ) ;
return Convert . FromBase64String ( padded ) ;
}
private static string Sha256Hex ( string value ) = >
Convert . ToHexString ( SHA256 . HashData ( Encoding . UTF8 . GetBytes ( value ) ) ) . ToLowerInvariant ( ) ;
private static class TeamMemberInviteSecretsForTests
{
public static string UnknownToken ( ) = >
Convert . ToBase64String ( RandomNumberGenerator . GetBytes ( 32 ) ) . TrimEnd ( '=' ) . Replace ( '+' , '-' ) . Replace ( '/' , '_' ) ;
}
}