fix(team-members): report only password-rule failures at finish as a rejected password

This commit is contained in:
Alexandre Brandizzi 2026-09-25 13:05:35 -03:00
parent e53415de39
commit afc2330184
3 changed files with 44 additions and 6 deletions

View file

@ -228,9 +228,16 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat
private static TeamMemberRegistrationStatus StatusFor(IdentityResult result) private static TeamMemberRegistrationStatus StatusFor(IdentityResult result)
{ {
// A password already set means the account was registered by another route. // A password already set means the account was registered by another route.
return result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword)) if (result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword)))
? TeamMemberRegistrationStatus.InvalidInvite return TeamMemberRegistrationStatus.InvalidInvite;
: TeamMemberRegistrationStatus.PasswordRejected;
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; private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime;

View file

@ -292,6 +292,33 @@ public sealed class TeamMemberInviteRegistrationTests
Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration); Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration);
} }
[Fact]
public async Task Complete_NonPolicyIdentityFailure_IsNotReportedAsAWeakPasswordAndRollsBack()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync(services =>
services.AddScoped<IPasswordValidator<ApplicationUser>, FailingPasswordValidator>());
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
var failure = await Assert.ThrowsAsync<InvalidOperationException>(() => 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);
}
/// <summary>Stands in for any Identity failure that is not the password rule itself.</summary>
private sealed class FailingPasswordValidator : IPasswordValidator<ApplicationUser>
{
public const string Code = "ConcurrencyFailure";
public Task<IdentityResult> ValidateAsync(UserManager<ApplicationUser> manager, ApplicationUser user, string? password) =>
Task.FromResult(IdentityResult.Failed(new IdentityError { Code = Code, Description = "Optimistic concurrency failure." }));
}
[Fact] [Fact]
public async Task Complete_WithoutPhone_KeepsThePhoneTheAdminEntered() public async Task Complete_WithoutPhone_KeepsThePhoneTheAdminEntered()
{ {

View file

@ -38,7 +38,7 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable
public ManualTimeProvider Time { get; } = new(); public ManualTimeProvider Time { get; } = new();
public CapturingLoggerProvider Logged { get; } = new(); public CapturingLoggerProvider Logged { get; } = new();
public static async Task<TeamMemberInviteTestHost> CreateAsync() public static async Task<TeamMemberInviteTestHost> CreateAsync(Action<IServiceCollection>? configure = null)
{ {
var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync(); await connection.OpenAsync();
@ -48,11 +48,14 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable
await setup.Database.EnsureCreatedAsync(); await setup.Database.EnsureCreatedAsync();
var host = new TeamMemberInviteTestHost(connection); var host = new TeamMemberInviteTestHost(connection);
host._provider = BuildProvider(connection, host); host._provider = BuildProvider(connection, host, configure);
return host; return host;
} }
private static ServiceProvider BuildProvider(SqliteConnection connection, TeamMemberInviteTestHost fakes) private static ServiceProvider BuildProvider(
SqliteConnection connection,
TeamMemberInviteTestHost fakes,
Action<IServiceCollection>? configure)
{ {
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> .AddInMemoryCollection(new Dictionary<string, string?>
@ -76,6 +79,7 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable
services.AddSingleton<IEmailSender>(fakes.Sent); services.AddSingleton<IEmailSender>(fakes.Sent);
services.AddDataServices(); services.AddDataServices();
services.AddBusinessServices(configuration); services.AddBusinessServices(configuration);
configure?.Invoke(services);
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }