From b2c37cf12214a3bd2ab43273cd4aab1fd9610fb9 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 5 May 2026 11:24:52 -0500 Subject: [PATCH 1/7] Added additional validation and tests around Joining the organization with invite. --- .../EventLog/Loggers/IEventLogger.cs | 17 +- src/AdminConsole/Helpers/InviteExtensions.cs | 9 + .../Pages/Organization/Join.cshtml | 4 +- .../Pages/Organization/Join.cshtml.cs | 46 ++- .../Services/IInvitationService.cs | 3 +- .../Services/InvitationService.cs | 32 +- src/Common/EventLog/Enums/EventType.cs | 1 + .../AdminConsole.Tests.csproj | 1 + .../DataFactory/FakeMagicLinkSignInManager.cs | 16 +- .../DataFactory/FakeUserManager.cs | 18 +- .../Pages/Organization/JoinTests.cs | 284 ++++++++++++++++++ .../Services/InvitationServiceTests.cs | 211 +++++++++++++ 12 files changed, 616 insertions(+), 26 deletions(-) create mode 100644 src/AdminConsole/Helpers/InviteExtensions.cs create mode 100644 tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs create mode 100644 tests/AdminConsole.Tests/Services/InvitationServiceTests.cs diff --git a/src/AdminConsole/EventLog/Loggers/IEventLogger.cs b/src/AdminConsole/EventLog/Loggers/IEventLogger.cs index e6fbaef48..f3efd2cd7 100644 --- a/src/AdminConsole/EventLog/Loggers/IEventLogger.cs +++ b/src/AdminConsole/EventLog/Loggers/IEventLogger.cs @@ -90,6 +90,21 @@ public static void LogCancelAdminInviteEvent( ) ); + public static void LogAdminExpiredInviteUsedEvent( + this IEventLogger logger, + Invite invite, + DateTime performedAt) => + logger.LogEvent( + new(invite.ToEmail, + EventType.AdminExpiredInviteUsed, + "Expired invite used.", + Severity.Warning, + invite.ToEmail, + invite.TargetOrgId, + performedAt + ) + ); + public static void LogAdminInvalidInviteUsedEvent( this IEventLogger logger, Invite invite, @@ -97,7 +112,7 @@ public static void LogAdminInvalidInviteUsedEvent( logger.LogEvent( new(invite.ToEmail, EventType.AdminInvalidInviteUsed, - "Expired invite used.", + "Invalid 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..48a18f583 --- /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..c0643ef4a 100644 --- a/src/AdminConsole/Pages/Organization/Join.cshtml.cs +++ b/src/AdminConsole/Pages/Organization/Join.cshtml.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Passwordless.AdminConsole.EventLog.Loggers; +using Passwordless.AdminConsole.Helpers; using Passwordless.AdminConsole.Identity; using Passwordless.AdminConsole.Services; using Passwordless.AdminConsole.Services.MagicLinks; @@ -33,30 +34,41 @@ 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 }); } try { - Invite = await _invitationService.GetInviteFromRawCodeAsync(code); + var invite = await _invitationService.GetInviteFromRawCodeAsync(code); + + if (invite is not null && await _invitationService.RemoveExpiredInviteAsync(invite)) + { + _eventLogger.LogAdminExpiredInviteUsedEvent(invite, _timeProvider.GetUtcNow().UtcDateTime); + Invite = null; + } + else + { + Invite = invite; + } } catch (Exception) { 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,15 +89,31 @@ 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) { - _eventLogger.LogAdminInvalidInviteUsedEvent(invite, _timeProvider.GetUtcNow().UtcDateTime); ModelState.AddModelError("bad-invite", "Invite is invalid or expired"); + return Page(); + } + + if (invite.IsExpired(_timeProvider)) + { + await _invitationService.RemoveExpiredInviteAsync(invite); + _eventLogger.LogAdminExpiredInviteUsedEvent(invite, _timeProvider.GetUtcNow().UtcDateTime); + + ModelState.AddModelError("bad-invite", "Invite is invalid or expired"); + return Page(); } + if (!string.Equals(form.Email, invite.ToEmail, StringComparison.OrdinalIgnoreCase) || + !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); if (existingUser == null) 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..2bc6cfe0c 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,26 +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) + _db.Invites.Remove(invite); + await _db.SaveChangesAsync(); + return true; + } + + public async Task ConsumeInviteAsync(Invite inv) + { + if (await RemoveExpiredInviteAsync(inv)) { return false; } - // delete it _db.Invites.Remove(inv); await _db.SaveChangesAsync(); diff --git a/src/Common/EventLog/Enums/EventType.cs b/src/Common/EventLog/Enums/EventType.cs index 6401e0680..08050534a 100644 --- a/src/Common/EventLog/Enums/EventType.cs +++ b/src/Common/EventLog/Enums/EventType.cs @@ -46,4 +46,5 @@ public enum EventType AdminGenerateSignInTokenEndpointDisabled = 7014, AdminMagicLinksEnabled = 7015, AdminMagicLinksDisabled = 7016, + AdminExpiredInviteUsed = 7017, } \ No newline at end of file diff --git a/tests/AdminConsole.Tests/AdminConsole.Tests.csproj b/tests/AdminConsole.Tests/AdminConsole.Tests.csproj index 0f0b221fe..a01597d8f 100644 --- a/tests/AdminConsole.Tests/AdminConsole.Tests.csproj +++ b/tests/AdminConsole.Tests/AdminConsole.Tests.csproj @@ -4,6 +4,7 @@ + diff --git a/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs b/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs index 4551771c7..3eed8d701 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; } } -} \ No newline at end of file + + public override Task SendEmailForSignInAsync(string email, string? returnUrl) + { + SentEmails.Add(email); + return Task.CompletedTask; + } +} diff --git a/tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs b/tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs index 4ac3c4673..ae0627e40 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); @@ -35,4 +51,4 @@ public override Task GenerateEmailConfirmationTokenAsync(ConsoleAdmin us return Task.FromResult(Guid.NewGuid().ToString()); } -} \ No newline at end of file +} diff --git a/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs b/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs new file mode 100644 index 000000000..f6ddb9980 --- /dev/null +++ b/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs @@ -0,0 +1,284 @@ +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 . Each scenario +/// is named after a step in the HackerOne PoC. +/// +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_RendersBadInvite_AndDeletesInvite() + { + 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.True(sut.ModelState.ContainsKey("bad-invite")); + Assert.Null(sut.Invite); + Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync()); + _eventLoggerMock.Verify(x => x.LogEvent(It.IsAny()), Times.Once); + } + + [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_BadInviteEmail_DoesNotCreateAdmin_AndPreservesInvite() + { + var (rawCode, hashedCode) = GenerateCode(); + SeedInvite(hashedCode, "otherguy@corp.example", 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 + }); + + Assert.IsType(result); + Assert.True(sut.ModelState.ContainsKey("bad-invite")); + Assert.Empty(_userManager.CreatedUsers); + Assert.Empty(_magicLinkSignInManager.SentEmails); + Assert.NotEmpty(await _dbContext.Invites.AsNoTracking().ToListAsync()); + _eventLoggerMock.Verify(x => x.LogEvent(It.IsAny()), Times.Once); + } + + [Fact] + public async Task OnPost_ExpiredInvite_AnyEmail_DoesNotCreateAdmin_AndDeletesInvite() + { + var (rawCode, hashedCode) = GenerateCode(); + SeedInvite(hashedCode, "otherguy@corp.example", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30)); + var sut = CreateJoin(); + + var result = await sut.OnPost(new Join.JoinForm + { + Code = rawCode, + Email = "flexo@other.com", + Name = "Flexo", + 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, "otherguy@corp.example", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30)); + var firstAttempt = CreateJoin(); + + await firstAttempt.OnPost(new Join.JoinForm + { + Code = rawCode, + Email = "flexo@other.com", + Name = "Flexo", + AcceptsTermsAndPrivacy = true + }); + Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync()); + + var secondAttempt = CreateJoin(); + var result = await secondAttempt.OnPost(new Join.JoinForm + { + Code = rawCode, + Email = "roberto@other.com", + Name = "Roberto", + AcceptsTermsAndPrivacy = true + }); + + Assert.IsType(result); + Assert.True(secondAttempt.ModelState.ContainsKey("bad-invite")); + Assert.Empty(_userManager.CreatedUsers); + Assert.Empty(_magicLinkSignInManager.SentEmails); + } + + [Fact] + public async Task OnPost_LiveInvite_MatchingEmail_CreatesAdminAndSendsMagicLink() + { + var (rawCode, hashedCode) = GenerateCode(); + SeedInvite(hashedCode, "user@example.com", targetOrgId: 42, expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)); + var sut = CreateJoin(); + + var result = await sut.OnPost(new Join.JoinForm + { + Code = rawCode, + Email = "user@example.com", + Name = "User", + AcceptsTermsAndPrivacy = true + }); + + var redirect = Assert.IsType(result); + Assert.Equal("/Organization/Verify", redirect.Url); + var created = Assert.Single(_userManager.CreatedUsers); + Assert.Equal("user@example.com", created.Email); + Assert.Equal(42, created.OrganizationId); + Assert.Equal("user@example.com", Assert.Single(_magicLinkSignInManager.SentEmails)); + Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync()); + } + + [Fact] + public async Task OnPost_LiveInvite_MatchingEmail_CaseInsensitive_Succeeds() + { + var (rawCode, hashedCode) = GenerateCode(); + SeedInvite(hashedCode, "User@Example.com", targetOrgId: 7, expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)); + var sut = CreateJoin(); + + var result = await sut.OnPost(new Join.JoinForm + { + Code = rawCode, + Email = "user@example.com", + Name = "User", + AcceptsTermsAndPrivacy = true + }); + + Assert.IsType(result); + Assert.Single(_userManager.CreatedUsers); + Assert.Single(_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(); +} diff --git a/tests/AdminConsole.Tests/Services/InvitationServiceTests.cs b/tests/AdminConsole.Tests/Services/InvitationServiceTests.cs new file mode 100644 index 000000000..c0c0fdd6c --- /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(); +} From a24737031364f490316d2fe65ec31cb9df381d78 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 5 May 2026 11:36:46 -0500 Subject: [PATCH 2/7] dotnet format --- src/AdminConsole/EventLog/Loggers/IEventLogger.cs | 2 +- src/AdminConsole/Helpers/InviteExtensions.cs | 2 +- src/AdminConsole/Pages/Organization/Join.cshtml.cs | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/AdminConsole/EventLog/Loggers/IEventLogger.cs b/src/AdminConsole/EventLog/Loggers/IEventLogger.cs index f3efd2cd7..e16eda2a6 100644 --- a/src/AdminConsole/EventLog/Loggers/IEventLogger.cs +++ b/src/AdminConsole/EventLog/Loggers/IEventLogger.cs @@ -104,7 +104,7 @@ public static void LogAdminExpiredInviteUsedEvent( performedAt ) ); - + public static void LogAdminInvalidInviteUsedEvent( this IEventLogger logger, Invite invite, diff --git a/src/AdminConsole/Helpers/InviteExtensions.cs b/src/AdminConsole/Helpers/InviteExtensions.cs index 48a18f583..d87d61e2f 100644 --- a/src/AdminConsole/Helpers/InviteExtensions.cs +++ b/src/AdminConsole/Helpers/InviteExtensions.cs @@ -4,6 +4,6 @@ namespace Passwordless.AdminConsole.Helpers; public static class InviteExtensions { - public static bool IsExpired(this Invite invite, TimeProvider timeProvider) => + 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.cs b/src/AdminConsole/Pages/Organization/Join.cshtml.cs index c0643ef4a..ce3e96d25 100644 --- a/src/AdminConsole/Pages/Organization/Join.cshtml.cs +++ b/src/AdminConsole/Pages/Organization/Join.cshtml.cs @@ -62,13 +62,13 @@ public async Task OnGet(string code) { 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 }; @@ -112,8 +112,8 @@ public async Task OnPost(JoinForm form) _eventLogger.LogAdminInvalidInviteUsedEvent(invite, _timeProvider.GetUtcNow().UtcDateTime); ModelState.AddModelError("bad-invite", "Invite is invalid or expired"); return Page(); - } - + } + ConsoleAdmin? existingUser = await _userManager.FindByEmailAsync(form.Email); if (existingUser == null) From cdbad6b000a4441040a12fad3f6d2cc1a087fc7b Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 5 May 2026 11:41:29 -0500 Subject: [PATCH 3/7] format --- tests/AdminConsole.Tests/AdminConsole.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AdminConsole.Tests/AdminConsole.Tests.csproj b/tests/AdminConsole.Tests/AdminConsole.Tests.csproj index a01597d8f..18cdb42c7 100644 --- a/tests/AdminConsole.Tests/AdminConsole.Tests.csproj +++ b/tests/AdminConsole.Tests/AdminConsole.Tests.csproj @@ -22,4 +22,4 @@ - + \ No newline at end of file From c6201594642d365a3c2eee60c339120816d7ad8e Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 5 May 2026 11:43:02 -0500 Subject: [PATCH 4/7] remove last empty line --- .../DataFactory/FakeMagicLinkSignInManager.cs | 2 +- tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs | 2 +- tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs | 2 +- tests/AdminConsole.Tests/Services/InvitationServiceTests.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs b/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs index 3eed8d701..5be47bf9c 100644 --- a/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs +++ b/tests/AdminConsole.Tests/DataFactory/FakeMagicLinkSignInManager.cs @@ -51,4 +51,4 @@ 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 ae0627e40..483e8741e 100644 --- a/tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs +++ b/tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs @@ -51,4 +51,4 @@ public override Task GenerateEmailConfirmationTokenAsync(ConsoleAdmin us return Task.FromResult(Guid.NewGuid().ToString()); } -} +} \ No newline at end of file diff --git a/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs b/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs index f6ddb9980..e4e78e068 100644 --- a/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs +++ b/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs @@ -281,4 +281,4 @@ private static (string rawCode, string hashedCode) GenerateCode() 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 index c0c0fdd6c..f54501a11 100644 --- a/tests/AdminConsole.Tests/Services/InvitationServiceTests.cs +++ b/tests/AdminConsole.Tests/Services/InvitationServiceTests.cs @@ -208,4 +208,4 @@ private static (string rawCode, string hashedCode) GenerateCode() public void Dispose() => _dbContext.Dispose(); public ValueTask DisposeAsync() => _dbContext.DisposeAsync(); -} +} \ No newline at end of file From 02db1d9aecc3861b05d47396c0a7ee9653265eb5 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 15 May 2026 14:06:18 -0500 Subject: [PATCH 5/7] simplified the fix. --- .../EventLog/Loggers/IEventLogger.cs | 17 +--- .../Pages/Organization/Join.cshtml | 2 +- .../Pages/Organization/Join.cshtml.cs | 33 ++----- .../Services/InvitationService.cs | 7 +- src/Common/EventLog/Enums/EventType.cs | 3 +- .../Pages/Organization/JoinTests.cs | 96 ++++++------------- 6 files changed, 39 insertions(+), 119 deletions(-) diff --git a/src/AdminConsole/EventLog/Loggers/IEventLogger.cs b/src/AdminConsole/EventLog/Loggers/IEventLogger.cs index e16eda2a6..ef5b7dd19 100644 --- a/src/AdminConsole/EventLog/Loggers/IEventLogger.cs +++ b/src/AdminConsole/EventLog/Loggers/IEventLogger.cs @@ -90,21 +90,6 @@ public static void LogCancelAdminInviteEvent( ) ); - public static void LogAdminExpiredInviteUsedEvent( - this IEventLogger logger, - Invite invite, - DateTime performedAt) => - logger.LogEvent( - new(invite.ToEmail, - EventType.AdminExpiredInviteUsed, - "Expired invite used.", - Severity.Warning, - invite.ToEmail, - invite.TargetOrgId, - performedAt - ) - ); - public static void LogAdminInvalidInviteUsedEvent( this IEventLogger logger, Invite invite, @@ -112,7 +97,7 @@ public static void LogAdminInvalidInviteUsedEvent( logger.LogEvent( new(invite.ToEmail, EventType.AdminInvalidInviteUsed, - "Invalid invite used.", + "Invalid/expired invite used.", Severity.Warning, invite.ToEmail, invite.TargetOrgId, diff --git a/src/AdminConsole/Pages/Organization/Join.cshtml b/src/AdminConsole/Pages/Organization/Join.cshtml index 53aabb141..74a2579b1 100644 --- a/src/AdminConsole/Pages/Organization/Join.cshtml +++ b/src/AdminConsole/Pages/Organization/Join.cshtml @@ -22,7 +22,7 @@ else
- +
diff --git a/src/AdminConsole/Pages/Organization/Join.cshtml.cs b/src/AdminConsole/Pages/Organization/Join.cshtml.cs index ce3e96d25..07e399055 100644 --- a/src/AdminConsole/Pages/Organization/Join.cshtml.cs +++ b/src/AdminConsole/Pages/Organization/Join.cshtml.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Passwordless.AdminConsole.EventLog.Loggers; -using Passwordless.AdminConsole.Helpers; using Passwordless.AdminConsole.Identity; using Passwordless.AdminConsole.Services; using Passwordless.AdminConsole.Services.MagicLinks; @@ -46,17 +45,7 @@ public async Task OnGet(string code) try { - var invite = await _invitationService.GetInviteFromRawCodeAsync(code); - - if (invite is not null && await _invitationService.RemoveExpiredInviteAsync(invite)) - { - _eventLogger.LogAdminExpiredInviteUsedEvent(invite, _timeProvider.GetUtcNow().UtcDateTime); - Invite = null; - } - else - { - Invite = invite; - } + Invite = await _invitationService.GetInviteFromRawCodeAsync(code); } catch (Exception) { @@ -97,32 +86,22 @@ public async Task OnPost(JoinForm form) return Page(); } - if (invite.IsExpired(_timeProvider)) - { - await _invitationService.RemoveExpiredInviteAsync(invite); - _eventLogger.LogAdminExpiredInviteUsedEvent(invite, _timeProvider.GetUtcNow().UtcDateTime); - - ModelState.AddModelError("bad-invite", "Invite is invalid or expired"); - return Page(); - } - - if (!string.Equals(form.Email, invite.ToEmail, StringComparison.OrdinalIgnoreCase) || - !await _invitationService.ConsumeInviteAsync(invite)) + 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 }; @@ -136,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/InvitationService.cs b/src/AdminConsole/Services/InvitationService.cs index 2bc6cfe0c..a29ed1afb 100644 --- a/src/AdminConsole/Services/InvitationService.cs +++ b/src/AdminConsole/Services/InvitationService.cs @@ -101,14 +101,11 @@ public async Task RemoveExpiredInviteAsync(Invite invite) public async Task ConsumeInviteAsync(Invite inv) { - if (await RemoveExpiredInviteAsync(inv)) - { - return false; - } + var isValid = !inv.IsExpired(_timeProvider); _db.Invites.Remove(inv); await _db.SaveChangesAsync(); - return true; + return isValid; } } \ No newline at end of file diff --git a/src/Common/EventLog/Enums/EventType.cs b/src/Common/EventLog/Enums/EventType.cs index 08050534a..069eeb5fd 100644 --- a/src/Common/EventLog/Enums/EventType.cs +++ b/src/Common/EventLog/Enums/EventType.cs @@ -45,6 +45,5 @@ public enum EventType AdminGenerateSignInTokenEndpointEnabled = 7013, AdminGenerateSignInTokenEndpointDisabled = 7014, AdminMagicLinksEnabled = 7015, - AdminMagicLinksDisabled = 7016, - AdminExpiredInviteUsed = 7017, + AdminMagicLinksDisabled = 7016 } \ No newline at end of file diff --git a/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs b/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs index e4e78e068..4857c8ae5 100644 --- a/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs +++ b/tests/AdminConsole.Tests/Pages/Organization/JoinTests.cs @@ -24,8 +24,10 @@ namespace Passwordless.AdminConsole.Tests.Pages.Organization; /// /// Regression tests for VULN-548 — exercises the real page model with the real -/// against an in-memory . Each scenario -/// is named after a step in the HackerOne PoC. +/// 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 { @@ -69,7 +71,7 @@ public async Task OnGet_LiveInvite_PopulatesFormAndInvite() } [Fact] - public async Task OnGet_ExpiredInvite_RendersBadInvite_AndDeletesInvite() + public async Task OnGet_ExpiredInvite_RendersFormAndLeavesInviteForOnPostToReject() { var (rawCode, hashedCode) = GenerateCode(); SeedInvite(hashedCode, "user@example.com", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(-1)); @@ -78,10 +80,10 @@ public async Task OnGet_ExpiredInvite_RendersBadInvite_AndDeletesInvite() var result = await sut.OnGet(rawCode); Assert.IsType(result); - Assert.True(sut.ModelState.ContainsKey("bad-invite")); - Assert.Null(sut.Invite); - Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync()); - _eventLoggerMock.Verify(x => x.LogEvent(It.IsAny()), Times.Once); + 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] @@ -99,10 +101,10 @@ public async Task OnGet_UnknownCode_RendersBadInvite() } [Fact] - public async Task OnPost_LiveInvite_BadInviteEmail_DoesNotCreateAdmin_AndPreservesInvite() + public async Task OnPost_LiveInvite_FormEmailIsIgnored_AdminCreatedFromInviteEmail() { var (rawCode, hashedCode) = GenerateCode(); - SeedInvite(hashedCode, "otherguy@corp.example", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)); + SeedInvite(hashedCode, "pjfry@planet.express", targetOrgId: 42, expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)); var sut = CreateJoin(); var result = await sut.OnPost(new Join.JoinForm @@ -113,26 +115,28 @@ public async Task OnPost_LiveInvite_BadInviteEmail_DoesNotCreateAdmin_AndPreserv AcceptsTermsAndPrivacy = true }); - Assert.IsType(result); - Assert.True(sut.ModelState.ContainsKey("bad-invite")); - Assert.Empty(_userManager.CreatedUsers); - Assert.Empty(_magicLinkSignInManager.SentEmails); - Assert.NotEmpty(await _dbContext.Invites.AsNoTracking().ToListAsync()); - _eventLoggerMock.Verify(x => x.LogEvent(It.IsAny()), Times.Once); + 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_AnyEmail_DoesNotCreateAdmin_AndDeletesInvite() + public async Task OnPost_ExpiredInvite_DoesNotCreateAdmin_AndDeletesInvite() { var (rawCode, hashedCode) = GenerateCode(); - SeedInvite(hashedCode, "otherguy@corp.example", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30)); + 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 = "flexo@other.com", - Name = "Flexo", + Email = "pjfry@planet.express", + Name = "Philip", AcceptsTermsAndPrivacy = true }); @@ -147,14 +151,14 @@ public async Task OnPost_ExpiredInvite_AnyEmail_DoesNotCreateAdmin_AndDeletesInv public async Task OnPost_ExpiredInvite_Replay_FindsNoInvite() { var (rawCode, hashedCode) = GenerateCode(); - SeedInvite(hashedCode, "otherguy@corp.example", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30)); + SeedInvite(hashedCode, "pjfry@planet.express", expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30)); var firstAttempt = CreateJoin(); await firstAttempt.OnPost(new Join.JoinForm { Code = rawCode, - Email = "flexo@other.com", - Name = "Flexo", + Email = "pjfry@planet.express", + Name = "Philip", AcceptsTermsAndPrivacy = true }); Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync()); @@ -163,8 +167,8 @@ await firstAttempt.OnPost(new Join.JoinForm var result = await secondAttempt.OnPost(new Join.JoinForm { Code = rawCode, - Email = "roberto@other.com", - Name = "Roberto", + Email = "pjfry@planet.express", + Name = "Philip", AcceptsTermsAndPrivacy = true }); @@ -174,50 +178,6 @@ await firstAttempt.OnPost(new Join.JoinForm Assert.Empty(_magicLinkSignInManager.SentEmails); } - [Fact] - public async Task OnPost_LiveInvite_MatchingEmail_CreatesAdminAndSendsMagicLink() - { - var (rawCode, hashedCode) = GenerateCode(); - SeedInvite(hashedCode, "user@example.com", targetOrgId: 42, expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)); - var sut = CreateJoin(); - - var result = await sut.OnPost(new Join.JoinForm - { - Code = rawCode, - Email = "user@example.com", - Name = "User", - AcceptsTermsAndPrivacy = true - }); - - var redirect = Assert.IsType(result); - Assert.Equal("/Organization/Verify", redirect.Url); - var created = Assert.Single(_userManager.CreatedUsers); - Assert.Equal("user@example.com", created.Email); - Assert.Equal(42, created.OrganizationId); - Assert.Equal("user@example.com", Assert.Single(_magicLinkSignInManager.SentEmails)); - Assert.Empty(await _dbContext.Invites.AsNoTracking().ToListAsync()); - } - - [Fact] - public async Task OnPost_LiveInvite_MatchingEmail_CaseInsensitive_Succeeds() - { - var (rawCode, hashedCode) = GenerateCode(); - SeedInvite(hashedCode, "User@Example.com", targetOrgId: 7, expireAtUtc: _timeProvider.GetUtcNow().UtcDateTime.AddDays(7)); - var sut = CreateJoin(); - - var result = await sut.OnPost(new Join.JoinForm - { - Code = rawCode, - Email = "user@example.com", - Name = "User", - AcceptsTermsAndPrivacy = true - }); - - Assert.IsType(result); - Assert.Single(_userManager.CreatedUsers); - Assert.Single(_magicLinkSignInManager.SentEmails); - } - private Join CreateJoin() { var join = new Join( From e6158ce69d4819248092c474d7c0dc0add831497 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 18 May 2026 12:01:27 -0500 Subject: [PATCH 6/7] Added back placeholder for name --- src/AdminConsole/Pages/Organization/Join.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AdminConsole/Pages/Organization/Join.cshtml b/src/AdminConsole/Pages/Organization/Join.cshtml index 74a2579b1..53aabb141 100644 --- a/src/AdminConsole/Pages/Organization/Join.cshtml +++ b/src/AdminConsole/Pages/Organization/Join.cshtml @@ -22,7 +22,7 @@ else
- +
From 051a88f0e858b157e4ff8d0c83433d2e9f304730 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 19 May 2026 15:28:57 -0500 Subject: [PATCH 7/7] adding last comma --- src/Common/EventLog/Enums/EventType.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Common/EventLog/Enums/EventType.cs b/src/Common/EventLog/Enums/EventType.cs index 069eeb5fd..6401e0680 100644 --- a/src/Common/EventLog/Enums/EventType.cs +++ b/src/Common/EventLog/Enums/EventType.cs @@ -45,5 +45,5 @@ public enum EventType AdminGenerateSignInTokenEndpointEnabled = 7013, AdminGenerateSignInTokenEndpointDisabled = 7014, AdminMagicLinksEnabled = 7015, - AdminMagicLinksDisabled = 7016 + AdminMagicLinksDisabled = 7016, } \ No newline at end of file