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