From 66a49ab9579f1bb86b1777dda80d397f7060f473 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:04:33 -0300 Subject: [PATCH 1/7] feat(auth): enforce one password policy on every password-setting path (SH-386) Both hosts now apply the same Identity password rule: at least 6 characters with one uppercase letter, one number and one special character. Change password requires an authenticated caller, verifies the current password before evaluating the new one, and reports a policy rejection separately from a wrong current password. --- .../AuthenticationControllerTests.cs | 38 +++- .../PasswordPolicyTests.cs | 180 ++++++++++++++++++ .../Controllers/AuthenticationController.cs | 18 +- .../Infrastructure/IdentityRegistration.cs | 24 +++ Api.SeaHavenIndustries/Program.cs | 8 +- .../Auth/IdentityPasswordPolicy.cs | 27 +++ SeaHaven.Services/DTOs/IdentityDTOs.cs | 12 ++ .../Implementation/AuthenticationService.cs | 21 +- .../Interfaces/IAuthenticationService.cs | 2 +- SeaHavenIndustries/Program.cs | 9 +- 10 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs create mode 100644 Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs create mode 100644 Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 99d5ab8..4187a24 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -117,11 +117,11 @@ public class AuthenticationControllerTests { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny())) - .ReturnsAsync(true); + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded }); var controller = NewController(service, "42"); - var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None); + var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", Confirmpassword = "new" }, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; @@ -130,11 +130,11 @@ public class AuthenticationControllerTests } [Fact] - public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus() + public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus() { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(false); + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect }); var controller = NewController(service, "42"); @@ -143,6 +143,36 @@ public class AuthenticationControllerTests var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; response.Status.Should().Be("Old Password is incorrect"); + response.Message.Should().Be("Current password is incorrect"); + } + + [Fact] + public async Task ChangePassword_PolicyRejection_ReturnsPasswordRequirementsMessage() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny())) + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.PasswordRejected }); + + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "weak", Confirmpassword = "weak" }, + CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Password does not meet requirements"); + response.Message.Should().Be( + "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."); + } + + [Fact] + public void ChangePassword_RequiresAuthenticatedCaller() + { + var method = typeof(AuthenticationController).GetMethod(nameof(AuthenticationController.ChangePassword))!; + + method.GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: true) + .Should().NotBeEmpty(); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs new file mode 100644 index 0000000..946a24d --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -0,0 +1,180 @@ +using Api.SeaHavenIndustries.Infrastructure; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Exercises the password rule through the same Identity registration the API +/// host uses, so these assertions describe the rule that runs in production. +/// +public sealed class PasswordPolicyTests : IAsyncDisposable +{ + private const string CurrentPassword = "Current1!"; + private readonly ServiceProvider _provider; + private readonly AsyncServiceScope _scope; + + public PasswordPolicyTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddSeaHavenIdentity(); + _provider = services.BuildServiceProvider(); + _scope = _provider.CreateAsyncScope(); + } + + private UserManager UserManager => + _scope.ServiceProvider.GetRequiredService>(); + + [Theory] + [InlineData("Ab1!x", "PasswordTooShort")] + [InlineData("abc12!", "PasswordRequiresUpper")] + [InlineData("Abcde!", "PasswordRequiresDigit")] + [InlineData("Abcde1", "PasswordRequiresNonAlphanumeric")] + public async Task Policy_RejectsPasswordMissingOneRule(string password, string expectedCode) + { + var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; + + var errors = await ValidateAsync(user, password); + + errors.Select(error => error.Code).Should().Equal(expectedCode); + } + + [Theory] + [InlineData("Abc1!x")] + [InlineData("ABC12!")] + public async Task Policy_AcceptsSixCharacterPasswordMeetingEveryRule(string password) + { + var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; + + var errors = await ValidateAsync(user, password); + + errors.Should().BeEmpty(); + } + + [Fact] + public void Registration_AppliesSharedPolicyOptions() + { + var options = _scope.ServiceProvider.GetRequiredService>().Value.Password; + + options.RequiredLength.Should().Be(6); + options.RequireUppercase.Should().BeTrue(); + options.RequireDigit.Should().BeTrue(); + options.RequireNonAlphanumeric.Should().BeTrue(); + options.RequireLowercase.Should().BeFalse(); + } + + [Fact] + public async Task ChangePassword_WrongCurrentPassword_IsRejectedBeforeNewPasswordIsEvaluated() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, "Wrong1!", "weak", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.CurrentPasswordIncorrect); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CorrectCurrentPasswordAndWeakNewPassword_IsRejectedByPolicy() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "abcdef", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.PasswordRejected); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CorrectCurrentPasswordAndCompliantNewPassword_ChangesPassword() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.Succeeded); + var reloaded = await UserManager.FindByIdAsync(user.Id); + (await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CancelledToken_Throws() + { + var service = NewAuthenticationService(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var act = () => service.ChangePasswordAsync("any", CurrentPassword, "Next2@x", cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode() + { + var user = await CreateUserAsync(); + var forget = new Mock(); + forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny())) + .ReturnsAsync(true); + forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny())) + .ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" }); + var service = NewAuthenticationService(forget); + + var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None); + + reset.Should().BeFalse(); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private async Task> ValidateAsync(ApplicationUser user, string password) + { + var errors = new List(); + foreach (var validator in UserManager.PasswordValidators) + { + var result = await validator.ValidateAsync(UserManager, user, password); + errors.AddRange(result.Errors); + } + + return errors; + } + + private async Task CreateUserAsync() + { + var user = new ApplicationUser { UserName = "member@example.com", Email = "member@example.com" }; + var created = await UserManager.CreateAsync(user, CurrentPassword); + created.Succeeded.Should().BeTrue(); + return user; + } + + private AuthenticationService NewAuthenticationService(Mock? forget = null) => + new( + UserManager, + Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), + Mock.Of(), + (forget ?? new Mock()).Object, + Mock.Of()); + + public async ValueTask DisposeAsync() + { + await _scope.DisposeAsync(); + await _provider.DisposeAsync(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 12fd8fa..7b6569a 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -55,20 +55,26 @@ namespace Api.SeaHavenIndustries.Controllers } + [Authorize] [Route("ChangePassword")] [HttpPost] public async Task ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken); - if (succeeded) + var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken); + return result.Status switch { - return Ok(new Response { Status = "Success ", Message = "Password successfully changed" }); - } - else - return BadRequest(new Response { Status = "Old Password is incorrect" }); + ChangePasswordStatus.Succeeded => + Ok(new Response { Status = "Success ", Message = "Password successfully changed" }), + ChangePasswordStatus.PasswordRejected => + BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }), + _ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" }) + }; } + private const string PasswordRequirementsMessage = + "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."; + [HttpPost] [Route("UpdateProfile")] public async Task UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken) diff --git a/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs b/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs new file mode 100644 index 0000000..752e494 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs @@ -0,0 +1,24 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public static class IdentityRegistration + { + /// + /// Registers ASP.NET Identity for the API with the shared password policy. + /// Program.cs and the behavior tests both compose Identity through this + /// method so the rule under test is the rule that runs. + /// + public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services) + { + return services.AddIdentity(options => + { + options.User.RequireUniqueEmail = false; + IdentityPasswordPolicy.Apply(options.Password); + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6baf11f..0151543 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,5 +1,6 @@ using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Infrastructure; using Api.SeaHavenIndustries.Middleware; using Api.SeaHavenIndustries.Observability; using Api.SeaHavenIndustries.Options; @@ -43,12 +44,7 @@ ConfigurationManager configuration = builder.Configuration; builder.Services.AddDbContext(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); -builder.Services.AddIdentity(options => -{ - options.User.RequireUniqueEmail = false; -}) - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); +builder.Services.AddSeaHavenIdentity(); builder.Services.AddControllers(options => { diff --git a/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs new file mode 100644 index 0000000..a0161ff --- /dev/null +++ b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Identity; + +namespace Data.SeaHavenIndustries +{ + /// + /// The single password rule for every surface that sets a password: at least + /// six characters with one uppercase letter, one number, and one special + /// character. Lowercase letters are deliberately not required so the server + /// accepts exactly what the four-item checklist in the web app marks as met. + /// + public static class IdentityPasswordPolicy + { + public const int MinimumLength = 6; + + public static void Apply(PasswordOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + options.RequiredLength = MinimumLength; + options.RequireUppercase = true; + options.RequireDigit = true; + options.RequireNonAlphanumeric = true; + options.RequireLowercase = false; + options.RequiredUniqueChars = 1; + } + } +} diff --git a/SeaHaven.Services/DTOs/IdentityDTOs.cs b/SeaHaven.Services/DTOs/IdentityDTOs.cs index ad3b8cb..e959478 100644 --- a/SeaHaven.Services/DTOs/IdentityDTOs.cs +++ b/SeaHaven.Services/DTOs/IdentityDTOs.cs @@ -11,6 +11,18 @@ namespace SeaHaven.Services.DTOs public string Id { get; set; } = string.Empty; } + public enum ChangePasswordStatus + { + Succeeded, + CurrentPasswordIncorrect, + PasswordRejected + } + + public sealed class ChangePasswordResultDTO + { + public ChangePasswordStatus Status { get; init; } + } + public class UpdateProfileRequestDTO { public string? Name { get; set; } diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index d86f19a..e293276 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -80,16 +80,27 @@ namespace SeaHaven.Services.Implementation return null; } - public async Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken) + public async Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var user = await _userManager.FindByIdAsync(userId); - if (user == null) - return false; + if (user == null || user.IsDeleted == true) + return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); - var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", confirmPassword ?? ""); - return result.Succeeded; + // The current password is verified before the new one is evaluated, so a + // caller without it learns nothing about the policy outcome. + if (!await _userManager.CheckPasswordAsync(user, currentPassword ?? "")) + return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); + + var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? ""); + return ChangePasswordResult(result.Succeeded + ? ChangePasswordStatus.Succeeded + : ChangePasswordStatus.PasswordRejected); } + private static ChangePasswordResultDTO ChangePasswordResult(ChangePasswordStatus status) => + new() { Status = status }; + public async Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken) { var exists = await _userDataService.UpdateProfileAsync( diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs index 426eb5f..e4ffc9b 100644 --- a/SeaHaven.Services/Interfaces/IAuthenticationService.cs +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -5,7 +5,7 @@ namespace SeaHaven.Services.Interfaces public interface IAuthenticationService { Task LoginAsync(string? username, string? password, CancellationToken cancellationToken); - Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken); + Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken); Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); Task ForgetPasswordAsync(string email, CancellationToken cancellationToken); Task VerifyCodeAsync(string code, CancellationToken cancellationToken); diff --git a/SeaHavenIndustries/Program.cs b/SeaHavenIndustries/Program.cs index 48b76c3..5a33b68 100644 --- a/SeaHavenIndustries/Program.cs +++ b/SeaHavenIndustries/Program.cs @@ -31,7 +31,8 @@ builder.Services.AddAuthentication(options => .AddIdentityCookies(); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); -builder.Services.AddDbContext(options => { +builder.Services.AddDbContext(options => +{ options.UseSqlServer(connectionString); }, ServiceLifetime.Transient); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); @@ -39,11 +40,7 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddIdentityCore(options => { options.SignIn.RequireConfirmedAccount = true; - options.Password.RequireDigit = true; - options.Password.RequireLowercase = false; - options.Password.RequireUppercase = false; - options.Password.RequireNonAlphanumeric = true; - options.Password.RequiredLength = 8; + IdentityPasswordPolicy.Apply(options.Password); }).AddRoles().AddEntityFrameworkStores().AddSignInManager() .AddDefaultTokenProviders(); From 6bfb56f3491e27ca9d72342dcc3e440d21f55d84 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:04:40 -0300 Subject: [PATCH 2/7] fix(data): stamp audit times in UTC and keep a caller-set work order creation time WorkOrderDataService.AddAsync overwrote the UTC CreatedDate set by WorkOrderService with local server time, offsetting the SLA response clock on any host not running in UTC. Data services now stamp CreatedDate, LastModificationTime and DeletionTime with DateTime.UtcNow, and a work order keeps the creation time its caller set. --- .../WorkOrderDataServiceTimestampTests.cs | 68 +++++++++++++++++++ .../Implementation/AccountDataService.cs | 4 +- .../Implementation/AssetDataService.cs | 4 +- .../Implementation/CategoryDataService.cs | 4 +- .../Implementation/ContactDataService.cs | 4 +- .../Implementation/DispatchDataService.cs | 4 +- .../Implementation/EmployeeDataService.cs | 4 +- .../Implementation/FollowUpDataService.cs | 10 +-- .../Implementation/LocationDataService.cs | 10 +-- .../Implementation/PMScheduleDataService.cs | 4 +- .../Implementation/WorkOrderDataService.cs | 4 +- 11 files changed, 94 insertions(+), 26 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs new file mode 100644 index 0000000..512ad0e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs @@ -0,0 +1,68 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// The SLA clock and every "created"/"modified" display read these stamps as UTC, so the data layer +/// must never write local server time into them. +/// +public class WorkOrderDataServiceTimestampTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrder NewWorkOrder() => + new() { InternalWONumber = "WO-1", WorkerOrderTitle = "Leak", LocationId = 100 }; + + [Fact] + public async Task AddAsync_KeepsTheCreationTimeTheCallerSet() + { + await using var context = NewContext(); + var createdAt = new DateTime(2026, 9, 25, 14, 0, 0, DateTimeKind.Utc); + var workOrder = NewWorkOrder(); + workOrder.CreatedDate = createdAt; + + var saved = await new WorkOrderDataService(context).AddAsync(workOrder); + + saved.CreatedDate.Should().Be(createdAt); + saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc); + (await context.workOrders.SingleAsync()).CreatedDate.Should().Be(createdAt); + } + + [Fact] + public async Task AddAsync_StampsUtcWhenTheCallerSetNoCreationTime() + { + await using var context = NewContext(); + var before = DateTime.UtcNow; + + var saved = await new WorkOrderDataService(context).AddAsync(NewWorkOrder()); + + saved.CreatedDate.Should().NotBeNull(); + saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc); + saved.CreatedDate.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } + + [Fact] + public async Task UpdateAsync_StampsTheModificationTimeInUtc() + { + await using var context = NewContext(); + var service = new WorkOrderDataService(context); + var saved = await service.AddAsync(NewWorkOrder()); + var before = DateTime.UtcNow; + + await service.UpdateAsync(saved); + + saved.LastModificationTime.Should().NotBeNull(); + saved.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc); + saved.LastModificationTime.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } +} diff --git a/SeaHaven.DataServices/Implementation/AccountDataService.cs b/SeaHaven.DataServices/Implementation/AccountDataService.cs index 5b00af2..ec2f874 100644 --- a/SeaHaven.DataServices/Implementation/AccountDataService.cs +++ b/SeaHaven.DataServices/Implementation/AccountDataService.cs @@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Accounts account) { - account.CreatedDate = DateTime.Now; + account.CreatedDate = DateTime.UtcNow; await _context.Accounts.AddAsync(account); await _context.SaveChangesAsync(); return account; @@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Accounts account) { - account.LastModificationTime = DateTime.Now; + account.LastModificationTime = DateTime.UtcNow; _context.Accounts.Update(account); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/AssetDataService.cs b/SeaHaven.DataServices/Implementation/AssetDataService.cs index 93a3f1e..1d60a7d 100644 --- a/SeaHaven.DataServices/Implementation/AssetDataService.cs +++ b/SeaHaven.DataServices/Implementation/AssetDataService.cs @@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Assets asset) { - asset.CreatedDate = DateTime.Now; + asset.CreatedDate = DateTime.UtcNow; await _context.Assets.AddAsync(asset); await _context.SaveChangesAsync(); return asset; @@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Assets asset) { - asset.LastModificationTime = DateTime.Now; + asset.LastModificationTime = DateTime.UtcNow; _context.Assets.Update(asset); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/CategoryDataService.cs b/SeaHaven.DataServices/Implementation/CategoryDataService.cs index c5af096..994acc3 100644 --- a/SeaHaven.DataServices/Implementation/CategoryDataService.cs +++ b/SeaHaven.DataServices/Implementation/CategoryDataService.cs @@ -25,7 +25,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Category category) { - category.CreatedDate = DateTime.Now; + category.CreatedDate = DateTime.UtcNow; await _context.Categories.AddAsync(category); await _context.SaveChangesAsync(); return category; @@ -33,7 +33,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Category category) { - category.LastModificationTime = DateTime.Now; + category.LastModificationTime = DateTime.UtcNow; _context.Categories.Update(category); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/ContactDataService.cs b/SeaHaven.DataServices/Implementation/ContactDataService.cs index 8196d2b..e4abf4f 100644 --- a/SeaHaven.DataServices/Implementation/ContactDataService.cs +++ b/SeaHaven.DataServices/Implementation/ContactDataService.cs @@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Contacts contact) { - contact.CreatedDate = DateTime.Now; + contact.CreatedDate = DateTime.UtcNow; await _context.Contacts.AddAsync(contact); await _context.SaveChangesAsync(); return contact; @@ -108,7 +108,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Contacts contact) { - contact.LastModificationTime = DateTime.Now; + contact.LastModificationTime = DateTime.UtcNow; _context.Contacts.Update(contact); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index 3274713..398051d 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -85,7 +85,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Dispatch dispatch) { - dispatch.CreatedDate = DateTime.Now; + dispatch.CreatedDate = DateTime.UtcNow; await _context.Dispatches.AddAsync(dispatch); await _context.SaveChangesAsync(); return dispatch; @@ -93,7 +93,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Dispatch dispatch) { - dispatch.LastModificationTime = DateTime.Now; + dispatch.LastModificationTime = DateTime.UtcNow; _context.Dispatches.Update(dispatch); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/EmployeeDataService.cs b/SeaHaven.DataServices/Implementation/EmployeeDataService.cs index 4606d11..7b0b2c7 100644 --- a/SeaHaven.DataServices/Implementation/EmployeeDataService.cs +++ b/SeaHaven.DataServices/Implementation/EmployeeDataService.cs @@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Employee employee) { - employee.CreatedDate = DateTime.Now; + employee.CreatedDate = DateTime.UtcNow; await _context.Employees.AddAsync(employee); await _context.SaveChangesAsync(); return employee; @@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Employee employee) { - employee.LastModificationTime = DateTime.Now; + employee.LastModificationTime = DateTime.UtcNow; _context.Employees.Update(employee); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/FollowUpDataService.cs b/SeaHaven.DataServices/Implementation/FollowUpDataService.cs index 4eebe82..72ef131 100644 --- a/SeaHaven.DataServices/Implementation/FollowUpDataService.cs +++ b/SeaHaven.DataServices/Implementation/FollowUpDataService.cs @@ -88,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(FollowUps followUp) { - followUp.CreatedDate = DateTime.Now; + followUp.CreatedDate = DateTime.UtcNow; await _context.FollowUps.AddAsync(followUp); await _context.SaveChangesAsync(); return followUp; @@ -96,7 +96,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(FollowUps followUp) { - followUp.LastModificationTime = DateTime.Now; + followUp.LastModificationTime = DateTime.UtcNow; _context.FollowUps.Update(followUp); await _context.SaveChangesAsync(); } @@ -218,7 +218,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(FollowUps followUp, CancellationToken cancellationToken) { - followUp.CreatedDate = DateTime.Now; + followUp.CreatedDate = DateTime.UtcNow; await _context.FollowUps.AddAsync(followUp, cancellationToken); await _context.SaveChangesAsync(cancellationToken); return followUp; @@ -226,7 +226,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken) { - followUp.LastModificationTime = DateTime.Now; + followUp.LastModificationTime = DateTime.UtcNow; _context.FollowUps.Update(followUp); await _context.SaveChangesAsync(cancellationToken); } @@ -238,7 +238,7 @@ namespace SeaHaven.DataServices.Implementation return false; entity.Status = status; - entity.LastModificationTime = DateTime.Now; + entity.LastModificationTime = DateTime.UtcNow; await _context.SaveChangesAsync(cancellationToken); return true; } diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 11cb89e..b331a01 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -98,7 +98,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Locations location) { - location.CreatedDate = DateTime.Now; + location.CreatedDate = DateTime.UtcNow; await _context.Locations.AddAsync(location); await _context.SaveChangesAsync(); return location; @@ -106,7 +106,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Locations location) { - location.LastModificationTime = DateTime.Now; + location.LastModificationTime = DateTime.UtcNow; _context.Locations.Update(location); await _context.SaveChangesAsync(); } @@ -228,7 +228,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Locations location, CancellationToken cancellationToken) { - location.CreatedDate = DateTime.Now; + location.CreatedDate = DateTime.UtcNow; await _context.Locations.AddAsync(location, cancellationToken); await _context.SaveChangesAsync(cancellationToken); return location; @@ -236,7 +236,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Locations location, CancellationToken cancellationToken) { - location.LastModificationTime = DateTime.Now; + location.LastModificationTime = DateTime.UtcNow; _context.Locations.Update(location); await _context.SaveChangesAsync(cancellationToken); } @@ -249,7 +249,7 @@ namespace SeaHaven.DataServices.Implementation if (entity == null) return false; - var now = DateTime.Now; + var now = DateTime.UtcNow; foreach (var contact in entity.Contacts ?? Enumerable.Empty()) { if (contact.IsDeleted != true) diff --git a/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs b/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs index a2418ea..6bc925a 100644 --- a/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs +++ b/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs @@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(PMSchedules schedule) { - schedule.CreatedDate = DateTime.Now; + schedule.CreatedDate = DateTime.UtcNow; await _context.PMSchedules.AddAsync(schedule); await _context.SaveChangesAsync(); return schedule; @@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(PMSchedules schedule) { - schedule.LastModificationTime = DateTime.Now; + schedule.LastModificationTime = DateTime.UtcNow; _context.PMSchedules.Update(schedule); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index d32b77b..b34db11 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -193,7 +193,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(WorkOrder workOrder) { - workOrder.CreatedDate = DateTime.Now; + workOrder.CreatedDate ??= DateTime.UtcNow; await _context.workOrders.AddAsync(workOrder); await _context.SaveChangesAsync(); return workOrder; @@ -201,7 +201,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(WorkOrder workOrder) { - workOrder.LastModificationTime = DateTime.Now; + workOrder.LastModificationTime = DateTime.UtcNow; _context.workOrders.Update(workOrder); await _context.SaveChangesAsync(); } From 27c21ec32e33d8bc89656c9dcf96ede7c9fc41b8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:06:36 -0300 Subject: [PATCH 3/7] fix(services): stamp user, contact, calendar and location audit times in UTC User creation, contact, calendar event and site-contact create/modify/delete stamps used local server time. Validation rules comparing user-entered dates and the JWT expiry are unchanged. --- Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs | 5 ++++- SeaHaven.Services/Implementation/CalendarService.cs | 6 +++--- SeaHaven.Services/Implementation/ContactService.cs | 4 ++-- SeaHaven.Services/Implementation/LocationService.cs | 8 ++++---- SeaHaven.Services/Implementation/UserService.cs | 4 ++-- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs index bceb520..fbab136 100644 --- a/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs @@ -44,7 +44,7 @@ public class CalendarServiceTests StartDate = startDate, EndDate = startDate, IsDeleted = isDeleted, - CreatedDate = DateTime.Now + CreatedDate = DateTime.UtcNow }; ctx.Events.Add(ev); ctx.SaveChanges(); @@ -64,6 +64,7 @@ public class CalendarServiceTests saved.Title.Should().Be("Standup"); saved.IsDeleted.Should().Be(false); saved.CreatedDate.Should().NotBeNull(); + saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] @@ -97,6 +98,7 @@ public class CalendarServiceTests var updated = ctx.Events.Single(); updated.Title.Should().Be("New"); updated.LastModificationTime.Should().NotBeNull(); + updated.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] @@ -134,6 +136,7 @@ public class CalendarServiceTests var row = ctx.Events.Single(); row.IsDeleted.Should().Be(true); row.DeletionTime.Should().NotBeNull(); + row.DeletionTime!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] diff --git a/SeaHaven.Services/Implementation/CalendarService.cs b/SeaHaven.Services/Implementation/CalendarService.cs index cece13d..596c083 100644 --- a/SeaHaven.Services/Implementation/CalendarService.cs +++ b/SeaHaven.Services/Implementation/CalendarService.cs @@ -31,7 +31,7 @@ namespace SeaHaven.Services.Implementation EndDate = input.EndDate, EndTime = input.EndTime, AllDay = input.AllDay, - CreatedDate = DateTime.Now, + CreatedDate = DateTime.UtcNow, IsDeleted = false }; @@ -57,7 +57,7 @@ namespace SeaHaven.Services.Implementation model.EndDate = input.EndDate; model.EndTime = input.EndTime; model.AllDay = input.AllDay; - model.LastModificationTime = DateTime.Now; + model.LastModificationTime = DateTime.UtcNow; await _dataService.UpdateEventAsync(model, cancellationToken); return true; @@ -76,7 +76,7 @@ namespace SeaHaven.Services.Implementation return false; model.IsDeleted = true; - model.DeletionTime = DateTime.Now; + model.DeletionTime = DateTime.UtcNow; await _dataService.UpdateEventAsync(model, cancellationToken); return true; diff --git a/SeaHaven.Services/Implementation/ContactService.cs b/SeaHaven.Services/Implementation/ContactService.cs index cfeab5b..bf359d2 100644 --- a/SeaHaven.Services/Implementation/ContactService.cs +++ b/SeaHaven.Services/Implementation/ContactService.cs @@ -104,7 +104,7 @@ namespace SeaHaven.Services.Implementation ContactType = dto.Type, AccountId = dto.AccountId, createdby = userId, - CreatedDate = DateTime.Now + CreatedDate = DateTime.UtcNow }; var savedContact = await _contactDataService.AddAsync(contact); @@ -151,7 +151,7 @@ namespace SeaHaven.Services.Implementation if (dto.AccountId.HasValue) existingContact.AccountId = dto.AccountId; - existingContact.LastModificationTime = DateTime.Now; + existingContact.LastModificationTime = DateTime.UtcNow; await _contactDataService.UpdateAsync(existingContact); diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index 64b275a..3e89cea 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -399,7 +399,7 @@ namespace SeaHaven.Services.Implementation { ThrowOnInvalidContacts(contacts); - var now = DateTime.Now; + var now = DateTime.UtcNow; var siteContacts = contacts .Select((row, index) => new Contacts { @@ -457,7 +457,7 @@ namespace SeaHaven.Services.Implementation existing.PhoneNumber = phone; existing.SiteContactOrder = i; existing.AccountId = location.AccountId; - existing.LastModificationTime = DateTime.Now; + existing.LastModificationTime = DateTime.UtcNow; } else { @@ -470,7 +470,7 @@ namespace SeaHaven.Services.Implementation PhoneNumber = phone, SiteContactOrder = i, AccountId = location.AccountId, - CreatedDate = DateTime.Now, + CreatedDate = DateTime.UtcNow, createdby = actorId }); } @@ -478,7 +478,7 @@ namespace SeaHaven.Services.Implementation // Rows omitted from the request are soft deleted so historical // WorkOrderContacts keep rendering. - var now = DateTime.Now; + var now = DateTime.UtcNow; foreach (var existing in existingById.Values) { if (keptIds.Contains(existing.Id) || existing.IsDeleted == true) diff --git a/SeaHaven.Services/Implementation/UserService.cs b/SeaHaven.Services/Implementation/UserService.cs index 99105a3..f448a1f 100644 --- a/SeaHaven.Services/Implementation/UserService.cs +++ b/SeaHaven.Services/Implementation/UserService.cs @@ -72,7 +72,7 @@ namespace SeaHaven.Services.Implementation { model.EmailConfirmed = true; model.UserName = model.Email; - model.CreatedDate = DateTime.Now; + model.CreatedDate = DateTime.UtcNow; model.UniqueName = "Active"; model.PhoneNumber = dto.Role; @@ -152,7 +152,7 @@ namespace SeaHaven.Services.Implementation exist.Email = dto.Email; exist.NormalizedEmail = dto.Email.ToUpperInvariant(); exist.NormalizedUserName = dto.Email.ToUpperInvariant(); - exist.CreatedDate = DateTime.Now; + exist.CreatedDate = DateTime.UtcNow; exist.UniqueName = "Active"; exist.PhoneNumber = dto.Role; exist.AccountId = dto.AccountId; From 227691269d1810ed864b064790695386c8429d73 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:07:46 -0300 Subject: [PATCH 4/7] style(data): trim trailing whitespace in touched data services --- .../Implementation/ContactDataService.cs | 8 ++++---- .../Implementation/PMScheduleDataService.cs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/ContactDataService.cs b/SeaHaven.DataServices/Implementation/ContactDataService.cs index e4abf4f..acd7e88 100644 --- a/SeaHaven.DataServices/Implementation/ContactDataService.cs +++ b/SeaHaven.DataServices/Implementation/ContactDataService.cs @@ -130,15 +130,15 @@ namespace SeaHaven.DataServices.Implementation public async Task EmailExistsAsync(string email, int? excludeId = null) { - return await _context.Contacts.AnyAsync(c => - c.Email == email && + return await _context.Contacts.AnyAsync(c => + c.Email == email && (excludeId == null || c.Id != excludeId)); } public async Task PhoneExistsAsync(string phone, int? excludeId = null) { - return await _context.Contacts.AnyAsync(c => - c.PhoneNumber == phone && + return await _context.Contacts.AnyAsync(c => + c.PhoneNumber == phone && (excludeId == null || c.Id != excludeId)); } diff --git a/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs b/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs index 6bc925a..1ce854f 100644 --- a/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs +++ b/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs @@ -46,8 +46,8 @@ namespace SeaHaven.DataServices.Implementation } public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( - int page, - int pageSize, + int page, + int pageSize, string? search = null) { var query = _context.PMSchedules.AsQueryable(); From 1c8da7f344560d3c98a1158508737c5085691e3e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:20:45 -0300 Subject: [PATCH 5/7] docs(readme): record that stored audit times are UTC and need no backfill Review asked whether rows written by the old DateTime.Now stamps need a backfill. They do not: the API has only run on Linux Elastic Beanstalk hosts at their UTC default, and nothing in Terraform, .ebextensions or .platform sets a time zone, so DateTime.Now already equalled UTC there. Record that next to the hosting table so the decision is findable. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 30598d7..0f1afd4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ Both run in `us-east-1` on the .NET 8 Amazon Linux 2023 platform. Terraform in `terraform/live/` owns the environments; GitHub Actions owns the application versions. There is no production environment yet. +Stored created, modified and deletion times are UTC: the API stamps them with +`DateTime.UtcNow`, whatever the host's time zone. The API has only ever run on +Linux Elastic Beanstalk hosts left at their UTC default (nothing in Terraform, +`.ebextensions` or `.platform` sets a time zone), so rows written before the +switch from `DateTime.Now` are already UTC and need no backfill. + ## Architecture ```text From 9091335ff23e6a32c0647c99b67f2fdd2c66f05d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:02:58 -0300 Subject: [PATCH 6/7] fix(auth): reject an unconfirmed new password and report only policy failures as weak --- .../AuthenticationControllerTests.cs | 34 +++++++++++++++++ .../PasswordPolicyTests.cs | 38 +++++++++++++++++++ .../Controllers/AuthenticationController.cs | 7 ++++ .../Auth/IdentityPasswordPolicy.cs | 20 ++++++++++ SeaHaven.Services/DTOs/IdentityDTOs.cs | 4 +- .../Implementation/AuthenticationService.cs | 9 +++-- 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 4187a24..0eaa5b4 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -166,6 +166,40 @@ public class AuthenticationControllerTests "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."); } + [Fact] + public async Task ChangePassword_ConfirmationMismatch_IsRejectedWithoutChangingThePassword() + { + var service = new Mock(); + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@y" }, + CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.Value.Should().BeOfType().Subject.Message.Should().Be("Passwords don't match"); + service.Verify( + s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny())) + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Failed }); + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@x" }, + CancellationToken.None); + + var response = result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Your password could not be changed. Try again."); + response.Message.Should().NotContain("at least 6 characters"); + } + [Fact] public void ChangePassword_RequiresAuthenticatedCaller() { diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs index 946a24d..b33d466 100644 --- a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -114,6 +114,44 @@ public sealed class PasswordPolicyTests : IAsyncDisposable (await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue(); } + [Theory] + [InlineData("ConcurrencyFailure")] + [InlineData("PasswordMismatch")] + [InlineData("DefaultError")] + public async Task ChangePassword_NonPolicyIdentityFailure_IsNotReportedAsAWeakPassword(string code) + { + var user = new ApplicationUser { Id = "member-1", UserName = "member@example.com" }; + var userManager = new Mock>( + Mock.Of>(), null!, null!, null!, null!, null!, null!, null!, null!); + userManager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user); + userManager.Setup(m => m.CheckPasswordAsync(user, CurrentPassword)).ReturnsAsync(true); + userManager.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, "Next2@x")) + .ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = code, Description = "failed" })); + var service = new AuthenticationService( + userManager.Object, + Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.Failed); + } + + [Theory] + [InlineData("PasswordTooShort", true)] + [InlineData("PasswordRequiresUpper", true)] + [InlineData("PasswordRequiresDigit", true)] + [InlineData("PasswordRequiresNonAlphanumeric", true)] + [InlineData("ConcurrencyFailure", false)] + [InlineData("PasswordMismatch", false)] + public void IsPolicyRejection_MatchesOnlyThePasswordRuleCodes(string code, bool expected) + { + IdentityPasswordPolicy.IsPolicyRejection(IdentityResult.Failed(new IdentityError { Code = code })) + .Should().Be(expected); + } + [Fact] public async Task ChangePassword_CancelledToken_Throws() { diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 7b6569a..dcd4dfd 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -60,6 +60,11 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost] public async Task ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) { + // [Compare] already rejects this during model validation; the check here keeps the + // unconfirmed password from ever being set if that validation is bypassed. + if (!string.Equals(usermodel.Newpassword, usermodel.Confirmpassword, StringComparison.Ordinal)) + return BadRequest(new Response { Status = "Password confirmation does not match", Message = "Passwords don't match" }); + var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken); return result.Status switch @@ -68,6 +73,8 @@ namespace Api.SeaHavenIndustries.Controllers Ok(new Response { Status = "Success ", Message = "Password successfully changed" }), ChangePasswordStatus.PasswordRejected => BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }), + ChangePasswordStatus.Failed => + BadRequest(new Response { Status = "Password not changed", Message = "Your password could not be changed. Try again." }), _ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" }) }; } diff --git a/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs index a0161ff..50e7543 100644 --- a/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs +++ b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs @@ -23,5 +23,25 @@ namespace Data.SeaHavenIndustries options.RequireLowercase = false; options.RequiredUniqueChars = 1; } + + private static readonly HashSet PolicyErrorCodes = new(StringComparer.Ordinal) + { + nameof(IdentityErrorDescriber.PasswordTooShort), + nameof(IdentityErrorDescriber.PasswordRequiresUpper), + nameof(IdentityErrorDescriber.PasswordRequiresLower), + nameof(IdentityErrorDescriber.PasswordRequiresDigit), + nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric), + nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars) + }; + + /// + /// True when Identity refused the password itself. Other failures, such as a + /// concurrency conflict, must not be reported to the user as a weak password. + /// + public static bool IsPolicyRejection(IdentityResult result) + { + ArgumentNullException.ThrowIfNull(result); + return result.Errors.Any(error => PolicyErrorCodes.Contains(error.Code)); + } } } diff --git a/SeaHaven.Services/DTOs/IdentityDTOs.cs b/SeaHaven.Services/DTOs/IdentityDTOs.cs index e959478..c618500 100644 --- a/SeaHaven.Services/DTOs/IdentityDTOs.cs +++ b/SeaHaven.Services/DTOs/IdentityDTOs.cs @@ -15,7 +15,9 @@ namespace SeaHaven.Services.DTOs { Succeeded, CurrentPasswordIncorrect, - PasswordRejected + PasswordRejected, + /// Identity failed for a reason other than the password policy. + Failed } public sealed class ChangePasswordResultDTO diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index e293276..2a44325 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -93,9 +93,12 @@ namespace SeaHaven.Services.Implementation return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? ""); - return ChangePasswordResult(result.Succeeded - ? ChangePasswordStatus.Succeeded - : ChangePasswordStatus.PasswordRejected); + if (result.Succeeded) + return ChangePasswordResult(ChangePasswordStatus.Succeeded); + + return ChangePasswordResult(IdentityPasswordPolicy.IsPolicyRejection(result) + ? ChangePasswordStatus.PasswordRejected + : ChangePasswordStatus.Failed); } private static ChangePasswordResultDTO ChangePasswordResult(ChangePasswordStatus status) => From 53538994182e18cf28ddb5df47fc8d09294b4134 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:18:06 -0300 Subject: [PATCH 7/7] feat(uplifts): order the rejected queue by decision time and include legacy denied rows --- .../UpliftQueueReadTests.cs | 87 +++++++++++++++++++ .../Implementation/UpliftDataService.cs | 14 ++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 5c398cf..b5427f5 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -164,6 +164,59 @@ public sealed class UpliftQueueReadTests new DateTime(2026, 3, 10)); } + [Fact] + public async Task List_RejectedStatus_OrdersMostRecentlyRejectedFirst() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + // Request order (3/1, 3/2, 3/3) deliberately disagrees with decision order. + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)), + Request(dispatch, "Rejected", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)), + Request(dispatch, "Rejected", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.DecidedAt).Should().Equal( + new DateTime(2026, 3, 20), + new DateTime(2026, 3, 15), + new DateTime(2026, 3, 10)); + } + + [Fact] + public async Task List_RejectedStatus_ReturnsOnlyRejectedRequests_IncludingLegacyDenied() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1)), + Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 3)), + Request(dispatch, "Revoked", new DateTime(2026, 3, 4), decided: new DateTime(2026, 3, 5)), + Request(dispatch, "Withdrawn", new DateTime(2026, 3, 6)), + Request(dispatch, "Rejected", new DateTime(2026, 3, 7), decided: new DateTime(2026, 3, 8)), + Request(dispatch, "Denied", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); + + result.Total.Should().Be(2); + result.Items.Select(i => i.Status).Should().Equal("Rejected", "Rejected"); + result.Items.Select(i => i.DecidedAt).Should().Equal( + new DateTime(2026, 3, 8), + new DateTime(2026, 2, 2)); + } + [Fact] public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder() { @@ -558,6 +611,40 @@ public sealed class UpliftQueueReadTests result.Items.Single().DecidedByName.Should().Be("Grace Hopper"); } + [Fact] + public async Task List_RejectedRow_CarriesRejecterRequesterDecisionTimeAndReason() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-R", "SITE-R", "Plumbing"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-R"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Lovelace" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Rejected", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2, 16, 40, 0), + DecidedByUserId = "user-7", + DecisionNote = "Outside this work order's scope", + RequestedByVendorName = "Gateway", + RequiredTier = 1, + RequestedNTE = 250m, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); + + var row = result.Items.Single(); + row.DecidedByName.Should().Be("Ada Lovelace"); + row.RequestedByName.Should().Be("Gateway"); + row.DecidedAt.Should().Be(new DateTime(2026, 3, 2, 16, 40, 0)); + row.DecisionNote.Should().Be("Outside this work order's scope"); + row.WorkOrderNumber.Should().Be("WO-R"); + } + [Fact] public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue() { diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 09566f3..0c3706c 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -11,6 +11,9 @@ namespace SeaHaven.DataServices.Implementation public class UpliftDataService : IUpliftDataService { private static readonly ConcurrentDictionary WorkOrderGates = new(); + + // The Rejected queue also surfaces the legacy "Denied" spelling, which reads as Rejected. + private static readonly string[] RejectedStatuses = { "Rejected", "Denied" }; private readonly ApplicationDbContext _context; public UpliftDataService(ApplicationDbContext context) @@ -46,7 +49,9 @@ namespace SeaHaven.DataServices.Implementation && (d.IsDeleted == null || d.IsDeleted == false) select new { u, d, v, ev, effectiveWorkOrderId, workOrder, reqUser, decUser }; - if (!string.IsNullOrWhiteSpace(status)) + if (string.Equals(status, "Rejected", StringComparison.Ordinal)) + query = query.Where(x => RejectedStatuses.Contains(x.u.Status)); + else if (!string.IsNullOrWhiteSpace(status)) query = query.Where(x => x.u.Status == status); if (tier.HasValue) query = query.Where(x => x.u.RequiredTier == tier.Value); @@ -54,12 +59,13 @@ namespace SeaHaven.DataServices.Implementation var total = await query.CountAsync(cancellationToken); // Approval queue read contract: the actionable queue (Pending) surfaces the - // oldest request first; the decision log (Approved) surfaces the most - // recently decided first. Every other read keeps the historical + // oldest request first; the decision logs (Approved, Rejected) surface the + // most recently decided first. Every other read keeps the historical // newest-request-first order. Id is the deterministic tiebreaker. if (string.Equals(status, "Pending", StringComparison.Ordinal)) query = query.OrderBy(x => x.u.CreatedDate).ThenBy(x => x.u.Id); - else if (string.Equals(status, "Approved", StringComparison.Ordinal)) + else if (string.Equals(status, "Approved", StringComparison.Ordinal) + || string.Equals(status, "Rejected", StringComparison.Ordinal)) query = query.OrderByDescending(x => x.u.DecidedAt).ThenByDescending(x => x.u.Id); else query = query.OrderByDescending(x => x.u.CreatedDate).ThenByDescending(x => x.u.Id);