mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
Merge remote-tracking branch 'origin/main' into fix/ab/sh-403-reset-code-hardening
This commit is contained in:
commit
9bd22e9a74
29 changed files with 631 additions and 75 deletions
|
|
@ -117,11 +117,11 @@ public class AuthenticationControllerTests
|
|||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
|
||||
.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<OkObjectResult>().Subject;
|
||||
var response = ok.Value.Should().BeOfType<Response>().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<IAuthenticationService>();
|
||||
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.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<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().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<IAuthenticationService>();
|
||||
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny<CancellationToken>()))
|
||||
.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<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().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<IAuthenticationService>();
|
||||
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<BadRequestObjectResult>().Subject;
|
||||
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Passwords don't match");
|
||||
service.Verify(
|
||||
s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny<CancellationToken>()))
|
||||
.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<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>().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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
218
Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
Normal file
218
Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the password rule through the same Identity registration the API
|
||||
/// host uses, so these assertions describe the rule that runs in production.
|
||||
/// </summary>
|
||||
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<ApplicationDbContext>(options =>
|
||||
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
|
||||
services.AddSeaHavenIdentity();
|
||||
_provider = services.BuildServiceProvider();
|
||||
_scope = _provider.CreateAsyncScope();
|
||||
}
|
||||
|
||||
private UserManager<ApplicationUser> UserManager =>
|
||||
_scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
|
||||
[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<IOptions<IdentityOptions>>().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<UserManager<ApplicationUser>>(
|
||||
Mock.Of<IUserStore<ApplicationUser>>(), 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<IUserDataService>(),
|
||||
Mock.Of<IForgetPasswordDataService>(),
|
||||
Mock.Of<IEmailSender>());
|
||||
|
||||
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<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode()
|
||||
{
|
||||
var user = await CreateUserAsync();
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
|
||||
.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<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
|
||||
{
|
||||
var errors = new List<IdentityError>();
|
||||
foreach (var validator in UserManager.PasswordValidators)
|
||||
{
|
||||
var result = await validator.ValidateAsync(UserManager, user, password);
|
||||
errors.AddRange(result.Errors);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private async Task<ApplicationUser> 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<IForgetPasswordDataService>? forget = null) =>
|
||||
new(
|
||||
UserManager,
|
||||
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
|
||||
Mock.Of<IUserDataService>(),
|
||||
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
|
||||
Mock.Of<IEmailSender>());
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _scope.DisposeAsync();
|
||||
await _provider.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class WorkOrderDataServiceTimestampTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -59,20 +59,33 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[Route("ChangePassword")]
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> 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<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Infrastructure
|
||||
{
|
||||
public static class IdentityRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services)
|
||||
{
|
||||
return services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = false;
|
||||
IdentityPasswordPolicy.Apply(options.Password);
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,12 +44,7 @@ ConfigurationManager configuration = builder.Configuration;
|
|||
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = false;
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
builder.Services.AddSeaHavenIdentity();
|
||||
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
|
|
|
|||
47
Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs
Normal file
47
Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> PolicyErrorCodes = new(StringComparer.Ordinal)
|
||||
{
|
||||
nameof(IdentityErrorDescriber.PasswordTooShort),
|
||||
nameof(IdentityErrorDescriber.PasswordRequiresUpper),
|
||||
nameof(IdentityErrorDescriber.PasswordRequiresLower),
|
||||
nameof(IdentityErrorDescriber.PasswordRequiresDigit),
|
||||
nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric),
|
||||
nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static bool IsPolicyRejection(IdentityResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
return result.Errors.Any(error => PolicyErrorCodes.Contains(error.Code));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Accounts> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Assets> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Category> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Contacts> 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<bool> 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<bool> 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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Dispatch> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Employee> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<FollowUps> 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<FollowUps> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Locations> 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<Locations> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}
|
||||
|
||||
public async Task<(IEnumerable<PMSchedules> 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<PMSchedules> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ namespace SeaHaven.DataServices.Implementation
|
|||
public class UpliftDataService : IUpliftDataService
|
||||
{
|
||||
private static readonly ConcurrentDictionary<int, SemaphoreSlim> 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);
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<WorkOrder> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,20 @@ namespace SeaHaven.Services.DTOs
|
|||
public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public enum ChangePasswordStatus
|
||||
{
|
||||
Succeeded,
|
||||
CurrentPasswordIncorrect,
|
||||
PasswordRejected,
|
||||
/// <summary>Identity failed for a reason other than the password policy.</summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
public sealed class ChangePasswordResultDTO
|
||||
{
|
||||
public ChangePasswordStatus Status { get; init; }
|
||||
}
|
||||
|
||||
public class UpdateProfileRequestDTO
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
|
|
|||
|
|
@ -86,16 +86,30 @@ namespace SeaHaven.Services.Implementation
|
|||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken)
|
||||
public async Task<ChangePasswordResultDTO> 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<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await _userDataService.UpdateProfileAsync(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
public interface IAuthenticationService
|
||||
{
|
||||
Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken);
|
||||
Task<bool> ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken);
|
||||
Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
|
||||
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Emails a reset code when the address belongs to an active account. Gives no
|
||||
|
|
|
|||
|
|
@ -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<ApplicationDbContext>(options => {
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||
{
|
||||
options.UseSqlServer(connectionString);
|
||||
}, ServiceLifetime.Transient);
|
||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
|
|
@ -39,11 +40,7 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
|||
builder.Services.AddIdentityCore<ApplicationUser>(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<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>().AddSignInManager()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue