mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
SendCodeAsync validates the invite, then starts the code with a conditional update that also requires the invite to still be open. When an admin revoked the link (or it was used) between those two reads, the refusal was reported as resend_too_soon with a Retry-After, although the link was already dead. On a refused start the invite is now read again: a closed invite gets the same generic invalid_invite response as any other dead link, and a cooldown or send limit refusal is computed from the fresh row.
675 lines
33 KiB
C#
675 lines
33 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 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("<a clicktracking=off href=", 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.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<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"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("revoked")]
|
|
[InlineData("used")]
|
|
public async Task SendCode_ForAnInviteClosedMidRequest_GetsTheGenericInvalidInviteResponse(string closedBy)
|
|
{
|
|
await using var host = await TeamMemberInviteTestHost.CreateAsync(services =>
|
|
services.Replace(ServiceDescriptor.Scoped<ITeamMemberInviteDataService>(provider =>
|
|
new ClosesInviteBeforeCodeStart(
|
|
new TeamMemberInviteDataService(provider.GetRequiredService<ApplicationDbContext>()),
|
|
provider.GetRequiredService<ApplicationDbContext>(),
|
|
closedBy))));
|
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
|
|
|
var response = await InvokeControllerAsync(host, controller => controller.SendCode(
|
|
new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None));
|
|
|
|
Assert.Equal(
|
|
"400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}",
|
|
response.Body);
|
|
Assert.Null(response.RetryAfter);
|
|
Assert.DoesNotContain(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code");
|
|
Assert.Equal(0, Assert.Single(await host.InvitesAsync(userId)).CodeSendCount);
|
|
}
|
|
|
|
/// <summary>Closes the invite after the service has read it and before the conditional code start runs.</summary>
|
|
private sealed class ClosesInviteBeforeCodeStart : ITeamMemberInviteDataService
|
|
{
|
|
private readonly ITeamMemberInviteDataService _inner;
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly string _closedBy;
|
|
|
|
public ClosesInviteBeforeCodeStart(ITeamMemberInviteDataService inner, ApplicationDbContext context, string closedBy)
|
|
{
|
|
_inner = inner;
|
|
_context = context;
|
|
_closedBy = closedBy;
|
|
}
|
|
|
|
public async Task<bool> TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken)
|
|
{
|
|
var invite = _context.TeamMemberInvites.Where(existing => existing.Id == command.InviteId);
|
|
if (_closedBy == "revoked")
|
|
await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.RevokedAt, command.Now), cancellationToken);
|
|
else
|
|
await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.UsedAt, command.Now), cancellationToken);
|
|
return await _inner.TryStartCodeAsync(command, cancellationToken);
|
|
}
|
|
|
|
public Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken) =>
|
|
_inner.AddAsync(invite, cancellationToken);
|
|
|
|
public Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken) =>
|
|
_inner.ReplaceOpenForUserAsync(invite, now, cancellationToken);
|
|
|
|
public Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) =>
|
|
_inner.GetByTokenHashAsync(tokenHash, cancellationToken);
|
|
|
|
public Task<bool> TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken) =>
|
|
_inner.TryReserveCodeAttemptAsync(inviteId, maxAttempts, now, cancellationToken);
|
|
|
|
public Task<bool> TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken) =>
|
|
_inner.TryConfirmEmailAsync(inviteId, codeHash, now, cancellationToken);
|
|
|
|
public Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken) =>
|
|
_inner.TryClaimAsync(inviteId, now, cancellationToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Complete_RequiresConfirmedEmail()
|
|
{
|
|
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<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." }));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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('/', '_');
|
|
}
|
|
}
|