diff --git a/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs new file mode 100644 index 0000000..cbfc971 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs @@ -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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ClaimsPrincipal UserWithRoles(params string[] roles) + { + var claims = new List { 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 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(); + 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) { } + } +} diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 5459c36..801139e 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -335,8 +335,12 @@ namespace SeaHaven.Services.Implementation 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) { + if (user.IsInRole("Admin")) return true; + var roles = RolesForTier(requiredTier); foreach (var r in roles) {