diff --git a/src/AdminConsole/EventLog/Loggers/IEventLogger.cs b/src/AdminConsole/EventLog/Loggers/IEventLogger.cs
index e6fbaef48..ef5b7dd19 100644
--- a/src/AdminConsole/EventLog/Loggers/IEventLogger.cs
+++ b/src/AdminConsole/EventLog/Loggers/IEventLogger.cs
@@ -97,7 +97,7 @@ public static void LogAdminInvalidInviteUsedEvent(
logger.LogEvent(
new(invite.ToEmail,
EventType.AdminInvalidInviteUsed,
- "Expired invite used.",
+ "Invalid/expired invite used.",
Severity.Warning,
invite.ToEmail,
invite.TargetOrgId,
diff --git a/src/AdminConsole/Helpers/InviteExtensions.cs b/src/AdminConsole/Helpers/InviteExtensions.cs
new file mode 100644
index 000000000..d87d61e2f
--- /dev/null
+++ b/src/AdminConsole/Helpers/InviteExtensions.cs
@@ -0,0 +1,9 @@
+using Passwordless.AdminConsole.Identity;
+
+namespace Passwordless.AdminConsole.Helpers;
+
+public static class InviteExtensions
+{
+ public static bool IsExpired(this Invite invite, TimeProvider timeProvider) =>
+ invite.ExpireAt < timeProvider.GetUtcNow().UtcDateTime;
+}
\ No newline at end of file
diff --git a/src/AdminConsole/Pages/Organization/Join.cshtml b/src/AdminConsole/Pages/Organization/Join.cshtml
index 12a2d274d..53aabb141 100644
--- a/src/AdminConsole/Pages/Organization/Join.cshtml
+++ b/src/AdminConsole/Pages/Organization/Join.cshtml
@@ -1,6 +1,6 @@
@page
@using Microsoft.AspNetCore.Authorization
-@model Passwordless.AdminConsole.Pages.Organization.Join
+@model Join
@attribute [AllowAnonymous]
@{
ViewBag.Title = "Join an organization";
@@ -30,7 +30,7 @@ else
diff --git a/src/AdminConsole/Pages/Organization/Join.cshtml.cs b/src/AdminConsole/Pages/Organization/Join.cshtml.cs
index 38222e3fc..07e399055 100644
--- a/src/AdminConsole/Pages/Organization/Join.cshtml.cs
+++ b/src/AdminConsole/Pages/Organization/Join.cshtml.cs
@@ -33,12 +33,12 @@ public Join(IInvitationService invitationService,
_timeProvider = timeProvider;
}
- public Invite Invite { get; set; }
+ public Invite? Invite { get; set; }
public JoinForm Form { get; set; }
public async Task OnGet(string code)
{
- if (User.Identity.IsAuthenticated)
+ if (User.Identity?.IsAuthenticated == true)
{
return RedirectToPage("JoinBusy", new { code = code });
}
@@ -52,11 +52,12 @@ public async Task OnGet(string code)
Invite = null;
}
- if (Invite == null)
+ if (Invite is null)
{
ModelState.AddModelError("bad-invite", "Invite is invalid or expired");
return Page();
}
+
// todo: We could add a check if the email is busy here and if so show a message.
Form = new JoinForm { Code = code, Email = Invite.ToEmail };
@@ -77,24 +78,30 @@ public async Task OnPost(JoinForm form)
return Page();
}
- Invite invite = await _invitationService.GetInviteFromRawCodeAsync(form.Code);
- var ok = await _invitationService.ConsumeInviteAsync(invite);
+ var invite = await _invitationService.GetInviteFromRawCodeAsync(form.Code);
- if (!ok)
+ if (invite is null)
+ {
+ ModelState.AddModelError("bad-invite", "Invite is invalid or expired");
+ return Page();
+ }
+
+ if (!await _invitationService.ConsumeInviteAsync(invite))
{
_eventLogger.LogAdminInvalidInviteUsedEvent(invite, _timeProvider.GetUtcNow().UtcDateTime);
ModelState.AddModelError("bad-invite", "Invite is invalid or expired");
+ return Page();
}
- ConsoleAdmin? existingUser = await _userManager.FindByEmailAsync(form.Email);
+ ConsoleAdmin? existingUser = await _userManager.FindByEmailAsync(invite.ToEmail);
if (existingUser == null)
{
// create account
var user = new ConsoleAdmin
{
- UserName = form.Email,
- Email = form.Email,
+ UserName = invite.ToEmail,
+ Email = invite.ToEmail,
OrganizationId = invite.TargetOrgId,
Name = form.Name
};
@@ -108,7 +115,7 @@ public async Task OnPost(JoinForm form)
}
else
{
- await _mailService.SendEmailIsAlreadyInUseAsync(existingUser.Email);
+ await _mailService.SendEmailIsAlreadyInUseAsync(existingUser.Email!);
}
return Redirect("/Organization/Verify");
diff --git a/src/AdminConsole/Services/IInvitationService.cs b/src/AdminConsole/Services/IInvitationService.cs
index 710b1f8b6..ce36c2d51 100644
--- a/src/AdminConsole/Services/IInvitationService.cs
+++ b/src/AdminConsole/Services/IInvitationService.cs
@@ -7,6 +7,7 @@ public interface IInvitationService
Task SendInviteAsync(string toEmail, int targetOrgId, string targetOrgName, string fromEmail, string fromName);
Task> GetInvitesAsync(int orgId);
Task CancelInviteAsync(Invite inviteToCancel);
- Task GetInviteFromRawCodeAsync(string code);
+ Task GetInviteFromRawCodeAsync(string code);
+ Task RemoveExpiredInviteAsync(Invite invite);
Task ConsumeInviteAsync(Invite inv);
}
\ No newline at end of file
diff --git a/src/AdminConsole/Services/InvitationService.cs b/src/AdminConsole/Services/InvitationService.cs
index fe8afb5b1..a29ed1afb 100644
--- a/src/AdminConsole/Services/InvitationService.cs
+++ b/src/AdminConsole/Services/InvitationService.cs
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Passwordless.AdminConsole.Db;
+using Passwordless.AdminConsole.Helpers;
using Passwordless.AdminConsole.Identity;
using Passwordless.AdminConsole.Services.Mail;
using Passwordless.Common.Extensions;
@@ -12,12 +13,18 @@ public class InvitationService : IInvitationService
private readonly ConsoleDbContext _db;
private readonly IMailService _mailService;
private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly TimeProvider _timeProvider;
- public InvitationService(ConsoleDbContext db, IMailService mailService, IHttpContextAccessor httpContextAccessor)
+ public InvitationService(
+ ConsoleDbContext db,
+ IMailService mailService,
+ IHttpContextAccessor httpContextAccessor,
+ TimeProvider timeProvider)
{
_db = db;
_mailService = mailService;
_httpContextAccessor = httpContextAccessor;
+ _timeProvider = timeProvider;
}
public async Task SendInviteAsync(string toEmail, int targetOrgId, string targetOrgName, string fromEmail, string fromName)
@@ -38,7 +45,7 @@ public async Task SendInviteAsync(string toEmail, int targetOrgId, string target
var hashedCode = HashCode(code);
inv.HashedCode = hashedCode;
- inv.CreatedAt = DateTime.UtcNow;
+ inv.CreatedAt = _timeProvider.GetUtcNow().UtcDateTime;
inv.ExpireAt = inv.CreatedAt.AddDays(7);
// store
@@ -72,29 +79,33 @@ public async Task CancelInviteAsync(Invite inviteToCancel)
await _db.Invites.Where(i => i.HashedCode == inviteToCancel.HashedCode).ExecuteDeleteAsync();
}
- public async Task GetInviteFromRawCodeAsync(string code)
+ public async Task GetInviteFromRawCodeAsync(string code)
{
var hashed = HashCode(code);
- return await _db.Invites.Where(i => i.HashedCode == hashed).FirstOrDefaultAsync();
+ return await _db.Invites
+ .Where(i => i.HashedCode == hashed)
+ .FirstOrDefaultAsync();
}
- public async Task ConsumeInviteAsync(Invite inv)
+ public async Task RemoveExpiredInviteAsync(Invite invite)
{
- if (inv == null)
+ if (!invite.IsExpired(_timeProvider))
{
return false;
}
- // check if expired
- if (inv.ExpireAt < DateTime.UtcNow)
- {
- return false;
- }
+ _db.Invites.Remove(invite);
+ await _db.SaveChangesAsync();
+ return true;
+ }
+
+ public async Task ConsumeInviteAsync(Invite inv)
+ {
+ var isValid = !inv.IsExpired(_timeProvider);
- // delete it
_db.Invites.Remove(inv);
await _db.SaveChangesAsync();
- return true;
+ return isValid;
}
}
\ No newline at end of file
diff --git a/tests/AdminConsole.Tests/AdminConsole.Tests.csproj b/tests/AdminConsole.Tests/AdminConsole.Tests.csproj
index 189a664de..83da87a8c 100644
--- a/tests/AdminConsole.Tests/AdminConsole.Tests.csproj
+++ b/tests/AdminConsole.Tests/AdminConsole.Tests.csproj
@@ -5,6 +5,7 @@
+
@@ -22,4 +23,4 @@
-
+
\ No newline at end of file
diff --git a/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs b/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs
index 4551771c7..5be47bf9c 100644
--- a/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs
+++ b/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs
@@ -15,11 +15,15 @@ public class FakeMagicLinkSignInManager : MagicLinkSignInManager
public const string SuccessToken = "successtoken";
public const string FailToken = "failtoken";
- public FakeMagicLinkSignInManager()
+ public List SentEmails { get; } = new();
+
+ public FakeMagicLinkSignInManager() : this(new FakeUserManager()) { }
+
+ public FakeMagicLinkSignInManager(UserManager userManager)
: base(
new Mock().Object,
new Mock().Object,
- new FakeUserManager(),
+ userManager,
new Mock().Object,
new Mock>().Object,
new Mock>().Object,
@@ -41,4 +45,10 @@ public override async Task PasswordlessSignInAsync(string token, b
return SignInResult.Failed;
}
}
+
+ public override Task SendEmailForSignInAsync(string email, string? returnUrl)
+ {
+ SentEmails.Add(email);
+ return Task.CompletedTask;
+ }
}
\ No newline at end of file
diff --git a/tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs b/tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs
index 4ac3c4673..483e8741e 100644
--- a/tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs
+++ b/tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs
@@ -8,6 +8,9 @@ namespace Passwordless.AdminConsole.Tests.DataFactory;
public class FakeUserManager : UserManager
{
+ public List CreatedUsers { get; } = new();
+ private readonly Dictionary _byEmail = new(StringComparer.OrdinalIgnoreCase);
+
public FakeUserManager()
: base(new Mock>().Object,
new Mock>().Object,
@@ -20,6 +23,19 @@ public FakeUserManager()
new Mock>>().Object)
{ }
+ public override Task FindByEmailAsync(string email) =>
+ Task.FromResult(_byEmail.TryGetValue(email, out var u) ? u : null);
+
+ public override Task CreateAsync(ConsoleAdmin user)
+ {
+ CreatedUsers.Add(user);
+ if (!string.IsNullOrEmpty(user.Email))
+ {
+ _byEmail[user.Email] = user;
+ }
+ return Task.FromResult(IdentityResult.Success);
+ }
+
public override Task CreateAsync(ConsoleAdmin user, string password)
{
return Task.FromResult(IdentityResult.Success);
diff --git a/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs b/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs
new file mode 100644
index 000000000..4857c8ae5
--- /dev/null
+++ b/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs
@@ -0,0 +1,244 @@
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Routing;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Time.Testing;
+using Moq;
+using Passwordless.AdminConsole.Db;
+using Passwordless.AdminConsole.EventLog.DTOs;
+using Passwordless.AdminConsole.EventLog.Loggers;
+using Passwordless.AdminConsole.Identity;
+using Passwordless.AdminConsole.Pages.Organization;
+using Passwordless.AdminConsole.Services;
+using Passwordless.AdminConsole.Services.Mail;
+using Passwordless.AdminConsole.Tests.DataFactory;
+using Passwordless.AdminConsole.Tests.Factory;
+using Xunit;
+
+namespace Passwordless.AdminConsole.Tests.Pages.Organization;
+
+///
+/// Regression tests for VULN-548 — exercises the real page model with the real
+/// against an in-memory . Covers the
+/// invariants the remediation has to hold: expired invites are rejected and removed, the form's
+/// email is never trusted (the invite's ToEmail is authoritative), and replays of a
+/// consumed/removed invite cannot create another admin.
+///
+public class JoinTests : IDisposable, IAsyncDisposable
+{
+ private readonly ConsoleDbContext _dbContext;
+ private readonly Mock _mailServiceMock = new();
+ private readonly Mock _httpContextAccessorMock = new();
+ private readonly Mock _eventLoggerMock = new();
+ private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero));
+ private readonly FakeUserManager _userManager;
+ private readonly FakeMagicLinkSignInManager _magicLinkSignInManager;
+ private readonly InvitationService _invitationService;
+
+ public JoinTests()
+ {
+ _dbContext = DbContextFactory.Create();
+ _invitationService = new InvitationService(
+ _dbContext,
+ _mailServiceMock.Object,
+ _httpContextAccessorMock.Object,
+ _timeProvider);
+ _userManager = new FakeUserManager();
+ _magicLinkSignInManager = new FakeMagicLinkSignInManager(_userManager);
+ }
+
+ [Fact]
+ public async Task OnGet_LiveInvite_PopulatesFormAndInvite()
+ {
+ var (rawCode, hashedCode) = GenerateCode();
+ SeedInvite(hashedCode, "user@example.com", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(7));
+ var sut = CreateJoin();
+
+ var result = await sut.OnGet(rawCode);
+
+ Assert.IsType(result);
+ Assert.False(sut.ModelState.ContainsKey("bad-invite"));
+ Assert.NotNull(sut.Invite);
+ Assert.Equal("user@example.com", sut.Invite!.ToEmail);
+ Assert.Equal(rawCode, sut.Form.Code);
+ Assert.Equal("user@example.com", sut.Form.Email);
+ Assert.NotEmpty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+ }
+
+ [Fact]
+ public async Task OnGet_ExpiredInvite_RendersFormAndLeavesInviteForOnPostToReject()
+ {
+ var (rawCode, hashedCode) = GenerateCode();
+ SeedInvite(hashedCode, "user@example.com", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(-1));
+ var sut = CreateJoin();
+
+ var result = await sut.OnGet(rawCode);
+
+ Assert.IsType(result);
+ Assert.False(sut.ModelState.ContainsKey("bad-invite"));
+ Assert.NotNull(sut.Invite);
+ Assert.NotEmpty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+ _eventLoggerMock.Verify(x => x.LogEvent(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task OnGet_UnknownCode_RendersBadInvite()
+ {
+ var (otherRawCode, _) = GenerateCode();
+ var sut = CreateJoin();
+
+ var result = await sut.OnGet(otherRawCode);
+
+ Assert.IsType(result);
+ Assert.True(sut.ModelState.ContainsKey("bad-invite"));
+ Assert.Null(sut.Invite);
+ _eventLoggerMock.Verify(x => x.LogEvent(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task OnPost_LiveInvite_FormEmailIsIgnored_AdminCreatedFromInviteEmail()
+ {
+ var (rawCode, hashedCode) = GenerateCode();
+ SeedInvite(hashedCode, "pjfry@planet.express", targetOrgId: 42, expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(7));
+ var sut = CreateJoin();
+
+ var result = await sut.OnPost(new Join.JoinForm
+ {
+ Code = rawCode,
+ Email = "flexo@other.com",
+ Name = "Flexo",
+ AcceptsTermsAndPrivacy = true
+ });
+
+ var redirect = Assert.IsType(result);
+ Assert.Equal("/Organization/Verify", redirect.Url);
+ var created = Assert.Single(_userManager.CreatedUsers);
+ Assert.Equal("pjfry@planet.express", created.Email);
+ Assert.Equal("pjfry@planet.express", created.UserName);
+ Assert.Equal(42, created.OrganizationId);
+ Assert.Equal("pjfry@planet.express", Assert.Single(_magicLinkSignInManager.SentEmails));
+ Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+ }
+
+ [Fact]
+ public async Task OnPost_ExpiredInvite_DoesNotCreateAdmin_AndDeletesInvite()
+ {
+ var (rawCode, hashedCode) = GenerateCode();
+ SeedInvite(hashedCode, "pjfry@planet.express", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30));
+ var sut = CreateJoin();
+
+ var result = await sut.OnPost(new Join.JoinForm
+ {
+ Code = rawCode,
+ Email = "pjfry@planet.express",
+ Name = "Philip",
+ AcceptsTermsAndPrivacy = true
+ });
+
+ Assert.IsType(result);
+ Assert.True(sut.ModelState.ContainsKey("bad-invite"));
+ Assert.Empty(_userManager.CreatedUsers);
+ Assert.Empty(_magicLinkSignInManager.SentEmails);
+ Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+ }
+
+ [Fact]
+ public async Task OnPost_ExpiredInvite_Replay_FindsNoInvite()
+ {
+ var (rawCode, hashedCode) = GenerateCode();
+ SeedInvite(hashedCode, "pjfry@planet.express", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30));
+ var firstAttempt = CreateJoin();
+
+ await firstAttempt.OnPost(new Join.JoinForm
+ {
+ Code = rawCode,
+ Email = "pjfry@planet.express",
+ Name = "Philip",
+ AcceptsTermsAndPrivacy = true
+ });
+ Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+
+ var secondAttempt = CreateJoin();
+ var result = await secondAttempt.OnPost(new Join.JoinForm
+ {
+ Code = rawCode,
+ Email = "pjfry@planet.express",
+ Name = "Philip",
+ AcceptsTermsAndPrivacy = true
+ });
+
+ Assert.IsType(result);
+ Assert.True(secondAttempt.ModelState.ContainsKey("bad-invite"));
+ Assert.Empty(_userManager.CreatedUsers);
+ Assert.Empty(_magicLinkSignInManager.SentEmails);
+ }
+
+ private Join CreateJoin()
+ {
+ var join = new Join(
+ _invitationService,
+ _userManager,
+ _magicLinkSignInManager,
+ _mailServiceMock.Object,
+ _eventLoggerMock.Object,
+ _timeProvider);
+
+ var httpContext = new DefaultHttpContext();
+ var actionContext = new ActionContext(
+ httpContext,
+ new RouteData(),
+ new PageActionDescriptor(),
+ new ModelStateDictionary());
+ join.PageContext = new PageContext(actionContext)
+ {
+ ViewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary())
+ };
+ join.Url = new StubUrlHelper(actionContext);
+
+ return join;
+ }
+
+ private sealed class StubUrlHelper : IUrlHelper
+ {
+ public StubUrlHelper(ActionContext actionContext) => ActionContext = actionContext;
+
+ public ActionContext ActionContext { get; }
+
+ public string Action(UrlActionContext actionContext) => "/stub";
+ public string Content(string? contentPath) => contentPath ?? "/stub";
+ public bool IsLocalUrl(string? url) => true;
+ public string Link(string? routeName, object? values) => "/stub";
+ public string RouteUrl(UrlRouteContext routeContext) => "/stub";
+ }
+
+ private void SeedInvite(string hashedCode, string toEmail, DateTime expireAtUtc, int targetOrgId = 1)
+ {
+ _dbContext.Invites.Add(new Invite
+ {
+ HashedCode = hashedCode,
+ ToEmail = toEmail,
+ TargetOrgId = targetOrgId,
+ TargetOrgName = "ExampleOrg",
+ FromEmail = "admin@example.com",
+ FromName = "Admin",
+ CreatedAt = expireAtUtc.AddDays(-7),
+ ExpireAt = expireAtUtc
+ });
+ _dbContext.SaveChanges();
+ }
+
+ private static (string rawCode, string hashedCode) GenerateCode()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(32);
+ return (Convert.ToBase64String(bytes), Convert.ToBase64String(SHA256.HashData(bytes)));
+ }
+
+ public void Dispose() => _dbContext.Dispose();
+
+ public ValueTask DisposeAsync() => _dbContext.DisposeAsync();
+}
\ No newline at end of file
diff --git a/tests/AdminConsole.Tests/Services/InvitationServiceTests.cs b/tests/AdminConsole.Tests/Services/InvitationServiceTests.cs
new file mode 100644
index 000000000..f54501a11
--- /dev/null
+++ b/tests/AdminConsole.Tests/Services/InvitationServiceTests.cs
@@ -0,0 +1,211 @@
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Http;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Time.Testing;
+using Moq;
+using Passwordless.AdminConsole.Db;
+using Passwordless.AdminConsole.Identity;
+using Passwordless.AdminConsole.Services;
+using Passwordless.AdminConsole.Services.Mail;
+using Passwordless.AdminConsole.Tests.Factory;
+using Xunit;
+
+namespace Passwordless.AdminConsole.Tests.Services;
+
+public class InvitationServiceTests : IDisposable, IAsyncDisposable
+{
+ private readonly ConsoleDbContext _dbContext;
+ private readonly Mock _mailServiceMock = new();
+ private readonly Mock _httpContextAccessorMock = new();
+ private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero));
+
+ private readonly InvitationService _sut;
+
+ public InvitationServiceTests()
+ {
+ _dbContext = DbContextFactory.Create();
+ _sut = new InvitationService(_dbContext, _mailServiceMock.Object, _httpContextAccessorMock.Object, _timeProvider);
+ }
+
+ [Fact]
+ public async Task ConsumeInviteAsync_WhenInviteIsLive_ReturnsTrueAndDeletesInvite()
+ {
+ var (_, hashedCode) = GenerateCode();
+ var invite = new Invite
+ {
+ HashedCode = hashedCode,
+ ToEmail = "user@example.com",
+ TargetOrgId = 1,
+ TargetOrgName = "ExampleOrg",
+ FromEmail = "admin@example.com",
+ FromName = "Admin",
+ CreatedAt = _timeProvider.GetUtcNow().UtcDateTime,
+ ExpireAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)
+ };
+ _dbContext.Invites.Add(invite);
+ await _dbContext.SaveChangesAsync();
+
+ var result = await _sut.ConsumeInviteAsync(invite);
+
+ Assert.True(result);
+ Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+ }
+
+ [Fact]
+ public async Task ConsumeInviteAsync_WhenInviteIsExpired_ReturnsFalseAndDeletesInvite()
+ {
+ var (_, hashedCode) = GenerateCode();
+ var invite = new Invite
+ {
+ HashedCode = hashedCode,
+ ToEmail = "user@example.com",
+ TargetOrgId = 1,
+ TargetOrgName = "ExampleOrg",
+ FromEmail = "admin@example.com",
+ FromName = "Admin",
+ CreatedAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30),
+ ExpireAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-1)
+ };
+ _dbContext.Invites.Add(invite);
+ await _dbContext.SaveChangesAsync();
+
+ var result = await _sut.ConsumeInviteAsync(invite);
+
+ Assert.False(result);
+ Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+ }
+
+ [Fact]
+ public async Task GetInviteFromRawCodeAsync_WhenInviteIsLive_ReturnsInvite()
+ {
+ var (rawCode, hashedCode) = GenerateCode();
+ var invite = new Invite
+ {
+ HashedCode = hashedCode,
+ ToEmail = "user@example.com",
+ TargetOrgId = 1,
+ TargetOrgName = "ExampleOrg",
+ FromEmail = "admin@example.com",
+ FromName = "Admin",
+ CreatedAt = _timeProvider.GetUtcNow().UtcDateTime,
+ ExpireAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)
+ };
+ _dbContext.Invites.Add(invite);
+ await _dbContext.SaveChangesAsync();
+
+ var result = await _sut.GetInviteFromRawCodeAsync(rawCode);
+
+ Assert.NotNull(result);
+ Assert.Equal(hashedCode, result.HashedCode);
+ }
+
+ [Fact]
+ public async Task GetInviteFromRawCodeAsync_WhenInviteIsExpired_ReturnsInviteWithoutDeleting()
+ {
+ var (rawCode, hashedCode) = GenerateCode();
+ var invite = new Invite
+ {
+ HashedCode = hashedCode,
+ ToEmail = "user@example.com",
+ TargetOrgId = 1,
+ TargetOrgName = "ExampleOrg",
+ FromEmail = "admin@example.com",
+ FromName = "Admin",
+ CreatedAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30),
+ ExpireAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-1)
+ };
+ _dbContext.Invites.Add(invite);
+ await _dbContext.SaveChangesAsync();
+
+ var result = await _sut.GetInviteFromRawCodeAsync(rawCode);
+
+ Assert.NotNull(result);
+ Assert.Equal(hashedCode, result.HashedCode);
+ Assert.NotEmpty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+ }
+
+ [Fact]
+ public async Task RemoveExpiredInviteAsync_WhenInviteIsExpired_RemovesAndReturnsTrue()
+ {
+ var (_, hashedCode) = GenerateCode();
+ var invite = new Invite
+ {
+ HashedCode = hashedCode,
+ ToEmail = "user@example.com",
+ TargetOrgId = 1,
+ TargetOrgName = "ExampleOrg",
+ FromEmail = "admin@example.com",
+ FromName = "Admin",
+ CreatedAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30),
+ ExpireAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-1)
+ };
+ _dbContext.Invites.Add(invite);
+ await _dbContext.SaveChangesAsync();
+
+ var result = await _sut.RemoveExpiredInviteAsync(invite);
+
+ Assert.True(result);
+ Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+ }
+
+ [Fact]
+ public async Task RemoveExpiredInviteAsync_WhenInviteIsLive_LeavesInviteAndReturnsFalse()
+ {
+ var (_, hashedCode) = GenerateCode();
+ var invite = new Invite
+ {
+ HashedCode = hashedCode,
+ ToEmail = "user@example.com",
+ TargetOrgId = 1,
+ TargetOrgName = "ExampleOrg",
+ FromEmail = "admin@example.com",
+ FromName = "Admin",
+ CreatedAt = _timeProvider.GetUtcNow().UtcDateTime,
+ ExpireAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)
+ };
+ _dbContext.Invites.Add(invite);
+ await _dbContext.SaveChangesAsync();
+
+ var result = await _sut.RemoveExpiredInviteAsync(invite);
+
+ Assert.False(result);
+ Assert.NotEmpty(await _dbContext.Invites.AsNoTracking().ToListAsync());
+ }
+
+ [Fact]
+ public async Task GetInviteFromRawCodeAsync_WhenCodeDoesNotMatch_ReturnsNull()
+ {
+ var (_, hashedCode) = GenerateCode();
+ var invite = new Invite
+ {
+ HashedCode = hashedCode,
+ ToEmail = "user@example.com",
+ TargetOrgId = 1,
+ TargetOrgName = "ExampleOrg",
+ FromEmail = "admin@example.com",
+ FromName = "Admin",
+ CreatedAt = _timeProvider.GetUtcNow().UtcDateTime,
+ ExpireAt = _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)
+ };
+ _dbContext.Invites.Add(invite);
+ await _dbContext.SaveChangesAsync();
+
+ var (otherCode, _) = GenerateCode();
+
+ var result = await _sut.GetInviteFromRawCodeAsync(otherCode);
+
+ Assert.Null(result);
+ }
+
+ private static (string rawCode, string hashedCode) GenerateCode()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(32);
+ var rawCode = Convert.ToBase64String(bytes);
+ var hashedCode = Convert.ToBase64String(SHA256.HashData(bytes));
+ return (rawCode, hashedCode);
+ }
+
+ public void Dispose() => _dbContext.Dispose();
+
+ public ValueTask DisposeAsync() => _dbContext.DisposeAsync();
+}
\ No newline at end of file