mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 11:22:09 +00:00
Compare commits
15 commits
ca9322fa60
...
74d5aa17eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d5aa17eb | ||
|
|
c985e9423d | ||
|
|
de0e767930 | ||
|
|
63465cc083 | ||
|
|
0298fc75bd | ||
|
|
498f49a2d8 | ||
|
|
987ec455f2 | ||
|
|
de8e283ee5 | ||
|
|
410bf1b4ca | ||
|
|
c8e4b8f3d5 | ||
|
|
cc46950254 | ||
|
|
b7be07411e | ||
|
|
e993e1b5fc | ||
|
|
185c84dd8c | ||
|
|
92cd44c481 |
32 changed files with 1949 additions and 55 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs
Normal file
138
Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
276
Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
Normal file
276
Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
26
SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs
Normal file
26
SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
109
SeaHaven.Services/Implementation/SessionStampService.cs
Normal file
109
SeaHaven.Services/Implementation/SessionStampService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
20
SeaHaven.Services/Interfaces/ISessionStampCache.cs
Normal file
20
SeaHaven.Services/Interfaces/ISessionStampCache.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
21
SeaHaven.Services/Interfaces/ISessionStampService.cs
Normal file
21
SeaHaven.Services/Interfaces/ISessionStampService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
259
SeaHavenIndustries.Tests/SessionRevocationTests.cs
Normal file
259
SeaHavenIndustries.Tests/SessionRevocationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
280
SeaHavenIndustries.Tests/SessionTestHost.cs
Normal file
280
SeaHavenIndustries.Tests/SessionTestHost.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
266
SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs
Normal file
266
SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue