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; 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; 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); Assert.Contains(" 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); Assert.Equal("Taylor Reed", completed.Session.Fullname); 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>().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().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 { 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() .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")); } [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() { 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); } [Fact] public async Task Complete_NonPolicyIdentityFailure_IsNotReportedAsAWeakPasswordAndRollsBack() { await using var host = await TeamMemberInviteTestHost.CreateAsync(services => services.AddScoped, FailingPasswordValidator>()); var (userId, token) = await host.AddPendingMemberAsync(Email); await host.ConfirmEmailAsync(token, Email); var failure = await Assert.ThrowsAsync(() => 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); } /// Stands in for any Identity failure that is not the password rule itself. private sealed class FailingPasswordValidator : IPasswordValidator { public const string Code = "ConcurrencyFailure"; public Task ValidateAsync(UserManager manager, ApplicationUser user, string? password) => Task.FromResult(IdentityResult.Failed(new IdentityError { Code = Code, Description = "Optimistic concurrency failure." })); } [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().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] 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() .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(() => host.RegistrationAsync(service => service.SendCodeAsync(token, cancelled.Token))); await Assert.ThrowsAnyAsync(() => 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 VerifyAsync(TeamMemberInviteTestHost host, string token, string code) => host.RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None)); private static Task 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 ResendInviteAsync(TeamMemberInviteTestHost host, string userId) => host.InScopeAsync(provider => provider.GetRequiredService() .ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None)); private static async Task 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> action) => host.InScopeAsync(async provider => { var httpContext = new DefaultHttpContext(); var controller = new TeamMemberInviteController(provider.GetRequiredService()) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; var result = Assert.IsAssignableFrom(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('/', '_'); } }