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 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); } [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); 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")); } [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_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('/', '_'); } }