mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 10:12:09 +00:00
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.
This commit is contained in:
parent
7b7df4463e
commit
cfb05a906f
2 changed files with 169 additions and 0 deletions
165
Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs
Normal file
165
Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
// SH-327: 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -335,8 +335,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence");
|
return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-327: 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)
|
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
|
||||||
{
|
{
|
||||||
|
if (user.IsInRole("Admin")) return true;
|
||||||
|
|
||||||
var roles = RolesForTier(requiredTier);
|
var roles = RolesForTier(requiredTier);
|
||||||
foreach (var r in roles)
|
foreach (var r in roles)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue