Compare commits

...

22 commits

Author SHA1 Message Date
Alexandre Brandizzi
50e5553e57
Merge pull request #177 from Sea-Haven-Industries/fix/ab/sh-397-uplift-decision-audit
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
fix(uplifts): let admins decide uplifts whose dispatch has no work order
2026-09-25 02:45:18 +00:00
Alexandre Brandizzi
b2b9fc3588 test(uplifts): cover escalation audit on a dispatch without an owning work order 2026-09-24 23:37:58 -03:00
Alexandre Brandizzi
59b8cdaf7d
Merge pull request #168 from Sea-Haven-Industries/fix/sh-327-request-uplifts-permission
fix(uplifts): enforce request permission at service boundary
2026-09-25 02:35:19 +00:00
Alexandre Brandizzi
c5d82a996a fix(uplifts): audit uplift decisions on the dispatch's resolved work order
Approve, reject, request-changes, expiry and escalation staged their work
order audit with WorkOrderId = dispatch.WorkOrderId ?? 0. WorkOrderAuditLogs
requires a real work order, so any uplift on a dispatch without an owning
work order failed to save: the decision returned a 500 and the request stayed
Pending, and the expiry sweep failed on it every run.

The audit now goes to the work order the uplift resolves to through the
existing owner-or-linked read that revoke already uses. When none resolves,
the status change is saved on the request and no audit row is written.
2026-09-24 23:30:16 -03:00
Alexandre Brandizzi
8185875ad2
Merge pull request #176 from Sea-Haven-Industries/fix/ab/sh-327-admin-approves-uplifts
fix(uplifts): Admin passes uplift approval checks regardless of tier config (SH-327)
2026-09-25 02:13:26 +00:00
Alexandre Brandizzi
beb091fcc8 Merge remote-tracking branch 'origin/main' into lane/sh-327
# Conflicts:
#	Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs
2026-09-24 23:00:18 -03:00
Alexandre Brandizzi
1e2f39a5fa chore(uplifts): drop ticket key from source comments 2026-09-24 22:57:09 -03:00
Alexandre Brandizzi
7591869462
Merge pull request #169 from Sea-Haven-Industries/hotfix/sh-387-concurrent-uplift
fix: return conflict for concurrent dispatch uplift requests
2026-09-25 01:54:50 +00:00
Alexandre Brandizzi
26b4aaf1b2 test(uplifts): cover Admin request-changes and evidence download with empty tier config (SH-327) 2026-09-24 22:51:57 -03:00
Alexandre Brandizzi
cfb05a906f fix(uplifts): Admin passes uplift approval checks regardless of tier config (SH-327)
UserCanApprove only accepted roles listed in Approvals:Tier1Roles/Tier2Roles,
so an environment whose config omits Admin denied every approval surface to
admins: can-approve, per-row CanDecide, approve/reject/request-changes and
evidence download. Admin now short-circuits the check; tier-role config still
governs every other role.
2026-09-24 22:34:32 -03:00
Alexandre Brandizzi
b116e71d16 test(uplifts): grant the SH-393 ownership fixture actor RequestUplifts
Uplift creation now checks the actor's RequestUplifts permission, so the
ownership tests construct the service with the permission services and seed
their actor as an Admin.
2026-09-24 22:31:07 -03:00
Alexandre Brandizzi
cc7cda4818 Merge remote-tracking branch 'origin/main' into lane/sh-327 2026-09-24 22:28:15 -03:00
Alexandre Brandizzi
2be74b261f test(uplifts): share conflict fixture dispatch through owned and linked work orders
After SH-393 the uplift dispatch resolves only through dispatches the work
order owns or links through DispatchWorkOrders. Model the shared dispatch
that way so the concurrent-insert conflict test exercises the conflict
mapping again; assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:28:04 -03:00
Alexandre Brandizzi
24ad21d7f2 Merge remote-tracking branch 'origin/main' into lane/sh-387 2026-09-24 22:25:54 -03:00
Alexandre Brandizzi
0ca9b767ed fix: distinguish uplift request key conflicts 2026-09-23 01:39:16 -03:00
Alexandre Brandizzi
8af9ad076f fix: map concurrent uplift inserts to conflict 2026-09-23 01:26:15 -03:00
Alexandre Brandizzi
0b39d92723 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
2026-09-23 00:57:01 -03:00
Alexandre Brandizzi
50a2cbab5f test(uplifts): correct current-role denial fixtures 2026-09-22 23:39:51 -03:00
Alexandre Brandizzi
e1632bc3bf fix(uplifts): expose only allowlisted denial copy 2026-09-22 23:35:07 -03:00
Alexandre Brandizzi
f4284badf2 fix(uplifts): place forbidden handler on request endpoint 2026-09-22 23:30:14 -03:00
Alexandre Brandizzi
ff6a5945f1 fix(uplifts): honor current permissions and denial contract 2026-09-22 23:24:17 -03:00
Alexandre Brandizzi
9156107e72 fix(uplifts): enforce request permission at service boundary 2026-09-22 23:09:47 -03:00
21 changed files with 1265 additions and 25 deletions

View file

@ -0,0 +1,206 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
// Admin passes every permission check regardless of stored configuration.
// Uplift approval authority is otherwise driven by Approvals:Tier1Roles/Tier2Roles,
// which may be empty in a deployed environment; Admin must still be able to decide.
public sealed class UpliftAdminApprovalTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ClaimsPrincipal UserWithRoles(params string[] roles)
{
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "user-42") };
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
}
private static UpliftService NewService(ApplicationDbContext context, ApprovalsOptions? options = null) =>
new(new UpliftDataService(context),
new DispatchDataService(context),
new NoopDocumentStorage(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions()));
private static async Task<Dispatch> SeedPendingAsync(ApplicationDbContext context, int requiredTier)
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Completed",
NTEAmount = 1000m,
DispatchNumber = "DIS-1"
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
CurrentNTE = 1000m,
RequestedNTE = 1800m,
VendorReason = "reason",
Status = UpliftStatus.Pending,
RequiredTier = requiredTier,
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
return dispatch;
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public void CanApprove_Admin_WithEmptyTierConfig_IsTrue(int tier)
{
using var context = NewContext();
var service = NewService(context);
service.CanApprove(UserWithRoles("Admin"), tier).Should().BeTrue();
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task List_Admin_WithEmptyTierConfig_CanDecidePendingRows(int tier)
{
using var context = NewContext();
var dispatch = await SeedPendingAsync(context, tier);
var service = NewService(context);
var queue = await service.ListAsync(UserWithRoles("Admin"), UpliftStatus.Pending, null, 1, 25, CancellationToken.None);
var forDispatch = await service.ListForDispatchAsync(UserWithRoles("Admin"), dispatch.Id, CancellationToken.None);
queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeTrue();
forDispatch.Should().ContainSingle().Which.CanDecide.Should().BeTrue();
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task Approve_Admin_WithEmptyTierConfig_Succeeds(int tier)
{
using var context = NewContext();
await SeedPendingAsync(context, tier);
var service = NewService(context);
var result = await service.ApproveAsync(UserWithRoles("Admin"), 1, "ok", CancellationToken.None);
result.Status.Should().Be(UpliftStatus.Approved);
context.Dispatches.Single().NTEAmount.Should().Be(1800m);
}
[Fact]
public async Task Reject_Admin_WithEmptyTierConfig_Succeeds()
{
using var context = NewContext();
await SeedPendingAsync(context, 2);
var service = NewService(context);
var result = await service.RejectAsync(UserWithRoles("Admin"), 1, "no", CancellationToken.None);
result.Status.Should().Be(UpliftStatus.Rejected);
context.Dispatches.Single().NTEAmount.Should().Be(1000m);
}
[Fact]
public async Task RequestChanges_Admin_WithEmptyTierConfig_Succeeds()
{
using var context = NewContext();
await SeedPendingAsync(context, 2);
var service = NewService(context);
var result = await service.RequestChangesAsync(UserWithRoles("Admin"), 1, "more detail", CancellationToken.None);
result.Status.Should().Be(UpliftStatus.ChangesRequested);
}
[Fact]
public async Task EvidenceDownload_Admin_WithEmptyTierConfig_ReturnsFile()
{
using var context = NewContext();
var dispatch = await SeedPendingAsync(context, 2);
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = dispatch.VendorId,
DispatchId = dispatch.Id,
WorkOrderId = dispatch.WorkOrderId!.Value,
OriginalFileName = "invoice.pdf",
StoredFileName = "evidence.bin",
ContentType = "application/pdf",
SizeBytes = 4,
ScanStatus = "Passed",
ReviewStatus = "Approved",
Purpose = "UpliftEvidence",
Version = 1
});
context.DispatchUpliftRequests.Single().EvidenceDocumentId = 1;
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Admin"), 1, CancellationToken.None);
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
result.FileName.Should().Be("invoice.pdf");
}
[Theory]
[InlineData("Dispatcher")]
[InlineData("Scheduler")]
public async Task NonAdminRole_WithEmptyTierConfig_IsStillDenied(string role)
{
using var context = NewContext();
await SeedPendingAsync(context, 1);
var service = NewService(context);
var user = UserWithRoles(role);
service.CanApprove(user, 1).Should().BeFalse();
service.CanApprove(user, 2).Should().BeFalse();
var queue = await service.ListAsync(user, UpliftStatus.Pending, null, 1, 25, CancellationToken.None);
queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeFalse();
var act = () => service.ApproveAsync(user, 1, "ok", CancellationToken.None);
await act.Should().ThrowAsync<UpliftForbiddenException>();
context.Dispatches.Single().NTEAmount.Should().Be(1000m);
context.DispatchUpliftRequests.Single().Status.Should().Be(UpliftStatus.Pending);
}
[Fact]
public void Tier1OnlyRole_ApprovesTier1_ButNotTier2()
{
using var context = NewContext();
var service = NewService(context, new ApprovalsOptions { Tier1Roles = new[] { "Approver" } });
var user = UserWithRoles("Approver");
service.CanApprove(user, 1).Should().BeTrue();
service.CanApprove(user, 2).Should().BeFalse();
}
private sealed class NoopDocumentStorage : IVendorDocumentStoragePort
{
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) => Task.CompletedTask;
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) => new MemoryStream();
public void Delete(int vendorId, int dispatchId, string storedFileName) { }
}
}

View file

@ -139,7 +139,8 @@ public sealed class UpliftWorkflowTests
private static VendorPortalService NewPortalService(
ApplicationDbContext context,
IEmailSender emailSender,
IVendorDocumentStoragePort? storage = null)
IVendorDocumentStoragePort? storage = null,
IUpliftDataService? upliftData = null)
{
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
@ -150,7 +151,7 @@ public sealed class UpliftWorkflowTests
return new VendorPortalService(
tokenService,
new DispatchDataService(context),
new UpliftDataService(context),
upliftData ?? new UpliftDataService(context),
commentData.Object,
Mock.Of<IUserDataService>(),
emailSender,
@ -214,6 +215,36 @@ public sealed class UpliftWorkflowTests
result.RequiredTier.Should().Be(2);
}
[Fact]
public async Task RequestUplift_ConcurrentActiveRequest_MapsPersistenceConflict()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException());
var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object);
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var act = () => service.RequestUpliftAsync(
session!,
dispatch.Id,
1500m,
"reason",
null,
1,
CancellationToken.None);
await act.Should().ThrowAsync<SeaHaven.Services.Exceptions.UpliftConflictException>();
}
// --- Evidence scan + cross-vendor/dispatch scoping ---
[Fact]
@ -650,6 +681,8 @@ public sealed class UpliftWorkflowTests
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DispatchUpliftRequest> { orphanReq, validReq });
upliftData.Setup(u => u.GetWorkOrderIdForUpliftAsync(202, It.IsAny<CancellationToken>()))
.ReturnsAsync(99);
upliftData.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);

View file

@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using Xunit;
@ -309,6 +310,34 @@ public class VendorPortalControllerTests
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RequestUpliftAsync(
Session,
10,
1500m,
"Need more parts",
It.IsAny<string?>(),
It.IsAny<int?>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftConflictException());
var controller = NewController(service);
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody
{
RequestedNTE = 1500m,
Reason = "Need more parts",
});
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value.Should().NotBeNull();
}
[Fact]
public async Task RequestUplift_Success_ReturnsResult()
{

View file

@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
@ -78,6 +79,75 @@ public sealed class WorkOrderUpliftControllerTests
Assert.Equal(12, created.Id);
}
[Fact]
public async Task CreateUplift_RequestPermissionDenied_ReturnsExact403Message()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage));
var controller = NewController(service, "Dispatcher");
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m },
CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
[Fact]
public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException("SECRET-internal-tier-detail"));
var controller = NewController(service, "Dispatcher");
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m },
CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().NotContain("SECRET-internal-tier-detail");
response.Message.Should().Contain("reference");
}
[Fact]
public async Task CreateUplift_ConcurrentActiveRequest_ReturnsConflict()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftConflictException());
var controller = NewController(service);
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m, Notes = "Extra labor" },
CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value.Should().NotBeNull();
}
[Fact]
public async Task CancelUplift_NotFound_Returns404()
{

View file

@ -0,0 +1,138 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Exceptions;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class WorkOrderUpliftServiceConflictTests
{
[Fact]
public async Task CreateAsync_DifferentWorkOrdersSharingDispatch_MapsConcurrentInsertConflict()
{
// Dispatch 10 is legitimately shared: work order 1 owns it and work order 2 links to
// it through DispatchWorkOrders, so the uplift dispatch resolves to it for both.
var sharedDispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
Status = "Scheduled",
NTEAmount = 1000m,
DispatchWorkOrders = new List<DispatchWorkOrder>
{
new() { DispatchId = 10, WorkOrderId = 2 },
},
};
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(x => x.GetUpliftDispatchForWorkOrderAsync(
It.IsIn(1, 2),
10,
It.IsAny<CancellationToken>()))
.ReturnsAsync(sharedDispatch);
var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var savesArrived = 0;
var winner = 0;
upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<WorkOrderUpliftDto?>>>(),
It.IsAny<CancellationToken>()))
.Returns<int, Func<CancellationToken, Task<WorkOrderUpliftDto?>>, CancellationToken>(
(_, work, cancellationToken) => work(cancellationToken));
upliftData.Setup(x => x.HasPendingForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.SumAutoApprovedAmountForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(0m);
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Returns(async () =>
{
if (Interlocked.Increment(ref savesArrived) == 2)
bothAtInsert.TrySetResult();
await bothAtInsert.Task.WaitAsync(TimeSpan.FromSeconds(5));
if (Interlocked.Exchange(ref winner, 1) != 0)
throw new UpliftDispatchConflictException();
});
var detailData = new Mock<IWorkOrderDetailDataService>();
detailData.Setup(x => x.ExistsAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
.ReturnsAsync(true);
detailData.Setup(x => x.GetWorkOrderForMediaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
.ReturnsAsync((int workOrderId, CancellationToken _, int? _) => new WorkOrder
{
Id = workOrderId,
PrimaryDispatchId = 10,
WorkOrderType = WorkOrderType.PM,
LifecycleStatus = LifecycleStatus.Scheduled,
});
var dispatchData = new Mock<IDispatchDataService>();
dispatchData.Setup(x => x.GetByIdAsync(10))
.ReturnsAsync(sharedDispatch);
dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var accountResolver = new Mock<IWorkOrderAccountResolver>();
accountResolver.Setup(x => x.ResolveAccountFilter(It.IsAny<ClaimsPrincipal>())).Returns((int?)null);
var userData = new Mock<IUserDataService>();
userData.Setup(x => x.GetDisplayNamesByIdsAsync(It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(new Dictionary<string, string>());
// SH-327: the requester must hold RequestUplifts before the create reaches the insert.
var permissionData = new Mock<ITeamPermissionOverrideDataService>();
permissionData.Setup(x => x.GetUserAsync("dispatcher-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamPermissionUserData { UserId = "dispatcher-1", RoleName = "Dispatcher" });
var service = new WorkOrderUpliftService(
upliftData.Object,
dispatchData.Object,
detailData.Object,
accountResolver.Object,
userData.Object,
permissionData.Object,
new TeamPermissionPolicy(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions
{
UpliftTier1MaxUsd = 2500m,
Tier1Roles = new[] { "Approver" },
Tier2Roles = new[] { "Manager" },
}));
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") },
"test"));
static async Task<(WorkOrderUpliftDto? Result, Exception? Error)> Capture(
Func<Task<WorkOrderUpliftDto?>> create)
{
try
{
return (await create(), null);
}
catch (Exception exception)
{
return (null, exception);
}
}
var outcomes = await Task.WhenAll(
Capture(() => service.CreateAsync(1, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)),
Capture(() => service.CreateAsync(2, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)));
Assert.Single(outcomes, outcome => outcome.Result != null);
Assert.IsType<UpliftConflictException>(Assert.Single(outcomes, outcome => outcome.Error != null).Error);
upliftData.Verify(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()), Times.Exactly(2));
}
}

View file

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -217,6 +218,10 @@ namespace Api.SeaHavenIndustries.Controllers
var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, body?.RequestKey, body?.EvidenceDocumentId, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch (UpliftConflictException ex)
{
return Conflict(new Response { Status = "Error", Message = ex.Message });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });

View file

@ -164,13 +164,20 @@ namespace Api.SeaHavenIndustries.Controllers
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(new DataResponse { Status = "Success", Data = created });
}
catch (UpliftConflictException ex)
{
return Conflict(new Response { Status = "Error", Message = ex.Message });
}
catch (KeyNotFoundException ex)
{
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
}
catch (UpliftForbiddenException ex)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") });
var message = ex.Message == UpliftForbiddenException.RequestUpliftsDeniedMessage
? UpliftForbiddenException.RequestUpliftsDeniedMessage
: _logger.Sanitize(ex, "You are not authorized to perform this action");
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = message });
}
catch (InvalidOperationException ex)
{

View file

@ -0,0 +1,9 @@
namespace SeaHaven.DataServices.Exceptions;
public sealed class UpliftDispatchConflictException : Exception
{
public UpliftDispatchConflictException()
: base("An active uplift request already exists for this dispatch.")
{
}
}

View file

@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Exceptions;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -544,7 +545,58 @@ namespace SeaHaven.DataServices.Implementation
public async Task SaveChangesAsync(CancellationToken cancellationToken)
{
await _context.SaveChangesAsync(cancellationToken);
try
{
await _context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException exception) when (IsActiveDispatchUniqueIndexViolation(exception))
{
throw new UpliftDispatchConflictException();
}
}
private bool IsActiveDispatchUniqueIndexViolation(DbUpdateException exception)
{
var provider = _context.Database.ProviderName;
var databaseException = exception.GetBaseException();
var exceptionType = databaseException.GetType();
if (provider?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) == true
&& exceptionType.FullName == "Microsoft.Data.SqlClient.SqlException")
{
var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?;
return number is 2601 or 2627
&& IsActiveDispatchIndexViolationMessage(databaseException.Message);
}
if (provider?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true
&& exceptionType.FullName == "Microsoft.Data.Sqlite.SqliteException")
{
var extendedCode = exceptionType.GetProperty("SqliteExtendedErrorCode")?.GetValue(databaseException) as int?;
const string uniqueConstraintPrefix = "UNIQUE constraint failed: ";
var message = databaseException.Message;
var prefixIndex = message.IndexOf(uniqueConstraintPrefix, StringComparison.OrdinalIgnoreCase);
if (extendedCode != 2067 || prefixIndex < 0)
return false;
var columnsStart = prefixIndex + uniqueConstraintPrefix.Length;
var columnsEnd = message.IndexOf('\'', columnsStart);
if (columnsEnd < 0)
columnsEnd = message.Length;
var columns = message[columnsStart..columnsEnd].Trim();
return string.Equals(
columns,
"DispatchUpliftRequests.DispatchId",
StringComparison.OrdinalIgnoreCase);
}
return false;
}
internal static bool IsActiveDispatchIndexViolationMessage(string message)
{
const string activeDispatchIndex = "'IX_DispatchUpliftRequests_DispatchId'";
return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase);
}
public async Task<T> ExecuteWorkOrderMutationAsync<T>(

View file

@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
[assembly: InternalsVisibleTo("SeaHavenIndustries.Tests")]

View file

@ -104,6 +104,9 @@ namespace SeaHaven.Services.DTOs
public class UpliftForbiddenException : Exception
{
public const string RequestUpliftsDeniedMessage =
"Your role can't request uplifts on this work order.";
public UpliftForbiddenException(string message) : base(message) { }
}

View file

@ -0,0 +1,9 @@
namespace SeaHaven.Services.Exceptions;
public sealed class UpliftConflictException : Exception
{
public UpliftConflictException()
: base("An active uplift request already exists for the associated dispatch. Refresh and retry.")
{
}
}

View file

@ -61,9 +61,9 @@ namespace SeaHaven.Services.Implementation
req.DecidedAt = now;
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch?.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
OldValue = previous,
NewValue = UpliftStatus.Expired,
@ -136,9 +136,9 @@ namespace SeaHaven.Services.Implementation
req.EscalatedAt = now;
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = "Pending",
NewValue = "Escalated",
@ -247,5 +247,19 @@ namespace SeaHaven.Services.Implementation
req.LastModificationTime = now;
await _upliftData.SaveChangesAsync(cancellationToken);
}
// The audit goes to the work order the uplift's dispatch belongs to (owner, else the
// one it is linked to). With neither, the status change is recorded on the request
// alone; a work-order id of 0 would fail the required audit foreign key and abort
// the sweep on every run.
private async Task StageAuditAsync(
int upliftId,
Func<int, WorkOrderAuditLog> buildEntry,
CancellationToken cancellationToken)
{
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
if (workOrderId is int resolved)
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
}
}
}

View file

@ -166,9 +166,9 @@ namespace SeaHaven.Services.Implementation
req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim();
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
OldValue = $"${oldNTE:F2}",
@ -252,9 +252,9 @@ namespace SeaHaven.Services.Implementation
req.DecisionNote = note!.Trim();
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch?.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
OldValue = previous,
@ -290,9 +290,9 @@ namespace SeaHaven.Services.Implementation
req.DecisionNote = note.Trim();
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch?.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
OldValue = previous,
@ -307,6 +307,19 @@ namespace SeaHaven.Services.Implementation
public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier);
// The decision audit goes to the work order the uplift's dispatch belongs to (owner,
// else the one it is linked to). A dispatch with neither has no work order to audit
// against, so the decision is recorded on the request alone.
private async Task StageDecisionAuditAsync(
int upliftId,
Func<int, WorkOrderAuditLog> buildEntry,
CancellationToken cancellationToken)
{
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
if (workOrderId is int resolved)
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
}
// SH-101: authorized internal download of a Passed UpliftEvidence file. The data
// service resolves the document by the request's own linkage (no client-supplied
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
@ -335,8 +348,12 @@ namespace SeaHaven.Services.Implementation
return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence");
}
// Admin passes every permission check regardless of stored configuration,
// so the tier-role lists only govern non-Admin approvers.
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
{
if (user.IsInRole("Admin")) return true;
var roles = RolesForTier(requiredTier);
foreach (var r in roles)
{

View file

@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -651,7 +652,14 @@ namespace SeaHaven.Services.Implementation
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
try
{
await _upliftData.SaveChangesAsync(cancellationToken);
}
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
{
throw new UpliftConflictException();
}
// Notification is best-effort and persisted separately from workflow state: a failure
// never destroys the actionable request (the lifecycle sweep retries by sentinel).

View file

@ -4,6 +4,7 @@ using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
@ -19,6 +20,8 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IUserDataService _userData;
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly TimeProvider _timeProvider;
private readonly ApprovalsOptions _approvalsOptions;
@ -28,6 +31,8 @@ namespace SeaHaven.Services.Implementation
IWorkOrderDetailDataService detailData,
IWorkOrderAccountResolver accountResolver,
IUserDataService userData,
ITeamPermissionOverrideDataService permissionOverrideData,
ITeamPermissionPolicy permissionPolicy,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions)
{
@ -36,6 +41,8 @@ namespace SeaHaven.Services.Implementation
_detailData = detailData;
_accountResolver = accountResolver;
_userData = userData;
_permissionOverrideData = permissionOverrideData;
_permissionPolicy = permissionPolicy;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
}
@ -61,6 +68,8 @@ namespace SeaHaven.Services.Implementation
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
await EnsureCanRequestUpliftAsync(user, cancellationToken);
if (request.Amount <= 0)
throw new InvalidOperationException("Uplift amount must be greater than zero");
@ -69,17 +78,49 @@ namespace SeaHaven.Services.Implementation
var notes = request.Notes?.Trim() ?? "";
var accountFilter = _accountResolver.ResolveAccountFilter(user);
return await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
ct => CreateLockedAsync(
try
{
return await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
request.Amount,
notes,
userId,
requesterName,
accountFilter,
ct),
cancellationToken);
ct => CreateLockedAsync(
workOrderId,
request.Amount,
notes,
userId,
requesterName,
accountFilter,
ct),
cancellationToken);
}
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
{
throw new UpliftConflictException();
}
}
private async Task EnsureCanRequestUpliftAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionOverrideData.GetUserAsync(userId, cancellationToken);
if (permissionUser is null)
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
if (!_permissionPolicy.IsAllowed(
permissionUser.RoleName,
TeamPermissionKeys.RequestUplifts,
permissionUser.Overrides))
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
}
private async Task<WorkOrderUpliftDto?> CreateLockedAsync(

View file

@ -9,6 +9,117 @@ namespace SeaHavenIndustries.Tests;
public sealed class UpliftDataServiceTransactionTests
{
[Theory]
[InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId'.", true)]
[InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId_RequestKey'.", false)]
public void IsActiveDispatchIndexViolationMessage_MatchesOnlyTheActiveDispatchIndex(
string message,
bool expected)
{
Assert.Equal(expected, UpliftDataService.IsActiveDispatchIndexViolationMessage(message));
}
[Fact]
public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await context.Database.EnsureCreatedAsync();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
}, CancellationToken.None);
await data.SaveChangesAsync(CancellationToken.None);
// The database index is the final guard when competing requests pass
// their dispatch-scoped active-request checks.
var exception = await Assert.ThrowsAsync<SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException>(
() => data.ExecuteWorkOrderMutationAsync(
2,
async cancellationToken =>
{
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestedNTE = 1700m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
}, cancellationToken);
await data.SaveChangesAsync(cancellationToken);
return true;
},
CancellationToken.None));
Assert.Equal("An active uplift request already exists for this dispatch.", exception.Message);
Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync());
}
[Fact]
public async Task SaveChangesAsync_RequestKeyConflict_IsNotMappedToActiveDispatchConflict()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await context.Database.EnsureCreatedAsync();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestKey = "same-key",
RequestedNTE = 1500m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
}, CancellationToken.None);
await data.SaveChangesAsync(CancellationToken.None);
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestKey = "same-key",
RequestedNTE = 1700m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
}, CancellationToken.None);
await Assert.ThrowsAsync<DbUpdateException>(
() => data.SaveChangesAsync(CancellationToken.None));
}
[Fact]
public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails()
{

View file

@ -0,0 +1,332 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace SeaHavenIndustries.Tests;
// Relational (SQLite, foreign keys on) coverage for the work-order audit entry written by
// uplift decisions. WorkOrderAuditLogs.WorkOrderId is a required FK, so the in-memory
// provider cannot prove a decision on a dispatch without an owning work order commits.
// The audit goes to the work order the dispatch belongs to (owner, else the one it is
// linked to through DispatchWorkOrders); when none resolves, the decision is still saved
// and no audit row is written.
public sealed class UpliftDecisionAuditRelationalTests
{
private const string AdminId = "admin-1";
public enum Ownership
{
Owned,
Linked,
Unresolvable
}
public enum Decision
{
Approve,
Reject,
RequestChanges
}
[Theory]
[InlineData(Decision.Approve, Ownership.Owned)]
[InlineData(Decision.Approve, Ownership.Linked)]
[InlineData(Decision.Approve, Ownership.Unresolvable)]
[InlineData(Decision.Reject, Ownership.Owned)]
[InlineData(Decision.Reject, Ownership.Linked)]
[InlineData(Decision.Reject, Ownership.Unresolvable)]
[InlineData(Decision.RequestChanges, Ownership.Owned)]
[InlineData(Decision.RequestChanges, Ownership.Linked)]
[InlineData(Decision.RequestChanges, Ownership.Unresolvable)]
public async Task Decision_CommitsAndAuditsResolvedWorkOrder(Decision decision, Ownership ownership)
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
int upliftId;
int? expectedWorkOrderId;
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
(upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: null);
}
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
{
var service = new UpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
new NoDocumentStorage(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var admin = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, AdminId),
new Claim(ClaimTypes.Role, "Admin")
}, "Test"));
switch (decision)
{
case Decision.Approve:
await service.ApproveAsync(admin, upliftId, null, CancellationToken.None);
break;
case Decision.Reject:
await service.RejectAsync(admin, upliftId, "too high", CancellationToken.None);
break;
case Decision.RequestChanges:
await service.RequestChangesAsync(admin, upliftId, "send a quote", CancellationToken.None);
break;
}
}
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
var expectedStatus = decision switch
{
Decision.Approve => UpliftStatus.Approved,
Decision.Reject => UpliftStatus.Rejected,
_ => UpliftStatus.ChangesRequested
};
Assert.Equal(expectedStatus, saved.Status);
Assert.Equal(AdminId, saved.DecidedByUserId);
if (decision == Decision.Approve)
{
var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync(d => d.Id == saved.DispatchId);
Assert.Equal(5_000m, dispatch.NTEAmount);
}
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
if (expectedWorkOrderId is int workOrderId)
{
var audit = Assert.Single(audits);
Assert.Equal(workOrderId, audit.WorkOrderId);
Assert.StartsWith("uplift_", audit.Action);
}
else
{
Assert.Empty(audits);
}
}
[Theory]
[InlineData(Ownership.Linked)]
[InlineData(Ownership.Unresolvable)]
public async Task Expiry_CommitsAndAuditsResolvedWorkOrder(Ownership ownership)
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
int upliftId;
int? expectedWorkOrderId;
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
(upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: DateTime.UtcNow.AddHours(-1));
}
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
{
var lifecycle = new UpliftLifecycleService(
new UpliftDataService(context),
new DispatchDataService(context),
null!,
new NoEmailSender(),
Options.Create(new FrontendOptions()),
Options.Create(new ApprovalsOptions()),
TimeProvider.System,
NullLogger<UpliftLifecycleService>.Instance);
Assert.Equal(1, await lifecycle.ExpireDueAsync(CancellationToken.None));
}
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
Assert.Equal(UpliftStatus.Expired, saved.Status);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
if (expectedWorkOrderId is int workOrderId)
Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId);
else
Assert.Empty(audits);
}
[Theory]
[InlineData(Ownership.Linked)]
[InlineData(Ownership.Unresolvable)]
public async Task Escalation_CommitsAndAuditsResolvedWorkOrder(Ownership ownership)
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
int upliftId;
int? expectedWorkOrderId;
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
(upliftId, expectedWorkOrderId) = await SeedAsync(
seed,
ownership,
expiresAt: null,
initialNotificationSentAt: DateTime.UtcNow.AddDays(-3));
}
var emailSender = new NoEmailSender();
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
{
var lifecycle = new UpliftLifecycleService(
new UpliftDataService(context),
new DispatchDataService(context),
null!,
emailSender,
Options.Create(new FrontendOptions()),
Options.Create(new ApprovalsOptions { EscalationRecipients = new[] { "escalate@test.local" } }),
TimeProvider.System,
NullLogger<UpliftLifecycleService>.Instance);
Assert.Equal(1, await lifecycle.EscalateDueAsync(CancellationToken.None));
}
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
Assert.NotNull(saved.EscalatedAt);
Assert.Equal(1, emailSender.Calls);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
if (expectedWorkOrderId is int workOrderId)
Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId);
else
Assert.Empty(audits);
}
private static async Task<(int UpliftId, int? ExpectedWorkOrderId)> SeedAsync(
ApplicationDbContext context,
Ownership ownership,
DateTime? expiresAt,
DateTime? initialNotificationSentAt = null)
{
context.Users.Add(new ApplicationUser
{
Id = AdminId,
UserName = AdminId,
NormalizedUserName = AdminId.ToUpperInvariant(),
Email = "admin@test.local",
NormalizedEmail = "ADMIN@TEST.LOCAL"
});
var vendor = new Vendor { CompanyName = "Vinewood LLC", IsActive = true };
// A second work order keeps ids distinct from the first, so an audit landing on
// the wrong work order cannot pass by coincidence.
var unrelated = new WorkOrder { InternalWONumber = "WO-OTHER", WorkerOrderTitle = "Other" };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-TARGET",
WorkerOrderTitle = "Repair",
LifecycleStatus = LifecycleStatus.Scheduled
};
context.AddRange(vendor, unrelated, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = ownership == Ownership.Owned ? workOrder.Id : null,
DispatchNumber = "DIS-UPLIFT",
Status = "Scheduled",
NTEAmount = 1_000m
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
if (ownership == Ownership.Linked)
{
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id
});
}
var request = new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
CurrentNTE = 1_000m,
RequestedNTE = 5_000m,
VendorReason = "Scope grew",
Status = UpliftStatus.Pending,
RequiredTier = 2,
CreatedDate = DateTime.UtcNow.AddDays(-4),
ExpiresAt = expiresAt,
InitialNotificationSentAt = initialNotificationSentAt,
NotificationStatus = initialNotificationSentAt == null ? "Pending" : "Sent"
};
context.DispatchUpliftRequests.Add(request);
await context.SaveChangesAsync();
return (request.Id, ownership == Ownership.Unresolvable ? null : workOrder.Id);
}
private sealed class NoDocumentStorage : IVendorDocumentStoragePort
{
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
=> throw new NotSupportedException();
public void Delete(int vendorId, int dispatchId, string storedFileName)
=> throw new NotSupportedException();
}
// Records sends without delivering anything.
private sealed class NoEmailSender : IEmailSender
{
public int Calls;
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
{
Calls++;
return Task.FromResult(true);
}
}
private sealed class SqliteUpliftDecisionTestDbContext : ApplicationDbContext
{
public SqliteUpliftDecisionTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQL Server filtered index syntax is invalid on SQLite.
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; treat as plain nullable blobs.
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
{
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
@ -203,6 +204,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
@ -264,6 +267,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(
@ -300,6 +305,13 @@ public class WorkOrderBoardCancelServiceTests
FirstName = "Alex",
LastName = "Dispatcher",
});
var adminRole = new IdentityRole("Admin");
seed.Roles.Add(adminRole);
seed.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = adminRole.Id,
});
seed.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
seed.Dispatches.Add(new Dispatch
{
@ -332,6 +344,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(createContext),
WorkOrderAccountTestHelpers.Resolver(createContext),
new UserDataService(createContext),
new TeamPermissionOverrideDataService(createContext),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var boardData = new WorkOrderBoardDataService(cancelContext);
@ -345,6 +359,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(cancelContext),
WorkOrderAccountTestHelpers.Resolver(cancelContext),
new UserDataService(cancelContext),
new TeamPermissionOverrideDataService(cancelContext),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
@ -31,6 +32,11 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
var context = new ApplicationDbContext(options);
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
// SH-327: uplift creation checks the actor's RequestUplifts permission.
var adminRole = new IdentityRole("Admin");
context.Roles.Add(adminRole);
context.Users.Add(new ApplicationUser { Id = "actor-1", UserName = "actor-1" });
context.UserRoles.Add(new IdentityUserRole<string> { UserId = "actor-1", RoleId = adminRole.Id });
context.SaveChanges();
return context;
}
@ -67,6 +73,8 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions
{

View file

@ -1,9 +1,11 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
@ -37,6 +39,8 @@ public sealed class WorkOrderUpliftServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(NewOptions()));
}
@ -47,6 +51,7 @@ public sealed class WorkOrderUpliftServiceTests
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
ApplicationDbContext context,
WorkOrderType type = WorkOrderType.PM,
string role = "Admin",
int? primaryDispatchId = 10)
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
@ -57,6 +62,13 @@ public sealed class WorkOrderUpliftServiceTests
FirstName = "Alex",
LastName = "Dispatcher",
});
var identityRole = new IdentityRole(role);
context.Roles.Add(identityRole);
context.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = identityRole.Id,
});
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
@ -124,6 +136,22 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
}
[Fact]
public async Task CreateAsync_CurrentDatabaseAdmin_RemainsUnrestricted()
{
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 },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"),
CancellationToken.None);
Assert.NotNull(created);
}
[Fact]
public async Task CreateAsync_WithoutPrimaryDispatch_UsesWorkOrderDispatch()
{
@ -174,6 +202,109 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal(workOrder.Id, audit.WorkOrderId);
}
[Fact]
public async Task CreateAsync_StaleAdminClaim_DeniesWhenDatabaseRoleIsNotAdmin()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: "Scheduler");
var service = NewService(context);
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
Dispatcher(),
CancellationToken.None));
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Fact]
public async Task CreateAsync_MissingDatabaseUser_DeniesEvenWithAdminClaim()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.UserRoles.RemoveRange(context.UserRoles.Where(userRole => userRole.UserId == "dispatcher-1"));
context.Users.Remove(context.Users.Single(user => user.Id == "dispatcher-1"));
await context.SaveChangesAsync();
var service = NewService(context);
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
Dispatcher("dispatcher-1"),
CancellationToken.None));
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Theory]
[InlineData("Dispatcher", true)]
[InlineData("Scheduler", false)]
public async Task CreateAsync_UsesRoleDefaultForRequestPermission(string role, bool allowed)
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
var service = NewService(context);
var act = () => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
CancellationToken.None);
if (allowed)
{
Assert.NotNull(await act());
return;
}
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Theory]
[InlineData("Scheduler", UserPermissionState.Allow, true)]
[InlineData("Dispatcher", UserPermissionState.Deny, false)]
public async Task CreateAsync_AppliesExplicitRequestPermissionOverride(
string role,
UserPermissionState overrideState,
bool allowed)
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
context.UserPermissionOverrides.Add(new UserPermissionOverride
{
UserId = "dispatcher-1",
PermissionKey = TeamPermissionKeys.RequestUplifts,
State = overrideState,
});
await context.SaveChangesAsync();
var service = NewService(context);
var act = () => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
CancellationToken.None);
if (allowed)
{
Assert.NotNull(await act());
return;
}
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Fact]
public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest()
{