From daf3ed921041ff3cd3ea0709acb5698c9f858ed1 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 17 Sep 2026 01:40:40 -0300 Subject: [PATCH] fix(team-members): map duplicate-email race to conflict error (SH-325) CreateAsync checks FindByEmailAsync and then inserts, so two concurrent creates with the same email can both pass the check and hit the unique user-name index. That DbUpdateException was unhandled and surfaced as a 500. Catch it at the CreateAsync call and return the existing "Email is already in use." message, matching the check-then-insert converge pattern already used by SetOverrideCoreAsync. --- .../TeamMemberServiceTests.cs | 21 +++++++++++++++++++ .../Implementation/TeamMemberService.cs | 16 +++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs index 4b39ede..d9ce2d7 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -3,6 +3,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -95,6 +96,26 @@ public sealed class TeamMemberServiceTests overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny()), Times.Once); } + [Fact] + public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500() + { + var service = NewService(out var userManager, out var roleManager, out _, out _); + userManager + .Setup(manager => manager.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + userManager + .Setup(manager => manager.CreateAsync(It.IsAny())) + .ThrowsAsync(new DbUpdateException("duplicate key", new Exception())); + + var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Email is already in use."); + userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never); + userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); + } + private static TeamMemberService NewService( out Mock> userManager, out Mock> roleManager, diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index ad01254..7382c3c 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; @@ -68,7 +69,20 @@ public sealed class TeamMemberService : ITeamMemberService PendingRegistrationCreatedDate = now }; - var createResult = await _userManager.CreateAsync(user); + IdentityResult createResult; + try + { + createResult = await _userManager.CreateAsync(user); + } + catch (DbUpdateException) + { + // A concurrent create won the race on the unique user-name index + // between the FindByEmailAsync check above and this insert. Surface + // the same conflict message instead of letting the database + // exception bubble up as a 500. + return Failure("Email is already in use."); + } + if (!createResult.Succeeded) return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");