diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 5ec4371..544aa1d 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,70 @@ 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 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() + { + 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/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/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs new file mode 100644 index 0000000..b33d466 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -0,0 +1,218 @@ +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(); + } + + [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() + { + 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.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/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/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 469c1e9..ed1e8da 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -59,20 +59,33 @@ namespace Api.SeaHavenIndustries.Controllers } + [Authorize] [Route("ChangePassword")] [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 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 }), + 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" }) + }; } + 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 5dd1d2f..75f0d67 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -44,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..50e7543 --- /dev/null +++ b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs @@ -0,0 +1,47 @@ +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; + } + + 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/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 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..acd7e88 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(); } @@ -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/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 5215e0e..a3d9a6c 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -100,7 +100,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; @@ -108,7 +108,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(); } @@ -232,7 +232,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; @@ -240,7 +240,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); } diff --git a/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs b/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs index a2418ea..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(); @@ -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/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); 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(); } diff --git a/SeaHaven.Services/DTOs/IdentityDTOs.cs b/SeaHaven.Services/DTOs/IdentityDTOs.cs index ad3b8cb..c618500 100644 --- a/SeaHaven.Services/DTOs/IdentityDTOs.cs +++ b/SeaHaven.Services/DTOs/IdentityDTOs.cs @@ -11,6 +11,20 @@ namespace SeaHaven.Services.DTOs public string Id { get; set; } = string.Empty; } + public enum ChangePasswordStatus + { + Succeeded, + CurrentPasswordIncorrect, + PasswordRejected, + /// Identity failed for a reason other than the password policy. + Failed + } + + 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 1a0d263..b168f9a 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -86,16 +86,30 @@ 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 ?? ""); + if (result.Succeeded) + return ChangePasswordResult(ChangePasswordStatus.Succeeded); + + return ChangePasswordResult(IdentityPasswordPolicy.IsPolicyRejection(result) + ? ChangePasswordStatus.PasswordRejected + : ChangePasswordStatus.Failed); } + 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/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 fabeb97..0caa441 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -579,7 +579,7 @@ namespace SeaHaven.Services.Implementation { ThrowOnInvalidContacts(contacts); - var now = DateTime.Now; + var now = DateTime.UtcNow; var siteContacts = contacts .Select((row, index) => new Contacts { @@ -634,7 +634,7 @@ namespace SeaHaven.Services.Implementation existing.PhoneNumber = phone; existing.SiteContactOrder = i; existing.AccountId = location.AccountId; - existing.LastModificationTime = DateTime.Now; + existing.LastModificationTime = DateTime.UtcNow; } else { @@ -647,7 +647,7 @@ namespace SeaHaven.Services.Implementation PhoneNumber = phone, SiteContactOrder = i, AccountId = location.AccountId, - CreatedDate = DateTime.Now, + CreatedDate = DateTime.UtcNow, createdby = actorId }); } @@ -655,7 +655,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; diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs index 210efd6..3d769e7 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); /// /// Emails a reset code when the address belongs to an active account. Gives no 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();