shoc-backend/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs

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