Compare commits

...

15 commits

Author SHA1 Message Date
Alexandre Brandizzi
74d5aa17eb fix(auth): trace a reset email the provider rejects as an error
A send that the mail provider did not accept finished its background
transaction as ok, so rejected reset emails looked delivered in tracing.
It now finishes as an error with a fixed message that names no recipient.
2026-09-25 20:01:58 -03:00
Alexandre Brandizzi
c985e9423d Merge remote-tracking branch 'origin/main' into fix/ab/sh-403-reset-hardening-2
# Conflicts:
#	Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs
#	Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
#	SeaHaven.Services/Implementation/AuthenticationService.cs
2026-09-25 19:49:24 -03:00
Alexandre Brandizzi
de0e767930 fix(auth): refuse a reset email when the queue is full instead of dropping it
The channel used DropWrite, under which TryWrite reports success and
discards the email, so a full queue still counted the request and never
sent the code. Wait makes TryWrite return false when the queue is full,
without blocking, so the request is released and the user can ask again.
2026-09-25 19:38:53 -03:00
Alexandre Brandizzi
63465cc083
Merge pull request #198 from Sea-Haven-Industries/fix/ab/sh-409-invalidate-sessions-on-reset
End earlier sign-in sessions after a password reset, password change, deactivation or deletion
2026-09-25 22:38:44 +00:00
Alexandre Brandizzi
0298fc75bd Merge remote-tracking branch 'origin/main' into fix/ab/sh-403-reset-hardening-2 2026-09-25 19:27:01 -03:00
Alexandre Brandizzi
498f49a2d8 fix(auth): end earlier sessions when a user's role or account changes
A token carries the user's roles and account, so a demoted admin kept admin
claims until the token expired. The team member update and the admin user
edit now rotate the security stamp and evict the cached value when the role,
account or user name changes. Permission overrides are read per request and
are not in the token.
2026-09-25 19:26:25 -03:00
Alexandre Brandizzi
987ec455f2 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
2026-09-25 19:22:33 -03:00
Alexandre Brandizzi
de8e283ee5
Merge pull request #192 from Sea-Haven-Industries/fix/ab/sh-403-reset-code-hardening
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
fix(auth): stop reset-code guessing and email enumeration in Forgot Password
2026-09-25 22:16:01 +00:00
Alexandre Brandizzi
410bf1b4ca
Merge pull request #195 from Sea-Haven-Industries/fix/ab/sh-408-has-uplift-live-status
fix(work-orders): count only live uplifts for Has uplift and the Uplift column
2026-09-25 22:15:57 +00:00
Alexandre Brandizzi
c8e4b8f3d5
Merge pull request #196 from Sea-Haven-Industries/fix/ab/sh-406-no-revoke-auto-approved
fix(uplifts): refuse admin revoke of an auto-approved uplift
2026-09-25 22:15:54 +00:00
Alexandre Brandizzi
cc46950254 test(auth): format the password change request 2026-09-25 19:10:46 -03:00
Alexandre Brandizzi
b7be07411e fix(auth): end earlier sessions when a password or account status changes
Tokens now carry a keyed hash of the account's security stamp, and every
authenticated request compares it with the stored stamp (cached for 60 s,
evicted in-process on change). A password reset or change, a deactivation
and a deletion all rotate or remove the stamp, so tokens issued before them
get 401. Tokens without the claim get 401 too.
2026-09-25 19:08:33 -03:00
Alexandre Brandizzi
e993e1b5fc refactor(auth): compose bearer authentication through one registration 2026-09-25 18:54:10 -03:00
Alexandre Brandizzi
185c84dd8c fix(uplifts): refuse admin revoke of an auto-approved uplift
An admin revoke overturns a human decision, so admins may revoke only
admin-approved uplifts. The work-order revoke path let an admin revoke an
auto-approved uplift they had requested themselves. Both revoke endpoints
now refuse it; dispatchers keep revoking their own auto-approved uplifts.
2026-09-25 18:07:33 -03:00
Alexandre Brandizzi
92cd44c481 fix(work-orders): count only live uplifts for Has uplift and the Uplift column
A work order whose uplifts were all cancelled, withdrawn, expired or revoked no
longer matches the advanced-search Has uplift filter, and the board Uplift
column no longer reports it as having an uplift or shows the dead one as its
primary status. One shared live-status list backs both queries. Explicit
cancelled/revoked sub-filter values still find those work orders.
2026-09-25 17:51:47 -03:00
32 changed files with 1949 additions and 55 deletions

View file

@ -26,7 +26,9 @@ public class AuthenticationServiceTests
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
store = s;
hasher = h;
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System);
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, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System, sessionStamps);
}
private static JwtOptions JwtOptions => new()

View file

@ -135,7 +135,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IPasswordResetEmailQueue>(),
new InMemoryPasswordResetThrottle(TimeProvider.System),
TimeProvider.System);
TimeProvider.System,
Mock.Of<ISessionStampService>());
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
@ -223,7 +224,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IPasswordResetEmailQueue>(),
new InMemoryPasswordResetThrottle(TimeProvider.System),
TimeProvider.System);
TimeProvider.System,
Mock.Of<ISessionStampService>());
public async ValueTask DisposeAsync()
{

View file

@ -0,0 +1,43 @@
using Api.SeaHavenIndustries.HostedServices;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class PasswordResetEmailChannelTests
{
[Fact]
public void A_full_queue_refuses_the_email_instead_of_dropping_it_silently()
{
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++)
channel.TryEnqueue($"user{i}@example.com", "subject", "body").Should().BeTrue();
var accepted = channel.TryEnqueue("late@example.com", "subject", "body");
accepted.Should().BeFalse();
channel.Pending.Should().Be(PasswordResetEmailChannel.Capacity);
}
[Fact]
public async Task A_refused_email_is_never_delivered_and_the_queue_accepts_again_once_drained()
{
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++)
channel.TryEnqueue($"user{i}@example.com", "subject", "body");
channel.TryEnqueue("late@example.com", "subject", "body").Should().BeFalse();
var delivered = new List<string>();
while (channel.Reader.TryRead(out var email))
{
delivered.Add(email.EmailTo);
channel.MarkHandled();
}
delivered.Should().HaveCount(PasswordResetEmailChannel.Capacity).And.NotContain("late@example.com");
channel.Pending.Should().Be(0);
channel.TryEnqueue("retry@example.com", "subject", "body").Should().BeTrue();
(await channel.Reader.ReadAsync()).EmailTo.Should().Be("retry@example.com");
}
}

View file

@ -0,0 +1,55 @@
using Api.SeaHavenIndustries.HostedServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Sentry;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class PasswordResetEmailSenderTracingTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Each_send_finishes_its_transaction_as_ok_only_when_the_provider_accepts_it(bool accepted)
{
var transaction = new Mock<ITransactionTracer>();
var hub = new Mock<IHub>();
hub.Setup(h => h.PushScope()).Returns(Mock.Of<IDisposable>());
hub.Setup(h => h.StartTransaction(It.IsAny<ITransactionContext>(), It.IsAny<IReadOnlyDictionary<string, object?>>()))
.Returns(transaction.Object);
var sender = new Mock<IEmailSender>();
sender.Setup(s => s.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(accepted);
var services = new ServiceCollection().AddSingleton(sender.Object).BuildServiceProvider();
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
var worker = new PasswordResetEmailSenderHostedService(
channel,
services.GetRequiredService<IServiceScopeFactory>(),
NullLogger<PasswordResetEmailSenderHostedService>.Instance,
hub.Object);
await worker.StartAsync(CancellationToken.None);
Assert.True(channel.TryEnqueue("user@example.com", "subject", "Your code is 123456"));
var deadline = DateTime.UtcNow.AddSeconds(10);
while (channel.Pending > 0 && DateTime.UtcNow < deadline)
await Task.Delay(10);
await worker.StopAsync(CancellationToken.None);
Assert.Equal(0, channel.Pending);
if (accepted)
{
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Once);
transaction.Verify(t => t.Finish(It.IsAny<Exception>(), It.IsAny<SpanStatus>()), Times.Never);
}
else
{
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Never);
transaction.Verify(t => t.Finish(
It.Is<Exception>(ex => !ex.Message.Contains("user@example.com") && !ex.Message.Contains("123456")),
SpanStatus.InternalError), Times.Once);
}
}
}

View file

@ -72,4 +72,19 @@ public class ServiceRegistrationTests
AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices");
}
[Fact]
public void SessionStampCache_IsOneInstanceForTheWholeProcess()
{
// A scoped cache would never be hit, and a stamp change on one request would
// never evict the value another request cached.
using var provider = BuildConventionServices().BuildServiceProvider();
using var first = provider.CreateScope();
using var second = provider.CreateScope();
var cache = first.ServiceProvider.GetRequiredService<SeaHaven.Services.Interfaces.ISessionStampCache>();
cache.Should().BeOfType<InMemorySessionStampCache>();
second.ServiceProvider.GetRequiredService<SeaHaven.Services.Interfaces.ISessionStampCache>().Should().BeSameAs(cache);
}
}

View file

@ -0,0 +1,138 @@
using FluentAssertions;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class SessionStampServiceTests
{
private const string UserId = "user-1";
private readonly Mock<IUserDataService> _users = new();
private readonly SteppedTimeProvider _time = new();
private readonly InMemorySessionStampCache _cache;
public SessionStampServiceTests()
{
_cache = new InMemorySessionStampCache(_time);
}
private SessionStampService NewService(string secret = "0123456789abcdef0123456789abcdef0123456789abcdef") =>
new(_users.Object, _cache, Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = secret }));
private void StoredStamp(string? stamp) =>
_users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>())).ReturnsAsync(stamp);
[Fact]
public void The_token_carries_a_keyed_hash_never_the_stamp_itself()
{
var value = NewService().ClaimValueFor("STAMP-ONE");
value.Should().NotContain("STAMP-ONE");
value.Should().Be(NewService().ClaimValueFor("STAMP-ONE"));
value.Should().NotBe(NewService().ClaimValueFor("STAMP-TWO"));
value.Should().NotBe(NewService(new string('z', 64)).ClaimValueFor("STAMP-ONE"));
}
[Fact]
public async Task A_matching_stamp_is_read_once_per_cache_lifetime()
{
StoredStamp("STAMP-ONE");
var service = NewService();
var issued = service.ClaimValueFor("STAMP-ONE");
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
(await NewService().IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
_users.Verify(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task A_stamp_changed_by_another_instance_is_enforced_once_the_cached_value_expires()
{
StoredStamp("STAMP-ONE");
var service = NewService();
var issued = service.ClaimValueFor("STAMP-ONE");
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
StoredStamp("STAMP-TWO");
_time.Advance(InMemorySessionStampCache.Lifetime - TimeSpan.FromSeconds(1));
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
_time.Advance(TimeSpan.FromSeconds(1));
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
}
[Fact]
public async Task A_stamp_changed_by_this_instance_is_enforced_at_once()
{
StoredStamp("STAMP-ONE");
var service = NewService();
var issued = service.ClaimValueFor("STAMP-ONE");
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
StoredStamp("STAMP-TWO");
service.Forget(UserId);
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
}
[Fact]
public async Task A_read_that_races_a_change_is_not_cached()
{
var service = NewService();
var issued = service.ClaimValueFor("STAMP-ONE");
var reads = 0;
_users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
// The first read returns the old stamp while this instance saves a new one.
if (reads++ == 0)
{
service.Forget(UserId);
return "STAMP-ONE";
}
return "STAMP-TWO";
});
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task A_missing_deleted_or_stampless_account_matches_nothing(string? stored)
{
StoredStamp(stored);
var service = NewService();
(await service.IsCurrentAsync(UserId, service.ClaimValueFor("STAMP-ONE"), CancellationToken.None)).Should().BeFalse();
(await service.IsCurrentAsync(UserId, "", CancellationToken.None)).Should().BeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task A_token_without_a_stamp_is_refused_without_a_lookup(string? claimValue)
{
StoredStamp("STAMP-ONE");
(await NewService().IsCurrentAsync(UserId, claimValue, CancellationToken.None)).Should().BeFalse();
_users.Verify(users => users.GetActiveSecurityStampAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
private sealed class SteppedTimeProvider : 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);
}
}

View file

@ -29,7 +29,8 @@ public sealed class TeamMemberServiceTests
Mock.Of<ITeamPermissionOverrideDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<ITeamPermissionService>(),
Mock.Of<ITeamMemberInviteService>());
Mock.Of<ITeamMemberInviteService>(),
Mock.Of<ISessionStampService>());
var result = await service.CreateAsync(
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
@ -410,7 +411,8 @@ public sealed class TeamMemberServiceTests
overrides.Object,
userData.Object,
permissions.Object,
Mock.Of<ITeamMemberInviteService>());
Mock.Of<ITeamMemberInviteService>(),
Mock.Of<ISessionStampService>());
}
private static Mock<UserManager<ApplicationUser>> UserManager()

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

@ -26,7 +26,8 @@ public class UserServiceTests
Mock<IUserDataService> userData,
Mock<IEmailSender> email,
out Mock<IUserRoleStore<ApplicationUser>> store,
Mock<IAccountDataService>? accounts = null)
Mock<IAccountDataService>? accounts = null,
Mock<ISessionStampService>? sessions = null)
{
var (manager, s, _) = IdentityTestHelpers.CreateUserManager();
store = s;
@ -34,7 +35,8 @@ public class UserServiceTests
manager,
userData.Object,
(accounts ?? new Mock<IAccountDataService>()).Object,
email.Object);
email.Object,
(sessions ?? new Mock<ISessionStampService>()).Object);
}
[Fact]
@ -429,4 +431,42 @@ public class UserServiceTests
&& password.Any(char.IsDigit)
&& password.Any(character => !char.IsLetterOrDigit(character));
}
[Theory]
[InlineData(2, "alice@example.com", "Dispatcher", true)]
[InlineData(1, "alice@example.com", "Scheduler", true)]
[InlineData(1, "alice.new@example.com", "Dispatcher", true)]
[InlineData(1, "ALICE@example.com", "dispatcher", false)]
public async Task EditUser_EndsEarlierSessionsOnlyWhenTheTokenClaimsChange(
int accountId, string email, string role, bool endsSessions)
{
var existing = IdentityTestHelpers.User("u1", userName: "alice@example.com");
existing.AccountId = 1;
var stampBefore = existing.SecurityStamp;
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(existing);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsAsync(It.IsAny<int>())).ReturnsAsync(true);
var sessions = new Mock<ISessionStampService>();
var service = NewService(userData, new Mock<IEmailSender>(), out var store, accounts, sessions);
store.Setup(s => s.GetRolesAsync(existing, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "Dispatcher" });
var outcome = await service.EditUserAsync(
new EditUserRequestDTO { Id = "u1", Name = "Alice", Email = email, Role = role, AccountId = accountId },
Principal("Admin"),
CancellationToken.None);
outcome.Success.Should().BeTrue();
if (endsSessions)
{
existing.SecurityStamp.Should().NotBe(stampBefore);
sessions.Verify(s => s.Forget("u1"), Times.Once);
}
else
{
existing.SecurityStamp.Should().Be(stampBefore);
sessions.Verify(s => s.Forget(It.IsAny<string>()), Times.Never);
}
}
}

View file

@ -29,7 +29,10 @@ namespace Api.SeaHavenIndustries.HostedServices
private readonly Channel<PasswordResetEmail> _channel = Channel.CreateBounded<PasswordResetEmail>(
new BoundedChannelOptions(Capacity)
{
FullMode = BoundedChannelFullMode.DropWrite,
// Wait, not DropWrite: with DropWrite, TryWrite reports success and discards
// the email, so a full queue would still count the request. TryWrite never
// blocks; under Wait it returns false when the queue is full.
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true
});
private readonly ILogger<PasswordResetEmailChannel> _logger;
@ -88,8 +91,10 @@ namespace Api.SeaHavenIndustries.HostedServices
$"{nameof(PasswordResetEmailSenderHostedService)}.{nameof(SendAsync)}");
try
{
await SendAsync(email);
transaction.FinishOk();
if (await SendAsync(email))
transaction.FinishOk();
else
transaction.FinishError(new InvalidOperationException("The mail provider did not accept the password reset email."));
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@ -109,12 +114,16 @@ namespace Api.SeaHavenIndustries.HostedServices
}
}
private async Task SendAsync(PasswordResetEmail email)
/// <summary>True when the mail provider accepted the email.</summary>
private async Task<bool> SendAsync(PasswordResetEmail email)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
if (!await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body))
_logger.LogWarning("Password reset email was not accepted by the mail provider.");
if (await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body))
return true;
_logger.LogWarning("Password reset email was not accepted by the mail provider.");
return false;
}
}
}

View file

@ -0,0 +1,67 @@
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Infrastructure
{
public static class JwtAuthenticationRegistration
{
/// <summary>
/// Registers bearer-token authentication as the default scheme. Program.cs and the
/// behavior tests both compose authentication through this method so the token
/// rules under test are the rules that run.
/// </summary>
public static IServiceCollection AddSeaHavenJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
{
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = configuration["JWT:ValidAudience"],
ValidIssuer = configuration["JWT:ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
configuration["JWT:Secret"]
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = RejectEndedSessionAsync
};
});
return services;
}
/// <summary>
/// Refuses a correctly signed token whose session stamp no longer matches the
/// account: the password was reset or changed, or the account was deactivated or
/// deleted, after the token was issued. A token without a stamp is refused too.
/// </summary>
private static async Task RejectEndedSessionAsync(TokenValidatedContext context)
{
var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
var claimValue = context.Principal?.FindFirstValue(SeaHavenClaimTypes.SessionStamp);
var sessions = context.HttpContext.RequestServices.GetRequiredService<ISessionStampService>();
if (string.IsNullOrEmpty(userId)
|| !await sessions.IsCurrentAsync(userId, claimValue, context.HttpContext.RequestAborted))
{
// The handler logs this text; it names no account, token or stamp.
context.Fail("The session has ended.");
}
}
}
}

View file

@ -5,15 +5,12 @@ using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Observability;
using Api.SeaHavenIndustries.Options;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Sentry.AspNetCore;
using Sentry.Extensibility;
using System.Text;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
@ -145,27 +142,7 @@ builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthO
health.LegacySunsetDate = legacy.Value.SunsetDate;
});
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = configuration["JWT:ValidAudience"],
ValidIssuer = configuration["JWT:ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
configuration["JWT:Secret"]
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
};
});
builder.Services.AddSeaHavenJwtAuthentication(configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{

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

@ -189,6 +189,15 @@ namespace SeaHaven.DataServices.Implementation
}
}
public async Task<string?> GetActiveSecurityStampAsync(string userId, CancellationToken cancellationToken)
{
return await _context.Users
.AsNoTracking()
.Where(user => user.Id == userId && user.IsDeleted != true)
.Select(user => user.SecurityStamp)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<string?> GetEmailByIdAsync(
string userId, CancellationToken cancellationToken)
{

View file

@ -20,6 +20,9 @@ namespace SeaHaven.DataServices.Interfaces
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> callback, CancellationToken cancellationToken);
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
/// <summary>The security stamp of an account that exists and is not deleted; otherwise null.</summary>
Task<string?> GetActiveSecurityStampAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
}
}

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Reflection;
@ -15,6 +16,7 @@ namespace SeaHaven.Services.DependencyInjection
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
{
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<ISessionStampCache, InMemorySessionStampCache>();
services.Configure<FrontendOptions>(configuration);
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));

View file

@ -11,6 +11,12 @@ namespace SeaHaven.Services.Helpers
/// <summary>Signed org-wide elevation (Admin without AccountId).</summary>
public const string OrgScopeAll = "all";
/// <summary>
/// A keyed hash of the account's security stamp at sign-in. Never the stamp
/// itself: the token is readable by whoever holds it.
/// </summary>
public const string SessionStamp = "session_stamp";
}
/// <summary>Resolved media tenant scope from signed claims (fail-closed when Missing).</summary>

View file

@ -22,6 +22,7 @@ namespace SeaHaven.Services.Implementation
private readonly IPasswordResetEmailQueue _resetEmails;
private readonly IPasswordResetThrottle _resetThrottle;
private readonly TimeProvider _timeProvider;
private readonly ISessionStampService _sessionStamps;
private byte[]? _resetCodeKey;
public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15);
@ -37,7 +38,8 @@ namespace SeaHaven.Services.Implementation
IForgetPasswordDataService forgetPasswordDataService,
IPasswordResetEmailQueue resetEmails,
IPasswordResetThrottle resetThrottle,
TimeProvider timeProvider)
TimeProvider timeProvider,
ISessionStampService sessionStamps)
{
_userManager = userManager;
_jwtOptions = jwtOptions.Value;
@ -46,6 +48,7 @@ namespace SeaHaven.Services.Implementation
_resetEmails = resetEmails;
_resetThrottle = resetThrottle;
_timeProvider = timeProvider;
_sessionStamps = sessionStamps;
}
private byte[] ResetCodeKey => _resetCodeKey ??= PasswordResetCodeSecrets.DeriveKey(_jwtOptions.Secret);
@ -64,12 +67,22 @@ namespace SeaHaven.Services.Implementation
ArgumentNullException.ThrowIfNull(user);
cancellationToken.ThrowIfCancellationRequested();
// Every request checks the token's stamp against the account's, so an account
// without one would get a token that never works.
if (string.IsNullOrEmpty(user.SecurityStamp))
{
var stamped = await _userManager.UpdateSecurityStampAsync(user);
if (!stamped.Succeeded)
throw new InvalidOperationException("The account's security stamp could not be set.");
}
var userRoles = await _userManager.GetRolesAsync(user);
var authClaims = new List<Claim>
{
new Claim(ClaimTypes.Name, user.UserName ?? ""),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(SeaHavenClaimTypes.SessionStamp, _sessionStamps.ClaimValueFor(user.SecurityStamp!))
};
foreach (var userRole in userRoles)
{
@ -113,9 +126,13 @@ namespace SeaHaven.Services.Implementation
if (!await _userManager.CheckPasswordAsync(user, currentPassword ?? ""))
return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect);
// A changed password rotates the security stamp, which ends every earlier session.
var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? "");
if (result.Succeeded)
{
_sessionStamps.Forget(user.Id);
return ChangePasswordResult(ChangePasswordStatus.Succeeded);
}
return ChangePasswordResult(IdentityPasswordPolicy.IsPolicyRejection(result)
? ChangePasswordStatus.PasswordRejected
@ -230,6 +247,7 @@ namespace SeaHaven.Services.Implementation
}
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)
{
@ -239,6 +257,7 @@ namespace SeaHaven.Services.Implementation
return false;
}
_sessionStamps.Forget(user.Id);
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
return true;
}

View file

@ -0,0 +1,109 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class SessionStampService : ISessionStampService
{
private readonly IUserDataService _userDataService;
private readonly ISessionStampCache _cache;
private readonly byte[] _key;
public SessionStampService(
IUserDataService userDataService,
ISessionStampCache cache,
IOptions<JwtOptions> jwtOptions)
{
_userDataService = userDataService;
_cache = cache;
_key = HKDF.DeriveKey(
HashAlgorithmName.SHA256,
Encoding.UTF8.GetBytes(jwtOptions.Value.Secret),
32,
info: Encoding.UTF8.GetBytes("session-stamp-v1"));
}
public string ClaimValueFor(string securityStamp)
{
ArgumentException.ThrowIfNullOrEmpty(securityStamp);
return Base64UrlEncoder.Encode(HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(securityStamp)));
}
public async Task<bool> IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(claimValue))
return false;
if (!_cache.TryGet(userId, out var expected))
{
var generation = _cache.Generation;
var stamp = await _userDataService.GetActiveSecurityStampAsync(userId, cancellationToken);
// A missing, deleted or stampless account has no current value: nothing matches it.
expected = string.IsNullOrEmpty(stamp) ? null : ClaimValueFor(stamp);
_cache.Set(userId, expected, generation);
}
return expected != null && CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(claimValue));
}
public void Forget(string userId) => _cache.Remove(userId);
}
/// <summary>
/// Holds each expected value for <see cref="Lifetime"/>, so a change saved by another
/// instance is enforced here within that time; a change saved by this instance is
/// enforced at once through <see cref="Remove"/>.
/// </summary>
public sealed class InMemorySessionStampCache : ISessionStampCache
{
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60);
private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private long _generation;
public InMemorySessionStampCache(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public long Generation => Interlocked.Read(ref _generation);
public bool TryGet(string userId, out string? expected)
{
if (_entries.TryGetValue(userId, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow())
{
expected = entry.Expected;
return true;
}
expected = null;
return false;
}
public void Set(string userId, string? expected, long generation)
{
var entry = new Entry(expected, _timeProvider.GetUtcNow().Add(Lifetime));
_entries[userId] = entry;
// A removal since the read may have raced it: drop the value rather than keep it.
if (Generation != generation)
_entries.TryRemove(new KeyValuePair<string, Entry>(userId, entry));
}
public void Remove(string userId)
{
Interlocked.Increment(ref _generation);
_entries.TryRemove(userId, out _);
}
private sealed record Entry(string? Expected, DateTimeOffset ExpiresAt);
}
}

View file

@ -21,6 +21,7 @@ public sealed class TeamMemberService : ITeamMemberService
private readonly IUserDataService _userDataService;
private readonly ITeamPermissionService _permissionService;
private readonly ITeamMemberInviteService _inviteService;
private readonly ISessionStampService _sessionStamps;
public TeamMemberService(
UserManager<ApplicationUser> userManager,
@ -29,8 +30,10 @@ public sealed class TeamMemberService : ITeamMemberService
ITeamPermissionOverrideDataService permissionDataService,
IUserDataService userDataService,
ITeamPermissionService permissionService,
ITeamMemberInviteService inviteService)
ITeamMemberInviteService inviteService,
ISessionStampService sessionStamps)
{
_sessionStamps = sessionStamps;
_userManager = userManager;
_roleManager = roleManager;
_areaDataService = areaDataService;
@ -197,6 +200,8 @@ public sealed class TeamMemberService : ITeamMemberService
user.PhoneNumber = candidate.Phone?.Trim();
user.Color = color;
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
var activeChanged = request.IsActive == (user.IsDeleted == true);
var deactivated = activeChanged && !request.IsActive;
user.IsDeleted = !request.IsActive;
var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase);
@ -229,7 +234,15 @@ public sealed class TeamMemberService : ITeamMemberService
return OperationFailure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to update role.");
}
// A new stamp ends the member's earlier sessions: a deactivated member's stay
// ended if the member is reactivated later, and a member whose role changed
// signs in again to get a token with the new role.
if (deactivated || roleChanged)
user.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(user, cancellationToken);
if (activeChanged || emailChanged || roleChanged)
_sessionStamps.Forget(user.Id);
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
if (roleChanged || request.PermissionOverrides is not null)
{

View file

@ -15,17 +15,20 @@ namespace SeaHaven.Services.Implementation
private readonly IUserDataService _userDataService;
private readonly IAccountDataService _accountDataService;
private readonly IEmailSender _emailSender;
private readonly ISessionStampService _sessionStamps;
public UserService(
UserManager<ApplicationUser> userManager,
IUserDataService userDataService,
IAccountDataService accountDataService,
IEmailSender emailSender)
IEmailSender emailSender,
ISessionStampService sessionStamps)
{
_userManager = userManager;
_userDataService = userDataService;
_accountDataService = accountDataService;
_emailSender = emailSender;
_sessionStamps = sessionStamps;
}
public async Task<IEnumerable<UserListRowDTO>> GetUsersAsync(CancellationToken cancellationToken)
@ -103,6 +106,10 @@ namespace SeaHaven.Services.Implementation
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
var existingRole = await _userManager.GetRolesAsync(exist1);
var claimsChanged = exist1.AccountId != dto.AccountId
|| existingRole == null
|| existingRole.Count != 1
|| !string.Equals(existingRole[0], dto.Role, StringComparison.OrdinalIgnoreCase);
if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber)
{
@ -114,11 +121,15 @@ namespace SeaHaven.Services.Implementation
exist1.Contact = model.Contact;
exist1.PhoneNumber = model.PhoneNumber;
exist1.AccountId = dto.AccountId;
if (claimsChanged)
exist1.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(exist1, cancellationToken);
await _userManager.AddToRoleAsync(exist1, dto.Role ?? "");
await _userManager.UpdateAsync(exist1);
if (claimsChanged)
_sessionStamps.Forget(exist1.Id);
return new AddUserOutcomeDTO { Success = true };
}
}
@ -147,6 +158,15 @@ namespace SeaHaven.Services.Implementation
if (string.IsNullOrWhiteSpace(dto.Email))
throw new ArgumentException("Email is required.", nameof(dto));
// The token carries the user name, roles and account, so a change to any of
// them needs a fresh sign-in.
var existingRole = await _userManager.GetRolesAsync(exist);
var claimsChanged = exist.AccountId != dto.AccountId
|| !string.Equals(exist.UserName, dto.Email, StringComparison.OrdinalIgnoreCase)
|| existingRole == null
|| existingRole.Count != 1
|| !string.Equals(existingRole[0], dto.Role, StringComparison.OrdinalIgnoreCase);
exist.EmailConfirmed = true;
exist.UserName = dto.Email;
exist.Email = dto.Email;
@ -157,14 +177,17 @@ namespace SeaHaven.Services.Implementation
exist.PhoneNumber = dto.Role;
exist.AccountId = dto.AccountId;
var existingRole = await _userManager.GetRolesAsync(exist);
if (existingRole != null && existingRole.Any())
{
await _userManager.RemoveFromRolesAsync(exist, existingRole);
}
await _userManager.AddToRoleAsync(exist, dto.Role ?? "");
if (claimsChanged)
exist.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(exist, cancellationToken);
if (claimsChanged)
_sessionStamps.Forget(exist.Id);
return new AddUserOutcomeDTO { Success = true };
}
@ -185,6 +208,7 @@ namespace SeaHaven.Services.Implementation
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken);
_sessionStamps.Forget(data.Id);
return new AddUserOutcomeDTO { Success = true };
}
@ -193,8 +217,11 @@ namespace SeaHaven.Services.Implementation
var exist = await _userDataService.GetForEditAsync(userId, cancellationToken);
if (exist != null && !await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
{
// A new stamp keeps this account's earlier sessions ended even if it is restored.
exist.IsDeleted = true;
exist.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(exist, cancellationToken);
_sessionStamps.Forget(exist.Id);
}
}

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

@ -0,0 +1,20 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// The process-wide cache of expected session stamp values, keyed by user id.
/// Registered as a singleton.
/// </summary>
public interface ISessionStampCache
{
/// <summary>Changes on every removal; a read that spans one is not cached.</summary>
long Generation { get; }
/// <summary>True with the cached value (null: the account matches nothing) while it is fresh.</summary>
bool TryGet(string userId, out string? expected);
/// <summary>Caches a value read at <paramref name="generation"/>, unless a removal has happened since.</summary>
void Set(string userId, string? expected, long generation);
void Remove(string userId);
}
}

View file

@ -0,0 +1,21 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Ties a sign-in token to the account's security stamp, so a password reset or
/// change, a deactivation, or a deletion ends every session issued before it.
/// </summary>
public interface ISessionStampService
{
/// <summary>The value a token carries for <paramref name="securityStamp"/>.</summary>
string ClaimValueFor(string securityStamp);
/// <summary>
/// True when <paramref name="claimValue"/> matches the stamp of an account that
/// exists and is not deleted. Stored stamps are cached briefly.
/// </summary>
Task<bool> IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken);
/// <summary>Drops the cached stamp; call after a change to the stamp or the account is saved.</summary>
void Forget(string userId);
}
}

View file

@ -0,0 +1,259 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Security.Claims;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// A sign-in token must stop working once the account's password is reset or changed,
/// or once the account is deactivated or deleted. Every case goes through the real API
/// host: sign in over HTTP, change the account through its endpoint, then call an
/// authorized endpoint with the old and the new token.
/// </summary>
public sealed class SessionRevocationTests
{
private const string SessionStampClaim = "session_stamp";
private const string NewPassword = "Quiet-Tide-77!";
[Fact]
public async Task A_token_issued_before_a_password_reset_is_refused_and_the_new_sign_in_works()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var before = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, before);
await host.ResetPasswordAsync("sam@example.com", NewPassword);
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com", NewPassword);
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task A_token_issued_before_a_password_change_is_refused_and_the_new_sign_in_works()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var before = await host.SignInAsync("sam@example.com");
var other = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, other);
var change = new
{
currentpassword = SessionTestHost.Password,
newpassword = NewPassword,
confirmpassword = NewPassword
};
using (var changed = await host.SendAsync(HttpMethod.Post, "api/Authentication/ChangePassword", before, change))
Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
await AssertRefusedAsync(host, before);
await AssertRefusedAsync(host, other);
var after = await host.SignInAsync("sam@example.com", NewPassword);
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task A_deactivated_member_is_refused_and_the_old_token_stays_refused_after_reactivation()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Scheduler");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, before);
await UpdateMemberAsync(host, admin, member.Id, isActive: false);
await AssertRefusedAsync(host, before);
await UpdateMemberAsync(host, admin, member.Id, isActive: true);
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task A_token_for_an_account_its_owner_deleted_is_refused()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var before = await host.SignInAsync("sam@example.com");
using (var deleted = await host.SendAsync(HttpMethod.Post, "api/User/DeleteCurrentUser", before))
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
await AssertRefusedAsync(host, before);
}
[Fact]
public async Task A_token_for_an_account_an_admin_deleted_is_refused()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, before);
using (var deleted = await host.SendAsync(HttpMethod.Delete, "api/User/DeleteUser", admin, new { id = member.Id }))
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
await AssertRefusedAsync(host, before);
}
[Fact]
public async Task A_correctly_signed_token_with_a_forged_session_stamp_is_refused()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var issued = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, issued);
var forged = SessionTestHost.Resign(issued, claims =>
{
claims.RemoveAll(claim => claim.Type == SessionStampClaim);
claims.Add(new Claim(SessionStampClaim, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
});
var resignedUnchanged = SessionTestHost.Resign(issued, _ => { });
await AssertAcceptedAsync(host, resignedUnchanged);
await AssertRefusedAsync(host, forged);
}
[Fact]
public async Task A_correctly_signed_token_without_a_session_stamp_is_refused()
{
// The shape of every token issued before this check shipped.
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var issued = await host.SignInAsync("sam@example.com");
var stampless = SessionTestHost.Resign(issued, claims => claims.RemoveAll(claim => claim.Type == SessionStampClaim));
await AssertRefusedAsync(host, stampless);
}
[Fact]
public async Task A_refused_token_gets_the_same_401_as_no_token_and_nothing_sensitive_is_logged()
{
await using var host = await SessionTestHost.StartAsync();
var user = await host.AddUserAsync("sam@example.com");
var before = await host.SignInAsync("sam@example.com");
var stampClaim = new JwtSecurityTokenHandler().ReadJwtToken(before).Claims
.SingleOrDefault(claim => claim.Type == SessionStampClaim)?.Value;
var oldStamp = await host.StoredSecurityStampAsync(user.Id);
await host.ResetPasswordAsync("sam@example.com", NewPassword);
using var refused = await host.ProfileAsync(before);
using var anonymous = await host.ProfileAsync(null);
Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
Assert.Equal(await anonymous.Content.ReadAsStringAsync(), await refused.Content.ReadAsStringAsync());
Assert.Empty(await refused.Content.ReadAsStringAsync());
var newStamp = await host.StoredSecurityStampAsync(user.Id);
var secrets = new[] { before, oldStamp, newStamp, stampClaim, "sam@example.com" }
.Where(secret => !string.IsNullOrEmpty(secret))
.ToList();
var leaks = host.Logged.Entries
.Where(entry => secrets.Any(secret => entry.Contains(secret!, StringComparison.OrdinalIgnoreCase)))
.ToList();
Assert.Empty(leaks);
}
[Fact]
public async Task A_member_demoted_on_the_team_page_is_refused_and_signs_in_again_with_the_new_role()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Admin");
await host.EnsureRoleAsync("Scheduler");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
Assert.Equal(new[] { "Admin" }, RolesIn(before));
await AssertAcceptedAsync(host, before);
await UpdateMemberAsync(host, admin, member.Id, isActive: true, role: "Scheduler");
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com");
Assert.Equal(new[] { "Scheduler" }, RolesIn(after));
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task A_user_whose_role_an_admin_edits_is_refused_and_signs_in_again_with_the_new_role()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Admin");
await host.EnsureRoleAsync("Dispatcher");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, before);
var edit = new
{
id = member.Id,
name = "Sam",
email = "sam@example.com",
role = "Dispatcher"
};
using (var edited = await host.SendAsync(HttpMethod.Put, "api/User/EditUser", admin, edit))
Assert.Equal(HttpStatusCode.OK, edited.StatusCode);
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com");
Assert.Equal(new[] { "Dispatcher" }, RolesIn(after));
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task Editing_a_member_without_changing_role_status_or_email_keeps_their_session()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Scheduler");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
await UpdateMemberAsync(host, admin, member.Id, isActive: true);
await AssertAcceptedAsync(host, before);
}
private static string[] RolesIn(string token) =>
new JwtSecurityTokenHandler().ReadJwtToken(token).Claims
.Where(claim => claim.Type == ClaimTypes.Role)
.Select(claim => claim.Value)
.ToArray();
private static async Task UpdateMemberAsync(SessionTestHost host, string adminToken, string userId, bool isActive, string role = "Scheduler")
{
using var response = await host.SendAsync(HttpMethod.Put, $"api/team-members/{userId}", adminToken, new
{
name = "Sam Lee",
role,
color = "#0D9488",
email = "sam@example.com",
phone = "555-0100",
serviceAreas = Array.Empty<string>(),
isActive
});
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync());
}
private static async Task AssertAcceptedAsync(SessionTestHost host, string token)
{
using var response = await host.ProfileAsync(token);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
private static async Task AssertRefusedAsync(SessionTestHost host, string token)
{
using var response = await host.ProfileAsync(token);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}

View file

@ -0,0 +1,280 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.HostedServices;
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.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 Microsoft.IdentityModel.Tokens;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Hosts the real sign-in, user and team member controllers on Kestrel over a SQLite
/// file database, with Identity and bearer authentication registered exactly as the
/// API host registers them. Email goes to an in-memory sender and every log line is
/// captured.
/// </summary>
internal sealed class SessionTestHost : IAsyncDisposable
{
public static readonly string JwtSecret = new('s', 64);
public const string Issuer = "issuer";
public const string Audience = "audience";
public const string Password = "Harbor-Light-42!";
private static readonly Regex ResetCode = new(@"Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
private readonly WebApplication _app;
private readonly string _databasePath;
private SessionTestHost(WebApplication app, string databasePath, HttpClient client)
{
_app = app;
_databasePath = databasePath;
Client = client;
}
public HttpClient Client { get; }
public CapturingEmailSender Sent { get; private set; } = null!;
public CapturingLoggerProvider Logged { get; private set; } = null!;
public static async Task<SessionTestHost> StartAsync()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db");
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
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"] = JwtSecret,
["JWT:ValidIssuer"] = Issuer,
["JWT:ValidAudience"] = Audience
});
var sent = new CapturingEmailSender();
var logged = new CapturingLoggerProvider();
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 SqliteSessionDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
builder.Services.AddSeaHavenIdentity();
builder.Services.AddSingleton<IEmailSender>(sent);
builder.Services.AddDataServices();
builder.Services.AddBusinessServices(builder.Configuration);
// Forgot Password queues its email; the API host registers the same delivery.
builder.Services.AddSingleton<Sentry.IHub>(Sentry.Extensibility.HubAdapter.Instance);
builder.Services.AddPasswordResetEmailDelivery();
builder.Services.AddSeaHavenJwtAuthentication(builder.Configuration);
builder.Services.AddControllers()
.AddApplicationPart(typeof(AuthenticationController).Assembly)
.ConfigureApplicationPartManager(manager =>
{
manager.FeatureProviders.Clear();
manager.FeatureProviders.Add(new SessionControllers());
});
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
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();
var host = new SessionTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) });
host.Sent = sent;
host.Logged = logged;
return host;
}
public async Task<ApplicationUser> AddUserAsync(string email, string? role = null)
{
await using var scope = _app.Services.CreateAsyncScope();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = new ApplicationUser
{
UserName = email,
Email = email,
FirstName = "Sam",
LastName = "Lee",
EmailConfirmed = true,
UniqueName = "Active",
CreatedDate = DateTime.UtcNow
};
var created = await users.CreateAsync(user, Password);
Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(error => error.Description)));
if (role != null)
{
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
if (!await roles.RoleExistsAsync(role))
await roles.CreateAsync(new IdentityRole(role));
await users.AddToRoleAsync(user, role);
}
return user;
}
public async Task EnsureRoleAsync(string role)
{
await using var scope = _app.Services.CreateAsyncScope();
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
if (!await roles.RoleExistsAsync(role))
await roles.CreateAsync(new IdentityRole(role));
}
public async Task<string> SignInAsync(string email, string password = Password)
{
using var response = await Client.PostAsJsonAsync("api/Authentication/login", new { username = email, password });
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return payload.RootElement.GetProperty("token").GetString()!;
}
public Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, string? token, object? json = null)
{
var request = new HttpRequestMessage(method, path);
if (token != null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (json != null)
request.Content = JsonContent.Create(json);
return Client.SendAsync(request);
}
/// <summary>An authorized read that only succeeds for a signed-in, accepted session.</summary>
public Task<HttpResponseMessage> ProfileAsync(string? token) =>
SendAsync(HttpMethod.Get, "api/User/UserProfile", token);
/// <summary>Runs Forgot Password end to end: request a code, read it from the email, reset.</summary>
public async Task ResetPasswordAsync(string email, string newPassword)
{
var before = Sent.Messages.Count;
using (var requested = await SendAsync(
HttpMethod.Post, $"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(email)}", null, new { email }))
Assert.Equal(System.Net.HttpStatusCode.OK, requested.StatusCode);
var deadline = DateTime.UtcNow.AddSeconds(10);
SentEmail? message;
while ((message = Sent.Messages.Skip(before).LastOrDefault(sent => sent.To == email && ResetCode.IsMatch(sent.Body))) == null)
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("No password reset email was sent.");
await Task.Delay(10);
}
var code = ResetCode.Match(message.Body).Groups[1].Value;
using var reset = await SendAsync(
HttpMethod.Post, "api/Authentication/ResetPassword", null, new { email, code, password = newPassword });
Assert.Equal(System.Net.HttpStatusCode.OK, reset.StatusCode);
}
public async Task<string?> StoredSecurityStampAsync(string userId)
{
await using var scope = _app.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
.Users.AsNoTracking().Where(user => user.Id == userId).Select(user => user.SecurityStamp).SingleOrDefaultAsync();
}
/// <summary>
/// Re-signs <paramref name="token"/> with the host's real signing key after letting
/// <paramref name="edit"/> change its claims, so only the claims differ from a token
/// the API issued.
/// </summary>
public static string Resign(string token, Action<List<System.Security.Claims.Claim>> edit)
{
var original = new JwtSecurityTokenHandler().ReadJwtToken(token);
var claims = original.Claims
.Where(claim => claim.Type is not (JwtRegisteredClaimNames.Exp or JwtRegisteredClaimNames.Iss or JwtRegisteredClaimNames.Aud or JwtRegisteredClaimNames.Nbf or JwtRegisteredClaimNames.Iat))
.ToList();
edit(claims);
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret));
var resigned = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: original.ValidTo,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(resigned);
}
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 SessionControllers : ControllerFeatureProvider
{
protected override bool IsController(System.Reflection.TypeInfo typeInfo) =>
typeInfo.AsType() == typeof(AuthenticationController)
|| typeInfo.AsType() == typeof(UserController)
|| typeInfo.AsType() == typeof(TeamMemberController);
}
private sealed class SqliteSessionDbContext : ApplicationDbContext
{
public SqliteSessionDbContext(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;
}
}
}
}

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);
}