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)
This commit is contained in:
Alexandre Brandizzi 2026-09-25 02:13:26 +00:00 • committed by GitHub
commit 8185875ad2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 210 additions and 0 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

@ -335,8 +335,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)
{