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)
{
// 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;

View file

@ -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<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]
public async Task Complete_WithoutPhone_KeepsThePhoneTheAdminEntered()
{

View file

@ -38,7 +38,7 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable
public ManualTimeProvider Time { 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");
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<IServiceCollection>? configure)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@ -76,6 +79,7 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable
services.AddSingleton<IEmailSender>(fakes.Sent);
services.AddDataServices();
services.AddBusinessServices(configuration);
configure?.Invoke(services);
return services.BuildServiceProvider();
}