Merge remote-tracking branch 'origin/main' into fix/ab/sh-409-invalidate-sessions-on-reset

# Conflicts:
#	Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs
#	Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
#	SeaHaven.Services/Implementation/AuthenticationService.cs
This commit is contained in:
Alexandre Brandizzi 2026-09-25 19:22:33 -03:00
commit 987ec455f2
28 changed files with 6506 additions and 162 deletions

View file

@ -232,35 +232,45 @@ public class AuthenticationControllerTests
}
[Fact]
public async Task ForgetPassword_Found_ReturnsCheckEmailMessage()
public async Task ForgetPassword_ReturnsTheSameSuccessWhetherOrNotTheEmailIsRegistered()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(true);
var controller = NewController(service);
var result = await controller.ForgetPassword("a@b.com", CancellationToken.None);
var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
var unregistered = await controller.ForgetPassword(null, "x@y.com", CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var ok = registered.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Success ");
response.Message.Should().Be("Please check your email for code");
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
Json(unregistered.Should().BeOfType<OkObjectResult>().Subject.Value).Should().Be(Json(ok.Value));
service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>()), Times.Once);
service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage()
public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SECRET a@b.com"));
var logger = new Mock<ILogger<AuthenticationController>>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var controller = new AuthenticationController(service.Object, logger.Object);
var controller = NewController(service);
var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
var result = await controller.ForgetPassword("x@y.com", CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Error");
response.Message.Should().Be("No such email is registered");
var response = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Theory]
@ -269,11 +279,11 @@ public class AuthenticationControllerTests
public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage)
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
var controller = NewController(service);
var result = await controller.VerificationCode("123456", CancellationToken.None);
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, null, null, CancellationToken.None);
if (matched)
{
@ -291,6 +301,33 @@ public class AuthenticationControllerTests
}
}
[Fact]
public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService()
{
var service = new Mock<IAuthenticationService>();
var controller = NewController(service);
var result = await controller.VerificationCode(null, null, "123456", CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>()
.Which.Message.Should().Be("Code Not Matched");
service.Verify(s => s.VerifyCodeAsync(null, "123456", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.VerifyCodeAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail"));
var controller = NewController(service);
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "1" }, null, null, CancellationToken.None);
Json(result.Should().BeOfType<BadRequestObjectResult>().Subject.Value)
.Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" }));
}
[Fact]
public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage()
{

View file

@ -6,6 +6,7 @@ using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
@ -27,7 +28,7 @@ public class AuthenticationServiceTests
hasher = h;
var jwtOptions = Microsoft.Extensions.Options.Options.Create(JwtOptions);
var sessionStamps = new SessionStampService(userData.Object, new InMemorySessionStampCache(TimeProvider.System), jwtOptions);
return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, sessionStamps);
return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, TimeProvider.System, sessionStamps);
}
private static JwtOptions JwtOptions => new()
@ -101,31 +102,36 @@ public class AuthenticationServiceTests
}
[Fact]
public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail()
public async Task ForgetPassword_RegisteredEmail_StoresOnlyASaltedHashAndEmailsTheCode()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
string? stored = null, salt = null, body = null;
DateTime expires = default;
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, CancellationToken>((_, _, h, s, e, _) => { stored = h; salt = s; expires = e; })
.Returns(Task.CompletedTask);
email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny<string>()))
.Callback<string, string, string>((_, _, b) => body = b)
.ReturnsAsync(true);
var service = NewService(userData, forget, email, out _, out _);
var before = DateTime.UtcNow;
var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
found.Should().BeTrue();
forget.Verify(
f => f.ReplaceCodeAsync(
user.Email!,
user.Id,
It.Is<string>(code => code.Length == 6 && code.All(char.IsDigit)),
It.IsAny<CancellationToken>()),
Times.Once);
email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is<string>(b => b.Contains("Your Password Reset Code is:"))), Times.Once);
var code = System.Text.RegularExpressions.Regex.Match(body!, @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
code.Should().HaveLength(6);
stored.Should().NotBe(code).And.MatchRegex("^[0-9a-f]{64}$");
PasswordResetCodeSecrets.Matches(salt!, code, stored!).Should().BeTrue();
expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5));
}
[Fact]
public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode()
public async Task ForgetPassword_UnknownEmail_SendsNothingButStillDoesTheDatabaseRoundTrip()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
@ -134,40 +140,90 @@ public class AuthenticationServiceTests
var service = NewService(userData, forget, email, out _, out _);
var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
forget.Verify(f => f.RemoveByEmailAsync("nope@example.com", It.IsAny<CancellationToken>()), Times.Once);
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IdentityTestHelpers.User(isDeleted: true));
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
var service = NewService(userData, forget, email, out _, out _);
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
found.Should().BeFalse();
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task VerifyCode_ForwardsDataServiceResult(bool exists)
[InlineData(null, "123456")]
[InlineData("", "123456")]
[InlineData(" ", "123456")]
[InlineData("a@b.com", null)]
[InlineData("a@b.com", "")]
public async Task VerifyCode_WithoutEmailOrCode_FailsWithoutTouchingStoredCodes(string? emailAddress, string? code)
{
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny<CancellationToken>())).ReturnsAsync(exists);
var forget = new Mock<IForgetPasswordDataService>(MockBehavior.Strict);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out _, out _);
var result = await service.VerifyCodeAsync("abc", CancellationToken.None);
var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None);
result.Should().Be(exists);
result.Should().BeFalse();
}
[Fact]
public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset()
public async Task ResetPassword_NoPendingCodeForEmail_ReturnsFalseWithoutReset()
{
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
forget.Setup(f => f.GetByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ForgetPasswordCode?)null);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
var result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None);
var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None);
result.Should().BeFalse();
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ResetPassword_AttemptBudgetSpent_DeletesTheCodeAndFails()
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash("s", "123456"), FailedAttempts = 5 };
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
result.Should().BeFalse();
forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny<CancellationToken>()), Times.Once);
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Theory]
[InlineData("123456", "123456", true)]
[InlineData("123456", " 123456 ", true)]
[InlineData("123456", "123457", false)]
public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected)
{
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(salt, issued);
PasswordResetCodeSecrets.Matches(salt, candidate, hash).Should().Be(expected);
PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash);
PasswordResetCodeSecrets.Matches("", issued, hash).Should().BeFalse();
PasswordResetCodeSecrets.Matches(salt, issued, "").Should().BeFalse();
}
}

View file

@ -9,6 +9,7 @@ using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
@ -133,6 +134,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Mock.Of<IUserDataService>(),
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IEmailSender>(),
TimeProvider.System,
Mock.Of<ISessionStampService>());
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
@ -170,10 +172,19 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
{
var user = await CreateUserAsync();
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var salt = PasswordResetCodeSecrets.NewSalt();
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" });
.ReturnsAsync(new ForgetPasswordCode
{
Id = 7,
Email = user.Email!,
UserId = user.Id,
CodeSalt = salt,
CodeHash = PasswordResetCodeSecrets.Hash(salt, "123456"),
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10)
});
forget.Setup(f => f.TryConsumeAttemptAsync(7, It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var service = NewAuthenticationService(forget);
var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None);
@ -181,6 +192,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
reset.Should().BeFalse();
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny<CancellationToken>()), Times.Once);
}
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
@ -210,6 +222,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IEmailSender>(),
TimeProvider.System,
Mock.Of<ISessionStampService>());
public async ValueTask DisposeAsync()

View file

@ -0,0 +1,276 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Revoke rules exercised end to end through both revoke endpoints (the Uplift
/// Approvals route and the work-order route) against the real services, so a
/// refusal is observed as the HTTP result and the unchanged stored state.
/// </summary>
public sealed class UpliftRevokeEndpointRulesTests
{
private const int WorkOrderId = 1;
private const int DispatchId = 10;
private const int AutoApprovedId = 100;
private const int AdminApprovedId = 101;
public enum RevokeRoute
{
UpliftApprovals,
WorkOrder,
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ClaimsPrincipal OrgWideUser(string userId, string role) =>
new(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role),
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
},
"test"));
private static async Task SeedAsync(ApplicationDbContext context)
{
context.Accounts.Add(new Accounts { Id = 1, Name = "Acme Corp", IsDeleted = false });
context.Users.Add(new ApplicationUser { Id = "admin-1", UserName = "admin-1", FirstName = "Ada", LastName = "Admin" });
context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1", FirstName = "Dee", LastName = "Dispatcher" });
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = DispatchId,
VendorId = 1,
WorkOrderId = WorkOrderId,
NTEAmount = 2200m,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
context.workOrders.Add(new WorkOrder
{
Id = WorkOrderId,
InternalWONumber = "10000000001",
PrimaryDispatchId = DispatchId,
AccountId = 1,
WorkOrderType = WorkOrderType.PM,
LifecycleStatus = LifecycleStatus.Scheduled,
});
// The admin filed this one themselves and it auto-approved within the allowance.
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = AutoApprovedId,
DispatchId = DispatchId,
CurrentNTE = 1000m,
RequestedNTE = 400m,
Status = UpliftStatus.NoApprovalRequired,
RequiredTier = 0,
NotificationStatus = "Sent",
createdby = "admin-1",
CreatedDate = DateTime.UtcNow.AddHours(-2),
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = AdminApprovedId,
DispatchId = DispatchId,
CurrentNTE = 1400m,
RequestedNTE = 800m,
Status = UpliftStatus.Approved,
RequiredTier = 1,
NotificationStatus = "Sent",
createdby = "dispatcher-1",
CreatedDate = DateTime.UtcNow.AddHours(-1),
DecidedAt = DateTime.UtcNow.AddMinutes(-30),
DecidedByUserId = "admin-1",
});
await context.SaveChangesAsync();
}
private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) =>
new(
new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()));
private static async Task<IActionResult> RevokeAsync(
ApplicationDbContext context,
RevokeRoute route,
ClaimsPrincipal user,
int upliftId,
string? reason)
{
var workOrderFlow = NewWorkOrderUpliftService(context);
var httpContext = new DefaultHttpContext { User = user };
if (route == RevokeRoute.WorkOrder)
{
var controller = new WorkOrderDetailController(
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
workOrderFlow,
Mock.Of<ILogger<WorkOrderDetailController>>())
{
ControllerContext = new ControllerContext { HttpContext = httpContext },
};
return await controller.RevokeUplift(
WorkOrderId,
upliftId,
new RevokeWorkOrderUpliftRequestDto { Reason = reason },
CancellationToken.None);
}
var upliftService = new UpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
Mock.Of<IVendorDocumentStoragePort>(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
workOrderFlow);
var approvals = new UpliftController(upliftService, Mock.Of<ILogger<UpliftController>>())
{
ControllerContext = new ControllerContext { HttpContext = httpContext },
};
return await approvals.Revoke(
upliftId,
new UpliftController.DecisionRequest { Note = reason },
CancellationToken.None);
}
private static async Task AssertUnchangedAsync(ApplicationDbContext context, int upliftId, string status)
{
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
stored.Status.Should().Be(status);
stored.DecisionNote.Should().BeNull();
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(2200m);
(await context.WorkOrderAuditLogs.AsNoTracking().AnyAsync(a => a.Action == "uplift_revoke")).Should().BeFalse();
}
[Fact]
public async Task WorkOrderRoute_AdminRevokingAutoApprovedUpliftTheyRequested_IsForbiddenAndChangesNothing()
{
await using var context = CreateContext();
await SeedAsync(context);
var result = await RevokeAsync(
context,
RevokeRoute.WorkOrder,
OrgWideUser("admin-1", "Admin"),
AutoApprovedId,
"Wrong quote attached");
var refused = result.Should().BeOfType<ObjectResult>().Subject;
refused.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
refused.Value.Should().BeOfType<Response>().Which.Message
.Should().StartWith("You are not authorized to perform this action");
await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired);
}
[Fact]
public async Task ApprovalsRoute_AdminRevokingAutoApprovedUplift_IsRefusedAndChangesNothing()
{
await using var context = CreateContext();
await SeedAsync(context);
var result = await RevokeAsync(
context,
RevokeRoute.UpliftApprovals,
OrgWideUser("admin-1", "Admin"),
AutoApprovedId,
"Wrong quote attached");
var refused = result.Should().BeOfType<BadRequestObjectResult>().Subject;
refused.Value.Should().BeOfType<Response>().Which.Message
.Should().StartWith("This uplift request cannot be revoked");
await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired);
}
[Theory]
[InlineData(RevokeRoute.UpliftApprovals)]
[InlineData(RevokeRoute.WorkOrder)]
public async Task AdminRevokingAdminApprovedUpliftWithReason_Succeeds(RevokeRoute route)
{
await using var context = CreateContext();
await SeedAsync(context);
var result = await RevokeAsync(
context,
route,
OrgWideUser("admin-1", "Admin"),
AdminApprovedId,
" Approved against the wrong quote ");
result.Should().BeOfType<OkObjectResult>();
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AdminApprovedId);
stored.Status.Should().Be(UpliftStatus.Revoked);
stored.DecisionNote.Should().Be("Approved against the wrong quote");
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1400m);
var audit = await context.WorkOrderAuditLogs.AsNoTracking().SingleAsync(a => a.Action == "uplift_revoke");
audit.OldValue.Should().Be(UpliftStatus.Approved);
audit.NewValue.Should().Be(UpliftStatus.Revoked);
}
[Theory]
[InlineData(RevokeRoute.UpliftApprovals)]
[InlineData(RevokeRoute.WorkOrder)]
public async Task AdminRevokingAdminApprovedUpliftWithoutReason_IsRefused(RevokeRoute route)
{
await using var context = CreateContext();
await SeedAsync(context);
var result = await RevokeAsync(context, route, OrgWideUser("admin-1", "Admin"), AdminApprovedId, " ");
result.Should().BeOfType<BadRequestObjectResult>();
await AssertUnchangedAsync(context, AdminApprovedId, UpliftStatus.Approved);
}
[Fact]
public async Task WorkOrderRoute_DispatcherRevokingOwnAutoApprovedUpliftWithoutReason_Succeeds()
{
await using var context = CreateContext();
await SeedAsync(context);
var own = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == AutoApprovedId);
own.createdby = "dispatcher-1";
await context.SaveChangesAsync();
var result = await RevokeAsync(
context,
RevokeRoute.WorkOrder,
OrgWideUser("dispatcher-1", "Dispatcher"),
AutoApprovedId,
null);
result.Should().BeOfType<OkObjectResult>();
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AutoApprovedId);
stored.Status.Should().Be(UpliftStatus.Revoked);
stored.DecisionNote.Should().BeNull();
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1000m);
}
}

View file

@ -1,11 +1,15 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Runtime.CompilerServices;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
@ -108,30 +112,47 @@ namespace Api.SeaHavenIndustries.Controllers
#region Forget Password Area
public const string ForgetPasswordMessage =
"If that email belongs to an account, a reset code has been sent to it.";
// Email and code are read from the JSON body so they stay out of URLs and proxy
// access logs; the query-string form is still accepted for older clients.
[AllowAnonymous]
[HttpPost()]
[Route("ForgetPassword")]
public async Task<IActionResult> ForgetPassword(string Email, CancellationToken cancellationToken)
{
var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken);
if (found)
{
return Ok(new Response { Status = "Success ", Message = "Please check your email for code" });
}
else
{
return BadRequest(new Response { Status = "Error", Message = "No such email is registered" });
}
}
//need email and code
[AllowAnonymous]
[HttpPost()]
[Route("VerificationCode")]
public async Task<IActionResult> VerificationCode(string code, CancellationToken cancellationToken)
[EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)]
public async Task<IActionResult> ForgetPassword(
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ForgetPasswordRequest_Dto? body,
[FromQuery(Name = "Email")] string? email,
CancellationToken cancellationToken)
{
try
{
if (await _authenticationService.VerifyCodeAsync(code, cancellationToken))
await _authenticationService.ForgetPasswordAsync(body?.Email ?? email, cancellationToken);
}
catch (Exception ex)
{
// Same answer as success: a failure only a registered address can hit
// must not reveal that the address is registered.
LogResetFailure(ex);
}
return Ok(new Response { Status = "Success ", Message = ForgetPasswordMessage });
}
[AllowAnonymous]
[HttpPost()]
[Route("VerificationCode")]
[EnableRateLimiting(PasswordResetRateLimiting.VerificationCodePolicy)]
public async Task<IActionResult> VerificationCode(
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] VerificationCode_Dto? body,
[FromQuery] string? email,
[FromQuery] string? code,
CancellationToken cancellationToken)
{
try
{
if (await _authenticationService.VerifyCodeAsync(body?.Email ?? email, body?.Code ?? code, cancellationToken))
{
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
@ -143,14 +164,15 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
LogResetFailure(ex);
return BadRequest(new Response { Status = "Error", Message = "Code Not Matched" });
}
}
// need email, password and code
[AllowAnonymous]
[HttpPost()]
[Route("ResetPassword")]
[EnableRateLimiting(PasswordResetRateLimiting.ResetPasswordPolicy)]
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken)
{
try
@ -166,11 +188,21 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
LogResetFailure(ex);
return BadRequest(new Response { Status = "Error", Message = "Your email or code not found please check" });
}
}
// These endpoints answer failures exactly like a wrong code or an unknown email, so
// an error only a registered account can trigger reveals nothing. Exception
// messages here can echo the email or code, so only the type is logged.
private void LogResetFailure(Exception exception, [CallerMemberName] string operation = "")
{
_logger.LogError(
"Password reset {Operation} failed with {ExceptionType}.",
operation,
exception.GetType().FullName);
}
#endregion
}
}

View file

@ -4,9 +4,20 @@ namespace Api.SeaHavenIndustries.DTOs
{
public class ForgetPassword_Dto
{
public string Email { get; set; }
public string? Email { get; set; }
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
public string? Password { get; set; }
public string? Code { get; set; }
}
public class ForgetPasswordRequest_Dto
{
public string? Email { get; set; }
}
public class VerificationCode_Dto
{
public string? Email { get; set; }
public string? Code { get; set; }
}
}

View file

@ -0,0 +1,84 @@
using System.Net;
using System.Threading.RateLimiting;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
namespace Api.SeaHavenIndustries.Infrastructure;
/// <summary>
/// Per-client limits on the anonymous password reset endpoints, plus the
/// forwarded-header trust that makes "per client" mean the caller and not the proxy.
/// </summary>
public static class PasswordResetRateLimiting
{
public const string ForgetPasswordPolicy = "password-reset-request";
public const string VerificationCodePolicy = "password-reset-verify";
public const string ResetPasswordPolicy = "password-reset-confirm";
public const int PermitLimit = 10;
public static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
public const string RejectedMessage = "Too many requests. Please try again later.";
/// <summary>
/// The API runs on Elastic Beanstalk behind an application load balancer and the
/// instance's nginx, so every request reaches Kestrel from loopback. X-Forwarded-For
/// is read right to left through loopback and private (VPC) hops only; the first
/// public address is the client. Entries a caller writes further left are ignored.
/// </summary>
public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services)
{
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
options.ForwardLimit = null;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("127.0.0.0"), 8));
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("172.16.0.0"), 12));
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("192.168.0.0"), 16));
options.KnownProxies.Add(IPAddress.IPv6Loopback);
});
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(
new Response { Status = "Error", Message = RejectedMessage },
cancellationToken);
};
AddPerClientPolicy(options, ForgetPasswordPolicy);
AddPerClientPolicy(options, VerificationCodePolicy);
AddPerClientPolicy(options, ResetPasswordPolicy);
});
return services;
}
public static string ClientPartitionKey(HttpContext context)
{
var address = context.Connection.RemoteIpAddress;
if (address == null)
return "unknown";
return (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address).ToString();
}
private static void AddPerClientPolicy(RateLimiterOptions options, string policyName)
{
options.AddPolicy(policyName, context => RateLimitPartition.GetFixedWindowLimiter(
ClientPartitionKey(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = PermitLimit,
Window = Window,
QueueLimit = 0,
AutoReplenishment = true
}));
}
}

View file

@ -56,6 +56,7 @@ builder.Services.AddResponseCompression(opts =>
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
new[] { "application/octet-stream" });
});
builder.Services.AddPasswordResetRateLimiting();
builder.Services.AddCors(option =>
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
@ -172,6 +173,9 @@ builder.Services.AddSwaggerGen(c =>
var app = builder.Build();
// First, so every later middleware and the rate limiter see the real client address.
app.UseForwardedHeaders();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment() || app.Environment.IsProduction())
{
@ -189,6 +193,7 @@ app.UseStaticFiles();
app.UseCors();
app.UseMiddleware<LegacyDeprecationMiddleware>();
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseMiddleware<SentryRequestMetadataMiddleware>();
app.UseAuthorization();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class HashPasswordResetCodes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Pending codes were stored in plaintext with no expiry. They are
// short-lived, so they are discarded rather than migrated; affected users
// request a new code.
migrationBuilder.Sql("DELETE FROM [ForgetPasswordCodes];");
migrationBuilder.AddColumn<string>(
name: "CodeHash",
table: "ForgetPasswordCodes",
type: "nvarchar(64)",
maxLength: 64,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "CodeSalt",
table: "ForgetPasswordCodes",
type: "nvarchar(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<DateTime>(
name: "ExpiresAtUtc",
table: "ForgetPasswordCodes",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<int>(
name: "FailedAttempts",
table: "ForgetPasswordCodes",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CodeHash",
table: "ForgetPasswordCodes");
migrationBuilder.DropColumn(
name: "CodeSalt",
table: "ForgetPasswordCodes");
migrationBuilder.DropColumn(
name: "ExpiresAtUtc",
table: "ForgetPasswordCodes");
migrationBuilder.DropColumn(
name: "FailedAttempts",
table: "ForgetPasswordCodes");
}
}
}

View file

@ -1527,10 +1527,26 @@ namespace Data.SeaHavenIndustries.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CodeHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CodeSalt")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("datetime2");
b.Property<int>("FailedAttempts")
.HasColumnType("int");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(max)");

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
@ -11,6 +12,23 @@ namespace Data.SeaHavenIndustries
public int Id { get; set; }
public string UserId { get; set; }
public string Email { get; set; }
public string Code { get; set; }
/// <summary>
/// Legacy plaintext column. It is always written empty; only
/// <see cref="CodeHash"/> is compared.
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>Lowercase hex SHA-256 of "<see cref="CodeSalt"/>:code".</summary>
[MaxLength(64)]
public string CodeHash { get; set; } = string.Empty;
[MaxLength(32)]
public string CodeSalt { get; set; } = string.Empty;
public DateTime ExpiresAtUtc { get; set; }
/// <summary>Checks consumed against this code, including concurrent ones still in flight.</summary>
public int FailedAttempts { get; set; }
}
}

View file

@ -0,0 +1,26 @@
namespace SeaHaven.DataServices.Helpers
{
/// <summary>
/// Which stored uplift statuses still count as a live uplift on a work order.
/// Cancelled (legacy "Cancelled", "Withdrawn", "Expired") and revoked uplifts do not;
/// pending, approved, auto-approved and rejected ones do. The advanced-search
/// "Has uplift" filter and the board Uplift column both use this, so they cannot drift.
/// </summary>
public static class LiveUpliftStatus
{
/// <summary>
/// Stored statuses that are not live. Kept as an array so EF translates
/// <c>NonLive.Contains(u.Status)</c> inside query predicates.
/// </summary>
public static readonly string[] NonLive =
{
"Withdrawn",
"Cancelled",
"Expired",
"Revoked",
};
public static bool IsLive(string? status) =>
status == null || !NonLive.Contains(status, StringComparer.Ordinal);
}
}

View file

@ -238,11 +238,13 @@ namespace SeaHaven.DataServices.Helpers
CancellationToken cancellationToken)
{
var workOrderIds = workOrders.Select(w => w.Id);
var nonLive = LiveUpliftStatus.NonLive;
var live = context.DispatchUpliftRequests
.AsNoTracking()
.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& !nonLive.Contains(u.Status)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false));

View file

@ -15,12 +15,6 @@ namespace SeaHaven.DataServices.Helpers
WorkOrderType.Reactive,
};
private static readonly string[] RevokedUpliftStatuses =
{
"Withdrawn",
"Cancelled",
};
public static IQueryable<WorkOrder> ApplySeverityFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<int>? severities)
@ -125,18 +119,21 @@ namespace SeaHaven.DataServices.Helpers
if (!hasUplift)
return query;
// No sub-filter: any live uplift. An explicit sub-filter matches exactly the
// statuses it names, so "cancelled" / "revoked" still find those work orders.
var mappedStatuses = MapUpliftStatuses(upliftStatuses);
var anyLive = mappedStatuses.Count == 0;
var nonLive = LiveUpliftStatus.NonLive;
return query.Where(w => context.DispatchUpliftRequests.Any(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Status != "Expired"
&& !RevokedUpliftStatuses.Contains(u.Status)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
&& (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status))));
&& ((anyLive && !nonLive.Contains(u.Status))
|| (!anyLive && mappedStatuses.Contains(u.Status)))));
}
internal static List<string> MapUpliftStatuses(IReadOnlyList<string>? upliftStatuses)
@ -166,6 +163,14 @@ namespace SeaHaven.DataServices.Helpers
mapped.Add("Rejected");
mapped.Add("Denied");
break;
case "cancelled":
mapped.Add("Withdrawn");
mapped.Add("Cancelled");
mapped.Add("Expired");
break;
case "revoked":
mapped.Add("Revoked");
break;
}
}

View file

@ -13,71 +13,76 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public async Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken)
public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken)
{
var normalizedEmail = email.ToLower().Trim();
var normalizedEmail = Normalize(email);
var existing = await _context.ForgetPasswordCodes
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
.FirstOrDefaultAsync(cancellationToken);
.ToListAsync(cancellationToken);
_context.ForgetPasswordCodes.RemoveRange(existing);
if (existing != null)
{
_context.ForgetPasswordCodes.Remove(existing);
await _context.SaveChangesAsync(cancellationToken);
}
var forgetPasswordCode = new ForgetPasswordCode
_context.ForgetPasswordCodes.Add(new ForgetPasswordCode
{
Email = email,
UserId = userId,
Code = code
};
_context.ForgetPasswordCodes.Add(forgetPasswordCode);
Code = string.Empty,
CodeHash = codeHash,
CodeSalt = codeSalt,
ExpiresAtUtc = expiresAtUtc,
FailedAttempts = 0
});
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken)
{
return await _context.ForgetPasswordCodes
.AsNoTracking()
.Where(u => u.Code == code)
.AnyAsync(cancellationToken);
}
public async Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken)
{
var normalizedEmail = email.ToLower().Trim();
var trimmedCode = code.Trim();
return await _context.ForgetPasswordCodes
.AsNoTracking()
.Where(u => u.Email.ToLower().Trim() == normalizedEmail && u.Code == trimmedCode)
.AnyAsync(cancellationToken);
}
public async Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken)
{
var normalizedEmail = email.ToLower().Trim();
var normalizedEmail = Normalize(email);
return await _context.ForgetPasswordCodes
.AsNoTracking()
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
.OrderByDescending(u => u.Id)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
{
// A single conditional UPDATE, so concurrent checks can never consume
// more than maxAttempts between them.
var updated = await _context.ForgetPasswordCodes
.Where(u => u.Id == id && u.FailedAttempts < maxAttempts && u.ExpiresAtUtc > nowUtc)
.ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts + 1),
cancellationToken);
return updated == 1;
}
public async Task RefundAttemptAsync(int id, CancellationToken cancellationToken)
{
await _context.ForgetPasswordCodes
.Where(u => u.Id == id && u.FailedAttempts > 0)
.ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts - 1),
cancellationToken);
}
public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken)
{
var normalizedEmail = email.ToLower().Trim();
var records = await _context.ForgetPasswordCodes
var normalizedEmail = Normalize(email);
await _context.ForgetPasswordCodes
.Where(code => code.Email.ToLower().Trim() == normalizedEmail)
.ToListAsync(cancellationToken);
if (records.Count == 0)
return;
_context.ForgetPasswordCodes.RemoveRange(records);
await _context.SaveChangesAsync(cancellationToken);
.ExecuteDeleteAsync(cancellationToken);
}
public async Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken)
{
var normalizedEmail = Normalize(email);
await _context.ForgetPasswordCodes
.Where(code => code.Email.ToLower().Trim() == normalizedEmail && code.Id <= throughId)
.ExecuteDeleteAsync(cancellationToken);
}
private static string Normalize(string email) => email.ToLower().Trim();
}
}

View file

@ -4,10 +4,27 @@ namespace SeaHaven.DataServices.Interfaces
{
public interface IForgetPasswordDataService
{
Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken);
Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken);
Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken);
/// <summary>Deletes every pending code for the email, then stores the new one.</summary>
Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken);
/// <summary>Returns the pending code for exactly this email, or null.</summary>
Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken);
/// <summary>
/// Atomically consumes one attempt when the code is unexpired and has fewer
/// than <paramref name="maxAttempts"/> consumed. Returns false otherwise.
/// </summary>
Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken);
/// <summary>Gives back an attempt consumed by a check that matched.</summary>
Task RefundAttemptAsync(int id, CancellationToken cancellationToken);
Task RemoveByEmailAsync(string email, CancellationToken cancellationToken);
/// <summary>
/// Deletes the email's codes issued up to and including <paramref name="throughId"/>,
/// leaving any code issued after it by a concurrent request.
/// </summary>
Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,40 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Generation and hashing for emailed password reset codes. The raw code exists
/// only in memory and in the email sent to the account holder.
/// </summary>
public static class PasswordResetCodeSecrets
{
public static string NewCode()
{
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture);
}
public static string NewSalt()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
}
public static string Hash(string salt, string code)
{
ArgumentNullException.ThrowIfNull(salt);
ArgumentNullException.ThrowIfNull(code);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
}
public static bool Matches(string salt, string candidate, string expectedHash)
{
if (string.IsNullOrEmpty(salt) || string.IsNullOrEmpty(expectedHash))
return false;
var actual = Encoding.ASCII.GetBytes(Hash(salt, candidate.Trim()));
var expected = Encoding.ASCII.GetBytes(expectedHash);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
}
}

View file

@ -9,7 +9,6 @@ using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace SeaHaven.Services.Implementation
@ -21,13 +20,19 @@ namespace SeaHaven.Services.Implementation
private readonly IUserDataService _userDataService;
private readonly IForgetPasswordDataService _forgetPasswordDataService;
private readonly IEmailSender _emailSender;
private readonly TimeProvider _timeProvider;
private readonly ISessionStampService _sessionStamps;
public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15);
public const int MaxCodeAttempts = 5;
public AuthenticationService(
UserManager<ApplicationUser> userManager,
IOptions<JwtOptions> jwtOptions,
IUserDataService userDataService,
IForgetPasswordDataService forgetPasswordDataService,
IEmailSender emailSender,
TimeProvider timeProvider,
ISessionStampService sessionStamps)
{
_userManager = userManager;
@ -35,6 +40,7 @@ namespace SeaHaven.Services.Implementation
_userDataService = userDataService;
_forgetPasswordDataService = forgetPasswordDataService;
_emailSender = emailSender;
_timeProvider = timeProvider;
_sessionStamps = sessionStamps;
}
@ -148,50 +154,104 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken)
public async Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken)
{
var user = await _userDataService.GetByEmailNormalizedAsync(email, cancellationToken);
if (user == null) return false;
// Registered and unregistered addresses take the same path up to the
// email send: one user lookup, one code generated and hashed, one write.
var requested = email?.Trim() ?? string.Empty;
var user = requested.Length == 0
? null
: await _userDataService.GetByEmailNormalizedAsync(requested, cancellationToken);
var code = PasswordResetCodeSecrets.NewCode();
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(salt, code);
var code = GenerateRandomNo();
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email ?? "", user.Id, code, cancellationToken);
var body = $"Your Password Reset Code is: " + code;
await _emailSender.SendEmailAsync(user.Email ?? email, "Forget Password Request.", body);
if (user == null || user.IsDeleted == true || string.IsNullOrWhiteSpace(user.Email))
{
await _forgetPasswordDataService.RemoveByEmailAsync(requested, cancellationToken);
return;
}
var expiresAtUtc = _timeProvider.GetUtcNow().UtcDateTime.Add(ResetCodeLifetime);
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email, user.Id, hash, salt, expiresAtUtc, cancellationToken);
var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes.";
await _emailSender.SendEmailAsync(user.Email, "Forget Password Request.", body);
}
public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)
{
var pending = await CheckCodeAsync(email, code, cancellationToken);
if (pending == null)
return false;
// Verifying is a preview step; a correct code keeps all of its attempts.
await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken);
return true;
}
public async Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken)
public async Task<bool> ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken)
{
return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken);
if (string.IsNullOrWhiteSpace(password))
return false;
var pending = await CheckCodeAsync(email, code, cancellationToken);
if (pending == null)
return false;
var user = await _userManager.FindByIdAsync(pending.UserId);
if (user == null || user.IsDeleted == true)
{
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return false;
}
public async Task<bool> ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(password))
return false;
var matched = await _forgetPasswordDataService.ExistsByEmailAndCodeAsync(email, code, cancellationToken);
if (!matched) return false;
var record = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
if (record == null)
return false;
var user = await _userManager.FindByIdAsync(record.UserId);
if (user == null)
return false;
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
// A reset rotates the security stamp, which ends every earlier session.
var result = await _userManager.ResetPasswordAsync(user, token, password);
if (!result.Succeeded)
{
// The code was right and the new password was rejected: the user can
// try another password without spending an attempt.
await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken);
return false;
}
_sessionStamps.Forget(user.Id);
await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken);
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
return true;
}
/// <summary>
/// Returns the pending code record when <paramref name="code"/> matches the one
/// issued to <paramref name="email"/>. Every check consumes an attempt before
/// comparing; a check that uses the last attempt without matching deletes the code.
/// </summary>
private async Task<ForgetPasswordCode?> CheckCodeAsync(string? email, string? code, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(code))
return null;
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
if (pending == null)
return null;
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
// Deletes below stop at this code's id: a code issued by a concurrent request
// after this one was read belongs to that request and must survive.
if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken))
{
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
}
if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash))
return pending;
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
}
private JwtSecurityToken GetToken(List<Claim> authClaims)
{
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret));
@ -204,10 +264,5 @@ namespace SeaHaven.Services.Implementation
);
return token;
}
private static string GenerateRandomNo()
{
return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6");
}
}
}

View file

@ -273,6 +273,11 @@ namespace SeaHaven.Services.Implementation
if (canonical == UpliftStatus.NoApprovalRequired)
{
// An admin revoke overturns a human decision, and an auto-approval has none,
// so admins are refused here even when they filed the request themselves.
if (user.IsInRole("Admin"))
throw new UpliftForbiddenException("Admins can revoke only admin-approved uplifts");
if (string.IsNullOrWhiteSpace(userId)
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
{

View file

@ -9,8 +9,13 @@ namespace SeaHaven.Services.Interfaces
Task<LoginResultDTO> CreateSessionAsync(Data.SeaHavenIndustries.ApplicationUser user, CancellationToken cancellationToken);
Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken);
Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken);
Task<bool> ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken);
/// <summary>
/// Emails a reset code when the address belongs to an active account. Gives no
/// indication either way, so callers cannot learn which emails are registered.
/// </summary>
Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken);
/// <summary>Checks a code against the one issued to this email only.</summary>
Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken);
Task<bool> ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,406 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// The anonymous Forgot Password flow over HTTP, against the real controller,
/// services, Identity and a relational database.
/// </summary>
public sealed class PasswordResetFlowTests
{
private const string Alice = "alice@example.com";
private const string Bob = "bob@example.com";
private const string OldPassword = "Old@12345";
private const string NewPassword = "New@67890";
private const string CodeNotMatched = "{\"status\":\"Error\",\"message\":\"Code Not Matched\"}";
private const string ResetFailed = "{\"status\":\"Error\",\"message\":\"Your email or code not found please check\"}";
[Fact]
public async Task Registered_user_resets_password_end_to_end_and_signs_in_with_the_new_one()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var requested = await host.ForgetPasswordAsync(Alice);
Assert.Equal(HttpStatusCode.OK, requested.StatusCode);
var code = host.Sent.LatestCodeFor(Alice);
Assert.Matches("^[0-9]{6}$", code);
var verified = await host.VerifyAsync(Alice, code);
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
Assert.Contains("Code Matched", await verified.Content.ReadAsStringAsync());
var reset = await host.ResetAsync(Alice, code, NewPassword);
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
// A used code cannot be replayed.
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "Other@24680")).Content.ReadAsStringAsync());
Assert.Empty(await host.PendingCodesAsync());
}
[Fact]
public async Task ForgetPassword_answers_registered_and_unregistered_emails_identically_and_emails_only_the_registered_one()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var registered = await host.ForgetPasswordAsync(Alice);
var unregistered = await host.ForgetPasswordAsync("nobody@example.com");
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
Assert.Equal(registered.StatusCode, unregistered.StatusCode);
Assert.Equal(await registered.Content.ReadAsStringAsync(), await unregistered.Content.ReadAsStringAsync());
Assert.Contains(AuthenticationController.ForgetPasswordMessage, await registered.Content.ReadAsStringAsync());
var message = Assert.Single(host.Sent.Messages);
Assert.Equal(Alice, message.To);
Assert.Single(await host.PendingCodesAsync());
}
[Fact]
public async Task ForgetPassword_hides_an_email_delivery_failure_behind_the_same_response()
{
await using var host = await PasswordResetTestHost.StartAsync(new ThrowingEmailSender());
await host.AddUserAsync(Alice, OldPassword);
var registered = await host.ForgetPasswordAsync(Alice);
var unregistered = await host.ForgetPasswordAsync("nobody@example.com");
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
var body = await registered.Content.ReadAsStringAsync();
Assert.Equal(await unregistered.Content.ReadAsStringAsync(), body);
Assert.DoesNotContain("smtp-internal-detail", body);
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(Alice, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Stored_code_is_a_salted_hash_and_the_plaintext_is_only_in_the_email()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
var row = Assert.Single(await host.PendingCodesAsync());
Assert.Equal(string.Empty, row.Code);
Assert.Matches("^[0-9a-f]{32}$", row.CodeSalt);
Assert.Matches("^[0-9a-f]{64}$", row.CodeHash);
Assert.NotEqual(Sha256Hex(code), row.CodeHash);
Assert.Equal(Sha256Hex(row.CodeSalt + ":" + code), row.CodeHash);
Assert.DoesNotContain(code, string.Join("|", row.Code, row.CodeHash, row.CodeSalt, row.Email, row.UserId));
}
[Fact]
public async Task Code_expires_fifteen_minutes_after_it_is_issued()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
host.Time.Advance(TimeSpan.FromMinutes(15) - TimeSpan.FromSeconds(1));
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode);
host.Time.Advance(TimeSpan.FromSeconds(1));
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
}
[Fact]
public async Task Requesting_a_new_code_invalidates_the_previous_one()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var first = host.Sent.LatestCodeFor(Alice);
string second;
do
{
await host.ForgetPasswordAsync(Alice);
second = host.Sent.LatestCodeFor(Alice);
}
while (second == first);
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, first)).Content.ReadAsStringAsync());
Assert.Single(await host.PendingCodesAsync());
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword)).StatusCode);
}
[Fact]
public async Task Five_wrong_attempts_invalidate_the_code_until_a_new_one_is_requested()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
var wrong = WrongCode(code);
// Verify and reset share one attempt budget.
for (var attempt = 0; attempt < 3; attempt++)
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, wrong)).Content.ReadAsStringAsync());
for (var attempt = 0; attempt < 2; attempt++)
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, wrong, NewPassword)).Content.ReadAsStringAsync());
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
Assert.Empty(await host.PendingCodesAsync());
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
await host.ForgetPasswordAsync(Alice);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword)).StatusCode);
}
[Fact]
public async Task Exhausting_a_code_invalidates_every_pending_code_for_that_email()
{
await using var host = await PasswordResetTestHost.StartAsync();
var user = await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var older = host.Sent.LatestCodeFor(Alice);
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
for (var attempt = 0; attempt < 5; attempt++)
await host.VerifyAsync(Alice, WrongCode(newest));
Assert.Empty(await host.PendingCodesAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, NewPassword)).Content.ReadAsStringAsync());
}
[Fact]
public async Task A_successful_reset_invalidates_every_pending_code_for_that_email()
{
await using var host = await PasswordResetTestHost.StartAsync();
var user = await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var older = host.Sent.LatestCodeFor(Alice);
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, newest, NewPassword)).StatusCode);
Assert.Empty(await host.PendingCodesAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, "Other@24680")).Content.ReadAsStringAsync());
}
[Fact]
public async Task Four_wrong_attempts_then_the_right_code_still_resets()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
for (var attempt = 0; attempt < 4; attempt++)
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, WrongCode(code))).Content.ReadAsStringAsync());
// A correct verify does not spend an attempt, so the reset after it still succeeds.
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
}
[Fact]
public async Task A_rejected_new_password_does_not_spend_an_attempt_or_the_code()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
for (var attempt = 0; attempt < 6; attempt++)
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "weakpassword")).Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
}
[Fact]
public async Task A_code_is_checked_only_against_the_email_it_was_issued_to()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.AddUserAsync(Bob, OldPassword);
await host.ForgetPasswordAsync(Alice);
await host.ForgetPasswordAsync(Bob);
var aliceCode = host.Sent.LatestCodeFor(Alice);
var bobCode = host.Sent.LatestCodeFor(Bob);
if (aliceCode != bobCode)
{
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Bob, aliceCode)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Bob, aliceCode, NewPassword)).Content.ReadAsStringAsync());
}
// Exhausting Bob's attempts leaves Alice's code and budget untouched.
for (var attempt = 0; attempt < 5; attempt++)
await host.VerifyAsync(Bob, WrongCode(bobCode));
var alice = Assert.Single(await host.PendingCodesAsync());
Assert.Equal(Alice, alice.Email);
Assert.Equal(0, alice.FailedAttempts);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, aliceCode, NewPassword)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Bob, OldPassword)).StatusCode);
}
[Fact]
public async Task Concurrent_checks_can_never_consume_more_than_five_attempts()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var row = Assert.Single(await host.PendingCodesAsync());
var now = host.Time.GetUtcNow().UtcDateTime;
var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ =>
{
await using var scope = host.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IForgetPasswordDataService>()
.TryConsumeAttemptAsync(row.Id, 5, now, CancellationToken.None);
}));
Assert.Equal(5, results.Count(consumed => consumed));
Assert.Equal(5, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
}
[Fact]
public async Task VerificationCode_without_an_email_fails_generically_and_never_matches_another_users_code()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
// The old client contract: code in the query string, no body, no content type.
var legacy = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/VerificationCode?code={code}"));
Assert.Equal(HttpStatusCode.BadRequest, legacy.StatusCode);
Assert.Equal(CodeNotMatched, await legacy.Content.ReadAsStringAsync());
Assert.Equal(0, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
}
[Fact]
public async Task VerificationCode_and_ForgetPassword_still_accept_the_query_string_form()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var requested = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(Alice)}"));
Assert.Equal(HttpStatusCode.OK, requested.StatusCode);
var code = host.Sent.LatestCodeFor(Alice);
var verified = await host.Client.SendAsync(PasswordResetTestHost.Post(
$"api/Authentication/VerificationCode?email={Uri.EscapeDataString(Alice)}&code={code}"));
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
}
[Fact]
public async Task Each_endpoint_allows_ten_requests_per_client_per_window_then_answers_429()
{
await using var host = await PasswordResetTestHost.StartAsync();
const string client = "203.0.113.5";
for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++)
Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync($"n{request}@example.com", client)).StatusCode);
var limited = await host.ForgetPasswordAsync("n@example.com", client);
Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode);
Assert.Equal(
"{\"status\":\"Error\",\"message\":\"Too many requests. Please try again later.\"}",
await limited.Content.ReadAsStringAsync());
// A left-hand X-Forwarded-For entry written by the caller does not buy a new bucket.
Assert.Equal(HttpStatusCode.TooManyRequests, (await host.ForgetPasswordAsync("n@example.com", "198.51.100.1, " + client)).StatusCode);
// Another client, and the same client on another endpoint, are unaffected.
Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync("n@example.com", "203.0.113.6")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync("n@example.com", "000000", client)).StatusCode);
}
[Theory]
[InlineData("api/Authentication/VerificationCode")]
[InlineData("api/Authentication/ResetPassword")]
public async Task Verify_and_reset_are_rate_limited_per_client(string path)
{
await using var host = await PasswordResetTestHost.StartAsync();
const string client = "203.0.113.9";
var body = new { email = Alice, code = "000000", password = NewPassword };
for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++)
Assert.Equal(HttpStatusCode.BadRequest, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode);
}
[Fact]
public async Task No_code_or_email_reaches_the_logs_during_the_whole_flow()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
await host.ForgetPasswordAsync("nobody@example.com");
var code = host.Sent.LatestCodeFor(Alice);
await host.VerifyAsync(Alice, WrongCode(code));
await host.VerifyAsync(Alice, code);
await host.ResetAsync(Alice, code, NewPassword);
Assert.NotEmpty(host.Logged.Lines);
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(code, StringComparison.Ordinal));
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(WrongCode(code), StringComparison.Ordinal));
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains("@example.com", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(NewPassword, StringComparison.Ordinal));
}
[Fact]
public void Api_host_registers_forwarded_headers_and_rate_limiting_in_the_right_order()
{
var program = File.ReadAllText(Path.Combine(RepoRoot(), "Api.SeaHavenIndustries", "Program.cs"));
Assert.Contains("builder.Services.AddPasswordResetRateLimiting();", program);
var build = program.IndexOf("builder.Build()", StringComparison.Ordinal);
var forwarded = program.IndexOf("app.UseForwardedHeaders()", StringComparison.Ordinal);
var firstMiddleware = program.IndexOf("app.Use", build, StringComparison.Ordinal);
var routing = program.IndexOf("app.UseRouting()", StringComparison.Ordinal);
var limiter = program.IndexOf("app.UseRateLimiter()", StringComparison.Ordinal);
var controllers = program.IndexOf("app.MapControllers()", StringComparison.Ordinal);
Assert.True(forwarded > build && forwarded == firstMiddleware, "UseForwardedHeaders must be the first middleware.");
Assert.True(routing < limiter, "UseRateLimiter must run after UseRouting so endpoint policies apply.");
Assert.True(limiter < controllers);
}
private static string WrongCode(string code) =>
((int.Parse(code) + 1) % 1_000_000).ToString("D6");
private static string Sha256Hex(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string RepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null
&& !File.Exists(Path.Combine(directory.FullName, "Api.SeaHavenIndustries", "Program.cs")))
{
directory = directory.Parent;
}
return directory?.FullName
?? throw new InvalidOperationException("Could not locate the repository root from " + AppContext.BaseDirectory);
}
private sealed class ThrowingEmailSender : IEmailSender
{
public Task<bool> SendEmailAsync(string emailTo, string subject, string body) =>
throw new InvalidOperationException("smtp-internal-detail " + emailTo);
}
}

View file

@ -0,0 +1,118 @@
using System.Net;
using Data.SeaHavenIndustries;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// A new code requested while a check of the previous code is in flight. The
/// request runs for real, between the check's steps, through the same host.
/// </summary>
public sealed class PasswordResetRaceTests
{
private const string Alice = "alice@example.com";
private const string OldPassword = "Old@12345";
private const string NewPassword = "New@67890";
private static int _nextClient;
private static string NextClient()
{
var n = Interlocked.Increment(ref _nextClient);
return $"192.0.{n / 250 % 250}.{n % 250 + 1}";
}
[Fact]
public async Task A_code_requested_while_the_previous_one_is_being_checked_still_resets()
{
var race = new CheckRace();
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice, NextClient());
var first = host.Sent.LatestCodeFor(Alice);
// The check has read the first code; the new request replaces it before the attempt is counted.
race.BeforeNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, first, NextClient())).StatusCode);
Assert.Equal(2, host.Sent.Messages.Count);
var second = host.Sent.LatestCodeFor(Alice);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
}
[Fact]
public async Task A_code_requested_while_the_last_wrong_attempt_is_being_checked_still_resets()
{
var race = new CheckRace();
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice, NextClient());
var wrong = host.Sent.LatestCodeFor(Alice) == "000000" ? "111111" : "000000";
for (var attempt = 1; attempt < 5; attempt++)
await host.VerifyAsync(Alice, wrong, NextClient());
// The fifth wrong attempt is counted, then the new request lands before the used-up code is deleted.
race.AfterNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, wrong, NextClient())).StatusCode);
Assert.Equal(2, host.Sent.Messages.Count);
var second = host.Sent.LatestCodeFor(Alice);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
}
private sealed class CheckRace
{
private Func<Task>? _beforeNextConsume;
private Func<Task>? _afterNextConsume;
public Func<Task>? BeforeNextConsume { set => _beforeNextConsume = value; }
public Func<Task>? AfterNextConsume { set => _afterNextConsume = value; }
public void Register(IServiceCollection services) =>
services.Replace(ServiceDescriptor.Scoped<IForgetPasswordDataService>(provider =>
new RacingForgetPasswordDataService(new ForgetPasswordDataService(provider.GetRequiredService<ApplicationDbContext>()), this)));
public Task RunBeforeConsumeAsync() => Interlocked.Exchange(ref _beforeNextConsume, null)?.Invoke() ?? Task.CompletedTask;
public Task RunAfterConsumeAsync() => Interlocked.Exchange(ref _afterNextConsume, null)?.Invoke() ?? Task.CompletedTask;
}
private sealed class RacingForgetPasswordDataService : IForgetPasswordDataService
{
private readonly IForgetPasswordDataService _inner;
private readonly CheckRace _race;
public RacingForgetPasswordDataService(IForgetPasswordDataService inner, CheckRace race)
{
_inner = inner;
_race = race;
}
public Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken) =>
_inner.ReplaceCodeAsync(email, userId, codeHash, codeSalt, expiresAtUtc, cancellationToken);
public Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken) =>
_inner.GetByEmailAsync(email, cancellationToken);
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
{
await _race.RunBeforeConsumeAsync();
var consumed = await _inner.TryConsumeAttemptAsync(id, maxAttempts, nowUtc, cancellationToken);
await _race.RunAfterConsumeAsync();
return consumed;
}
public Task RefundAttemptAsync(int id, CancellationToken cancellationToken) =>
_inner.RefundAttemptAsync(id, cancellationToken);
public Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) =>
_inner.RemoveByEmailAsync(email, cancellationToken);
public Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken) =>
_inner.RemoveIssuedThroughAsync(email, throughId, cancellationToken);
}
}

View file

@ -0,0 +1,298 @@
using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Hosts the real <see cref="AuthenticationController"/> on Kestrel over a SQLite file
/// database, with the real authentication service, data services, Identity, and the
/// same rate limiting and forwarded-header registration the API host uses. Email goes
/// to an in-memory sender, the clock is manual, and every log line is captured.
/// </summary>
internal sealed class PasswordResetTestHost : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly string _databasePath;
private PasswordResetTestHost(WebApplication app, string databasePath, HttpClient client)
{
_app = app;
_databasePath = databasePath;
Client = client;
}
public HttpClient Client { get; }
public ResetEmailOutbox Sent { get; private init; } = null!;
public ResetTestClock Time { get; private init; } = null!;
public ResetLogCapture Logged { get; private init; } = null!;
public IServiceProvider Services => _app.Services;
public static async Task<PasswordResetTestHost> StartAsync(IEmailSender? emailSender = null, Action<IServiceCollection>? configureServices = null)
{
var databasePath = Path.Combine(Path.GetTempPath(), $"password-reset-{Guid.NewGuid():N}.db");
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
var sent = new ResetEmailOutbox();
var time = new ResetTestClock();
var logged = new ResetLogCapture();
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["JWT:Secret"] = new string('k', 64),
["JWT:ValidIssuer"] = "issuer",
["JWT:ValidAudience"] = "audience"
});
builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(LogLevel.Trace);
builder.Logging.AddProvider(logged);
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(connectionString));
builder.Services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
new SqlitePasswordResetDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddSingleton<TimeProvider>(time);
builder.Services.AddSingleton<IEmailSender>(emailSender ?? sent);
builder.Services.AddDataServices();
configureServices?.Invoke(builder.Services);
builder.Services.AddBusinessServices(builder.Configuration);
builder.Services.AddControllers()
.AddApplicationPart(typeof(AuthenticationController).Assembly)
.ConfigureApplicationPartManager(manager =>
{
manager.FeatureProviders.Clear();
manager.FeatureProviders.Add(new OnlyAuthenticationController());
});
builder.Services.AddPasswordResetRateLimiting();
var app = builder.Build();
app.UseForwardedHeaders();
app.UseRouting();
app.UseRateLimiter();
app.MapControllers();
await using (var scope = app.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.EnsureCreatedAsync();
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features
.Get<IServerAddressesFeature>()!.Addresses.Single();
return new PasswordResetTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) })
{
Sent = sent,
Time = time,
Logged = logged
};
}
public async Task<ApplicationUser> AddUserAsync(string email, string password)
{
await using var scope = _app.Services.CreateAsyncScope();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = new ApplicationUser
{
UserName = email,
Email = email,
FirstName = "Alice",
LastName = "Q",
EmailConfirmed = true,
CreatedDate = DateTime.UtcNow
};
var result = await users.CreateAsync(user, password);
Assert.True(result.Succeeded, string.Join("; ", result.Errors.Select(error => error.Description)));
return user;
}
public async Task<List<ForgetPasswordCode>> PendingCodesAsync()
{
await using var scope = _app.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
.ForgetPasswordCodes.AsNoTracking().OrderBy(code => code.Id).ToListAsync();
}
/// <summary>
/// Stores a second pending code for the email directly, as two concurrent first
/// requests could, and returns it. The new row is the newest one.
/// </summary>
public async Task<string> AddSiblingCodeAsync(string email, string userId)
{
const string code = "424242";
const string salt = "0123456789abcdef0123456789abcdef";
await using var scope = _app.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
context.ForgetPasswordCodes.Add(new ForgetPasswordCode
{
Email = email,
UserId = userId,
CodeSalt = salt,
CodeHash = SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(salt, code),
ExpiresAtUtc = Time.GetUtcNow().UtcDateTime.AddMinutes(15)
});
await context.SaveChangesAsync();
return code;
}
public static HttpRequestMessage Post(string path, object? json = null, string? clientIp = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, path);
if (json != null)
request.Content = JsonContent.Create(json);
if (clientIp != null)
request.Headers.Add("X-Forwarded-For", clientIp);
return request;
}
public Task<HttpResponseMessage> ForgetPasswordAsync(string email, string? clientIp = null) =>
Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp));
public Task<HttpResponseMessage> VerifyAsync(string email, string code, string? clientIp = null) =>
Client.SendAsync(Post("api/Authentication/VerificationCode", new { email, code }, clientIp));
public Task<HttpResponseMessage> ResetAsync(string email, string code, string password, string? clientIp = null) =>
Client.SendAsync(Post("api/Authentication/ResetPassword", new { email, code, password }, clientIp));
public Task<HttpResponseMessage> LoginAsync(string username, string password) =>
Client.SendAsync(Post("api/Authentication/login", new { username, password }));
public async ValueTask DisposeAsync()
{
Client.Dispose();
await _app.StopAsync();
await _app.DisposeAsync();
SqliteConnection.ClearAllPools();
foreach (var path in new[] { _databasePath, _databasePath + "-wal", _databasePath + "-shm", _databasePath + "-journal" })
{
if (File.Exists(path))
File.Delete(path);
}
}
private sealed class OnlyAuthenticationController : ControllerFeatureProvider
{
protected override bool IsController(System.Reflection.TypeInfo typeInfo) =>
typeInfo.AsType() == typeof(AuthenticationController);
}
private sealed class SqlitePasswordResetDbContext : ApplicationDbContext
{
public SqlitePasswordResetDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
{
// SQL Server filter syntax does not carry over; a filtered unique index
// without its filter would wrongly reject a second user.
if (index.GetFilter() is not null)
{
index.SetFilter(null);
index.IsUnique = false;
}
}
foreach (var property in builder.Model.GetEntityTypes()
.SelectMany(entity => entity.GetProperties())
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
{
property.ValueGenerated = ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}
internal sealed class ResetTestClock : TimeProvider
{
private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
internal sealed class ResetEmailOutbox : IEmailSender
{
private static readonly Regex CodePattern = new(@"Your Password Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
public ConcurrentQueue<(string To, string Subject, string Body)> Messages { get; } = new();
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
{
Messages.Enqueue((emailTo, subject, body));
return Task.FromResult(true);
}
public string LatestCodeFor(string email)
{
var message = Messages.Last(sent => string.Equals(sent.To, email, StringComparison.OrdinalIgnoreCase));
return CodePattern.Match(message.Body).Groups[1].Value;
}
}
internal sealed class ResetLogCapture : ILoggerProvider
{
public ConcurrentQueue<string> Lines { get; } = new();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName);
public void Dispose()
{
}
private sealed class CapturingLogger : ILogger
{
private readonly ResetLogCapture _owner;
private readonly string _category;
public CapturingLogger(ResetLogCapture owner, string category)
{
_owner = owner;
_category = category;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var values = state is IEnumerable<KeyValuePair<string, object?>> pairs
? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}"))
: string.Empty;
_owner.Lines.Enqueue($"{logLevel} {_category} {formatter(state, exception)} {values} {exception}");
}
}
}

View file

@ -212,7 +212,7 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
row.Id,
created!.Id,
new RevokeWorkOrderUpliftRequestDto(),
WorkOrderAccountTestHelpers.AccountUser(),
WorkOrderAccountTestHelpers.AccountUser("actor-1", 1, "Dispatcher"),
CancellationToken.None);
Assert.Equal("revoked", revoked!.Status);

View file

@ -0,0 +1,162 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
namespace SeaHavenIndustries.Tests;
// Relational (SQLite) coverage for the live-uplift predicate. The in-memory provider
// evaluates LINQ in memory and cannot prove the "Has uplift" filter or the board uplift
// aggregate translate to SQL, so this runs the advanced search against a real provider,
// scoped to one account, with a live uplift on another account's work order.
public sealed class WorkOrderUpliftLiveStatusRelationalTests
{
[Fact]
public async Task AdvancedSearch_HasUplift_CountsOnlyLiveUpliftsWithinAccount()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new SqliteLiveUpliftTestDbContext(options);
await context.Database.EnsureCreatedAsync();
var accountA = new Accounts { Name = "Account A" };
var accountB = new Accounts { Name = "Account B" };
var vendor = new Vendor { CompanyName = "Acme HVAC", IsActive = true };
context.AddRange(accountA, accountB, vendor);
await context.SaveChangesAsync();
var revokedOnly = NewWorkOrder("WO-REVOKED", accountA.Id);
var cancelledOnly = NewWorkOrder("WO-CANCELLED", accountA.Id);
var revokedPlusPending = NewWorkOrder("WO-MIXED", accountA.Id);
var otherAccountPending = NewWorkOrder("WO-OTHER", accountB.Id);
context.AddRange(revokedOnly, cancelledOnly, revokedPlusPending, otherAccountPending);
await context.SaveChangesAsync();
var revokedDispatch = new Dispatch { VendorId = vendor.Id, DispatchNumber = "DIS-R", Status = "Scheduled" };
var cancelledDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = cancelledOnly.Id, DispatchNumber = "DIS-C", Status = "Scheduled" };
var mixedDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M", Status = "Scheduled" };
var mixedPendingDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M2", Status = "Scheduled" };
var otherDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = otherAccountPending.Id, DispatchNumber = "DIS-O", Status = "Scheduled" };
context.Dispatches.AddRange(revokedDispatch, cancelledDispatch, mixedDispatch, mixedPendingDispatch, otherDispatch);
await context.SaveChangesAsync();
// The revoked-only work order reaches its uplift through a multi-WO dispatch link.
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = revokedDispatch.Id, WorkOrderId = revokedOnly.Id });
context.DispatchUpliftRequests.AddRange(
NewUplift(revokedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)),
NewUplift(cancelledDispatch.Id, "Withdrawn", new DateTime(2026, 6, 20, 9, 0, 0)),
NewUplift(mixedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)),
NewUplift(mixedPendingDispatch.Id, "Pending", new DateTime(2026, 6, 20, 11, 0, 0)),
NewUplift(otherDispatch.Id, "Pending", new DateTime(2026, 6, 20, 9, 0, 0)));
await context.SaveChangesAsync();
var data = new WorkOrderAdvancedSearchDataService(context);
var hasUplift = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: null));
var row = Assert.Single(hasUplift.Rows);
Assert.Equal(revokedPlusPending.Id, row.Id);
Assert.True(row.HasUplift);
Assert.Equal("Pending", row.PrimaryUpliftStatus);
var revoked = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: new[] { "revoked" }));
Assert.Equal(
new[] { revokedOnly.Id, revokedPlusPending.Id }.OrderBy(id => id),
revoked.Rows.Select(r => r.Id).OrderBy(id => id));
var all = await data.SearchAsync(Query(accountA.Id, hasUplift: false, upliftStatuses: null));
Assert.Equal(3, all.TotalCount);
Assert.DoesNotContain(all.Rows, r => r.Id == otherAccountPending.Id);
Assert.False(all.Rows.Single(r => r.Id == revokedOnly.Id).HasUplift);
Assert.Null(all.Rows.Single(r => r.Id == revokedOnly.Id).PrimaryUpliftStatus);
Assert.False(all.Rows.Single(r => r.Id == cancelledOnly.Id).HasUplift);
Assert.True(all.Rows.Single(r => r.Id == revokedPlusPending.Id).HasUplift);
}
private static WorkOrder NewWorkOrder(string number, int accountId) => new()
{
InternalWONumber = number,
WorkerOrderTitle = "Repair",
AccountId = accountId,
ScheduledDate = new DateTime(2026, 6, 23, 8, 0, 0),
LifecycleStatus = LifecycleStatus.Scheduled,
};
private static DispatchUpliftRequest NewUplift(int dispatchId, string status, DateTime createdDate) => new()
{
DispatchId = dispatchId,
Status = status,
CreatedDate = createdDate,
RequiredTier = 1,
RequestedNTE = 750m,
NotificationStatus = "Pending",
};
private static WorkOrderAdvancedSearchQuery Query(
int accountId,
bool hasUplift,
IReadOnlyList<string>? upliftStatuses) => new(
Search: null,
DateFrom: new DateOnly(2026, 6, 22),
DateTo: new DateOnly(2026, 6, 28),
UnscheduledOnly: false,
Sites: null,
Regions: null,
Types: null,
Overdue: false,
Dispatchers: null,
Statuses: null,
PmTypes: null,
VendorIds: null,
DocStatuses: null,
Severities: null,
Rescheduled: false,
CarriedOver: false,
AddOn: false,
AvetaOnly: false,
FlagColors: null,
InternalOnly: false,
HasUplift: hasUplift,
UpliftStatuses: upliftStatuses,
MyWorkOrders: false,
CurrentUserId: null,
Page: 0,
PageSize: 50,
SortBy: "scheduledDate",
SortDir: "asc",
AccountId: accountId);
private sealed class SqliteLiveUpliftTestDbContext : ApplicationDbContext
{
public SqliteLiveUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQL Server filtered index syntax is invalid on SQLite.
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; treat as plain nullable blobs.
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
{
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}

View file

@ -0,0 +1,266 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// "Has uplift" (advanced filter) and the board Uplift column count only live uplifts:
/// a work order whose uplifts were all cancelled, withdrawn, expired or revoked has none.
/// </summary>
public class WorkOrderUpliftLiveStatusTests
{
private static readonly DateOnly WeekStart = new(2026, 6, 22);
private const int WithdrawnOnly = 1;
private const int LegacyCancelledOnly = 2;
private const int RevokedOnlyViaLink = 3;
private const int RevokedPlusPending = 4;
private const int RejectedOnly = 5;
private const int ExpiredOnly = 6;
private const int NoUplift = 7;
private const int NewerRevokedOlderAutoApproved = 8;
private static readonly int[] LiveUpliftWorkOrders =
{
RevokedPlusPending,
RejectedOnly,
NewerRevokedOlderAutoApproved,
};
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static async Task<ApplicationDbContext> SeedAsync()
{
var context = CreateContext();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
for (var id = WithdrawnOnly; id <= NewerRevokedOlderAutoApproved; id++)
{
// The revoked-only work order reaches its uplift through a multi-WO dispatch link.
var linkedOnly = id == RevokedOnlyViaLink;
context.Dispatches.Add(new Dispatch
{
Id = 10 + id,
VendorId = 1,
WorkOrderId = linkedOnly ? null : id,
});
if (linkedOnly)
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10 + id, WorkOrderId = id });
context.workOrders.Add(new WorkOrder
{
Id = id,
InternalWONumber = $"1000000000{id}",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = linkedOnly ? null : 10 + id,
});
}
var older = new DateTime(2026, 6, 20, 9, 0, 0, DateTimeKind.Utc);
var newer = older.AddHours(2);
context.DispatchUpliftRequests.AddRange(
Uplift(101, WithdrawnOnly, "Withdrawn", older),
Uplift(102, LegacyCancelledOnly, "Cancelled", older),
Uplift(103, RevokedOnlyViaLink, "Revoked", older),
Uplift(104, RevokedPlusPending, "Revoked", older),
Uplift(105, RevokedPlusPending, "Pending", newer),
Uplift(106, RejectedOnly, "Rejected", older),
Uplift(107, ExpiredOnly, "Expired", older),
Uplift(108, NewerRevokedOlderAutoApproved, "NoApprovalRequired", older),
Uplift(109, NewerRevokedOlderAutoApproved, "Revoked", newer));
await context.SaveChangesAsync();
return context;
}
private static DispatchUpliftRequest Uplift(int id, int workOrderId, string status, DateTime createdDate) => new()
{
Id = id,
DispatchId = 10 + workOrderId,
RequestedNTE = 1000m + id,
Status = status,
RequiredTier = 1,
NotificationStatus = "Pending",
CreatedDate = createdDate,
};
private static async Task<int[]> SearchIdsAsync(
ApplicationDbContext context,
bool hasUplift,
List<string>? upliftStatuses = null)
{
var service = new WorkOrderAdvancedSearchService(
new WorkOrderAdvancedSearchDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(
new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = WeekStart,
DateTo = WeekStart.AddDays(6),
HasUplift = hasUplift,
UpliftStatuses = upliftStatuses,
PageSize = 50,
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
return result.Items.Select(i => i.Id).OrderBy(id => id).ToArray();
}
[Fact]
public async Task HasUplift_ExcludesWorkOrdersWhoseOnlyUpliftsAreCancelledOrRevoked()
{
await using var context = await SeedAsync();
var ids = await SearchIdsAsync(context, hasUplift: true);
Assert.Equal(LiveUpliftWorkOrders, ids);
Assert.DoesNotContain(WithdrawnOnly, ids);
Assert.DoesNotContain(LegacyCancelledOnly, ids);
Assert.DoesNotContain(RevokedOnlyViaLink, ids);
Assert.DoesNotContain(ExpiredOnly, ids);
}
[Fact]
public async Task HasUplift_IncludesWorkOrderWithRevokedAndPendingUplift()
{
await using var context = await SeedAsync();
Assert.Contains(RevokedPlusPending, await SearchIdsAsync(context, hasUplift: true));
Assert.Equal(
new[] { RevokedPlusPending },
await SearchIdsAsync(context, hasUplift: true, new List<string> { "pending" }));
}
[Fact]
public async Task HasUplift_RejectedUpliftStillCounts()
{
await using var context = await SeedAsync();
Assert.Equal(
new[] { RejectedOnly },
await SearchIdsAsync(context, hasUplift: true, new List<string> { "rejected" }));
}
[Fact]
public async Task HasUplift_ExplicitCancelledStatus_FindsCancelledWithdrawnAndExpiredUplifts()
{
await using var context = await SeedAsync();
var ids = await SearchIdsAsync(context, hasUplift: true, new List<string> { "cancelled" });
Assert.Equal(new[] { WithdrawnOnly, LegacyCancelledOnly, ExpiredOnly }, ids);
}
[Fact]
public async Task HasUplift_ExplicitRevokedStatus_FindsRevokedUpliftsOnPrimaryAndLinkedDispatches()
{
await using var context = await SeedAsync();
var ids = await SearchIdsAsync(context, hasUplift: true, new List<string> { "revoked" });
Assert.Equal(new[] { RevokedOnlyViaLink, RevokedPlusPending, NewerRevokedOlderAutoApproved }, ids);
}
[Fact]
public async Task BoardUpliftColumn_AgreesWithHasUpliftFilter()
{
await using var context = await SeedAsync();
var filtered = await SearchIdsAsync(context, hasUplift: true);
var boardService = new WorkOrderBoardService(
new WorkOrderBoardDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var board = await boardService.GetBoardAsync(
new WorkOrderBoardQueryDto { WeekStart = WeekStart },
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
Assert.Equal(8, board.Scheduled.Count);
foreach (var row in board.Scheduled)
{
Assert.Equal(filtered.Contains(row.Id), row.UpliftSummary!.HasUplift);
}
var searchService = new WorkOrderAdvancedSearchService(
new WorkOrderAdvancedSearchDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var unfiltered = await searchService.SearchAsync(
new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = WeekStart,
DateTo = WeekStart.AddDays(6),
PageSize = 50,
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
Assert.Equal(8, unfiltered.Items.Count());
foreach (var item in unfiltered.Items)
{
Assert.Equal(filtered.Contains(item.Id), item.UpliftSummary!.HasUplift);
}
}
[Fact]
public async Task BoardUpliftColumn_ShowsNewestLiveUpliftAndNoStatusForNonLiveOnly()
{
await using var context = await SeedAsync();
var boardService = new WorkOrderBoardService(
new WorkOrderBoardDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var board = await boardService.GetBoardAsync(
new WorkOrderBoardQueryDto { WeekStart = WeekStart },
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
var byId = board.Scheduled.ToDictionary(r => r.Id, r => r.UpliftSummary!);
foreach (var id in new[] { WithdrawnOnly, LegacyCancelledOnly, RevokedOnlyViaLink, ExpiredOnly, NoUplift })
{
Assert.False(byId[id].HasUplift);
Assert.Null(byId[id].PrimaryStatus);
Assert.Null(byId[id].Amount);
}
Assert.Equal("pending", byId[RevokedPlusPending].PrimaryStatus);
Assert.Equal(1, byId[RevokedPlusPending].PendingCount);
Assert.Equal("rejected", byId[RejectedOnly].PrimaryStatus);
Assert.Equal("auto_approved", byId[NewerRevokedOlderAutoApproved].PrimaryStatus);
Assert.Equal(1108m, byId[NewerRevokedOlderAutoApproved].Amount);
}
[Theory]
[InlineData(UpliftStatus.Pending)]
[InlineData(UpliftStatus.Approved)]
[InlineData(UpliftStatus.Rejected)]
[InlineData(UpliftStatus.ChangesRequested)]
[InlineData(UpliftStatus.Withdrawn)]
[InlineData(UpliftStatus.Expired)]
[InlineData(UpliftStatus.NoApprovalRequired)]
[InlineData(UpliftStatus.Revoked)]
[InlineData(UpliftStatus.LegacyDenied)]
[InlineData(UpliftStatus.LegacyCancelled)]
public void LiveUpliftStatus_MatchesFrontendCancelledAndRevokedStatuses(string storedStatus)
{
var frontendStatus = WorkOrderUpliftContractMapper.ToFrontendStatus(storedStatus);
var expectedLive = frontendStatus is not ("cancelled" or "revoked");
Assert.Equal(expectedLive, LiveUpliftStatus.IsLive(storedStatus));
}
}

View file

@ -48,6 +48,11 @@ public sealed class WorkOrderUpliftServiceTests
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
=> WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
// Same user as Dispatcher(), without the Admin role: the only kind of caller that
// may revoke an auto-approved uplift, and only their own.
private static ClaimsPrincipal DispatcherRoleOnly(string userId = "dispatcher-1")
=> WorkOrderAccountTestHelpers.AccountUser(userId, 1, "Dispatcher");
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
ApplicationDbContext context,
WorkOrderType type = WorkOrderType.PM,
@ -585,7 +590,7 @@ public sealed class WorkOrderUpliftServiceTests
workOrder.Id,
created.Id,
new RevokeWorkOrderUpliftRequestDto(),
Dispatcher(),
DispatcherRoleOnly(),
CancellationToken.None);
// SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise
@ -724,7 +729,7 @@ public sealed class WorkOrderUpliftServiceTests
workOrder.Id,
created!.Id,
new RevokeWorkOrderUpliftRequestDto(),
Dispatcher(),
DispatcherRoleOnly(),
CancellationToken.None);
Assert.Equal("revoked", revoked!.Status);
@ -733,6 +738,35 @@ public sealed class WorkOrderUpliftServiceTests
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
}
[Fact]
public async Task RevokeAsync_AdminOwnerRevokingAutoApproved_IsForbiddenAndKeepsAllowanceConsumed()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" },
Dispatcher(),
CancellationToken.None);
Assert.Equal("auto_approved", created!.Status);
await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.RevokeAsync(
workOrder.Id,
created.Id,
new RevokeWorkOrderUpliftRequestDto { Reason = "Wrong quote" },
Dispatcher(),
CancellationToken.None));
var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id);
Assert.Equal(UpliftStatus.NoApprovalRequired, stored.Status);
Assert.Null(stored.DecisionNote);
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
Assert.Equal(400m, await new UpliftDataService(context)
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
}
[Theory]
[InlineData(LifecycleStatus.Completed)]
[InlineData(LifecycleStatus.Canceled)]
@ -760,7 +794,7 @@ public sealed class WorkOrderUpliftServiceTests
workOrder.Id,
100,
new RevokeWorkOrderUpliftRequestDto(),
Dispatcher(),
DispatcherRoleOnly(),
CancellationToken.None));
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
}