mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
523 lines
25 KiB
C#
523 lines
25 KiB
C#
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<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"));
|
|
}
|
|
|
|
[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<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('/', '_');
|
|
}
|
|
}
|