mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 22:33:19 +00:00
Merge pull request #196 from Sea-Haven-Industries/fix/ab/sh-406-no-revoke-auto-approved
fix(uplifts): refuse admin revoke of an auto-approved uplift
This commit is contained in:
commit
c8e4b8f3d5
4 changed files with 319 additions and 4 deletions
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -273,6 +273,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
if (canonical == UpliftStatus.NoApprovalRequired)
|
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)
|
if (string.IsNullOrWhiteSpace(userId)
|
||||||
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
|
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
|
||||||
row.Id,
|
row.Id,
|
||||||
created!.Id,
|
created!.Id,
|
||||||
new RevokeWorkOrderUpliftRequestDto(),
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
WorkOrderAccountTestHelpers.AccountUser(),
|
WorkOrderAccountTestHelpers.AccountUser("actor-1", 1, "Dispatcher"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("revoked", revoked!.Status);
|
Assert.Equal("revoked", revoked!.Status);
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
||||||
=> WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
|
=> 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(
|
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
WorkOrderType type = WorkOrderType.PM,
|
WorkOrderType type = WorkOrderType.PM,
|
||||||
|
|
@ -585,7 +590,7 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
workOrder.Id,
|
workOrder.Id,
|
||||||
created.Id,
|
created.Id,
|
||||||
new RevokeWorkOrderUpliftRequestDto(),
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
Dispatcher(),
|
DispatcherRoleOnly(),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise
|
// SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise
|
||||||
|
|
@ -724,7 +729,7 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
workOrder.Id,
|
workOrder.Id,
|
||||||
created!.Id,
|
created!.Id,
|
||||||
new RevokeWorkOrderUpliftRequestDto(),
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
Dispatcher(),
|
DispatcherRoleOnly(),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("revoked", revoked!.Status);
|
Assert.Equal("revoked", revoked!.Status);
|
||||||
|
|
@ -733,6 +738,35 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
|
.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]
|
[Theory]
|
||||||
[InlineData(LifecycleStatus.Completed)]
|
[InlineData(LifecycleStatus.Completed)]
|
||||||
[InlineData(LifecycleStatus.Canceled)]
|
[InlineData(LifecycleStatus.Canceled)]
|
||||||
|
|
@ -760,7 +794,7 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
workOrder.Id,
|
workOrder.Id,
|
||||||
100,
|
100,
|
||||||
new RevokeWorkOrderUpliftRequestDto(),
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
Dispatcher(),
|
DispatcherRoleOnly(),
|
||||||
CancellationToken.None));
|
CancellationToken.None));
|
||||||
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue