From afc2330184b502602e480d513c403441848ddac9 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:05:35 -0300 Subject: [PATCH] fix(team-members): report only password-rule failures at finish as a rejected password --- .../TeamMemberRegistrationService.cs | 13 ++++++--- .../TeamMemberInviteRegistrationTests.cs | 27 +++++++++++++++++++ .../TeamMemberInviteTestHost.cs | 10 ++++--- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs index c64e119..de7ae0a 100644 --- a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs @@ -228,9 +228,16 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat private static TeamMemberRegistrationStatus StatusFor(IdentityResult result) { // A password already set means the account was registered by another route. - return result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword)) - ? TeamMemberRegistrationStatus.InvalidInvite - : TeamMemberRegistrationStatus.PasswordRejected; + if (result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword))) + return TeamMemberRegistrationStatus.InvalidInvite; + + if (IdentityPasswordPolicy.IsPolicyRejection(result)) + return TeamMemberRegistrationStatus.PasswordRejected; + + // Anything else (a concurrency conflict, a store failure) is not the member's password: + // roll back and let the endpoint answer with its generic error. Only codes are recorded. + throw new InvalidOperationException( + "Identity refused the registration: " + string.Join(", ", result.Errors.Select(error => error.Code))); } private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime; diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs index dac402a..f0a4c49 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs @@ -292,6 +292,33 @@ public sealed class TeamMemberInviteRegistrationTests Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration); } + [Fact] + public async Task Complete_NonPolicyIdentityFailure_IsNotReportedAsAWeakPasswordAndRollsBack() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(services => + services.AddScoped, FailingPasswordValidator>()); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + var failure = await Assert.ThrowsAsync(() => CompleteAsync(host, token, Password)); + + Assert.Contains(FailingPasswordValidator.Code, failure.Message); + Assert.DoesNotContain(Password, failure.Message); + var pending = await host.ReloadUserAsync(userId); + Assert.True(pending.PendingRegistration); + Assert.Null(pending.PasswordHash); + Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt); + } + + /// Stands in for any Identity failure that is not the password rule itself. + private sealed class FailingPasswordValidator : IPasswordValidator + { + public const string Code = "ConcurrencyFailure"; + + public Task ValidateAsync(UserManager manager, ApplicationUser user, string? password) => + Task.FromResult(IdentityResult.Failed(new IdentityError { Code = Code, Description = "Optimistic concurrency failure." })); + } + [Fact] public async Task Complete_WithoutPhone_KeepsThePhoneTheAdminEntered() { diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs b/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs index e1d6f9e..a2e5a1a 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs @@ -38,7 +38,7 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable public ManualTimeProvider Time { get; } = new(); public CapturingLoggerProvider Logged { get; } = new(); - public static async Task CreateAsync() + public static async Task CreateAsync(Action? configure = null) { var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); await connection.OpenAsync(); @@ -48,11 +48,14 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable await setup.Database.EnsureCreatedAsync(); var host = new TeamMemberInviteTestHost(connection); - host._provider = BuildProvider(connection, host); + host._provider = BuildProvider(connection, host, configure); return host; } - private static ServiceProvider BuildProvider(SqliteConnection connection, TeamMemberInviteTestHost fakes) + private static ServiceProvider BuildProvider( + SqliteConnection connection, + TeamMemberInviteTestHost fakes, + Action? configure) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -76,6 +79,7 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable services.AddSingleton(fakes.Sent); services.AddDataServices(); services.AddBusinessServices(configuration); + configure?.Invoke(services); return services.BuildServiceProvider(); }