mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 01:52:10 +00:00
fix(uplifts): count evidence photos and require request permission
Uplift evidence uploads now share the work-order photo limit and the RequestUplifts check used when creating an uplift. Co-authored-by: Arthur Bassi <bassi-arthurr@users.noreply.github.com>
This commit is contained in:
parent
1daac954a3
commit
98bd45423d
6 changed files with 193 additions and 36 deletions
|
|
@ -18,12 +18,18 @@ public sealed class WorkOrderUpliftControllerTests
|
|||
private static WorkOrderDetailController NewController(
|
||||
Mock<IWorkOrderUpliftService> upliftService,
|
||||
params string[] roles)
|
||||
=> NewController(upliftService, new Mock<IWorkOrderUpliftEvidenceService>(), roles);
|
||||
|
||||
private static WorkOrderDetailController NewController(
|
||||
Mock<IWorkOrderUpliftService> upliftService,
|
||||
Mock<IWorkOrderUpliftEvidenceService> evidenceService,
|
||||
params string[] roles)
|
||||
{
|
||||
var controller = new WorkOrderDetailController(
|
||||
Mock.Of<IWorkOrderDetailService>(),
|
||||
Mock.Of<IWorkOrderCommentService>(),
|
||||
upliftService.Object,
|
||||
Mock.Of<IWorkOrderUpliftEvidenceService>(),
|
||||
evidenceService.Object,
|
||||
Mock.Of<ILogger<WorkOrderDetailController>>());
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "dispatcher-1") };
|
||||
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
|
|
@ -103,6 +109,31 @@ public sealed class WorkOrderUpliftControllerTests
|
|||
response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadUpliftEvidence_RequestPermissionDenied_ReturnsExact403Message()
|
||||
{
|
||||
var evidence = new Mock<IWorkOrderUpliftEvidenceService>();
|
||||
evidence.Setup(x => x.UploadAsync(
|
||||
7,
|
||||
It.IsAny<IFormFile>(),
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage));
|
||||
|
||||
var controller = NewController(new Mock<IWorkOrderUpliftService>(), evidence, "Scheduler");
|
||||
var file = new FormFile(new MemoryStream(new byte[] { 0xFF, 0xD8, 0xFF }), 0, 3, "file", "quote.jpg")
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = "image/jpeg",
|
||||
};
|
||||
var result = await controller.UploadUpliftEvidence(7, file, 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -206,6 +206,13 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
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)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The evidence file could not be uploaded") });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared gate for creating an uplift and uploading its evidence. Both paths load the
|
||||
/// caller's database role and overrides, then require <see cref="TeamPermissionKeys.RequestUplifts"/>.
|
||||
/// </summary>
|
||||
public static class WorkOrderUpliftRequestAuthorization
|
||||
{
|
||||
public static async Task EnsureCanRequestUpliftAsync(
|
||||
ITeamPermissionOverrideDataService permissionUsers,
|
||||
ITeamPermissionPolicy permissionPolicy,
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var permissionUser = string.IsNullOrWhiteSpace(userId)
|
||||
? null
|
||||
: await permissionUsers.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.Options;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
|
|
@ -23,6 +24,8 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IVendorDocumentDataService _documentData;
|
||||
private readonly IVendorDocumentStoragePort _documentStorage;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
|
||||
private readonly ITeamPermissionPolicy _permissionPolicy;
|
||||
private readonly VendorDocumentsOptions _documentOptions;
|
||||
|
||||
public WorkOrderUpliftEvidenceService(
|
||||
|
|
@ -32,6 +35,8 @@ namespace SeaHaven.Services.Implementation
|
|||
IVendorDocumentDataService documentData,
|
||||
IVendorDocumentStoragePort documentStorage,
|
||||
IWorkOrderAccountResolver accountResolver,
|
||||
ITeamPermissionOverrideDataService permissionOverrideData,
|
||||
ITeamPermissionPolicy permissionPolicy,
|
||||
IOptions<VendorDocumentsOptions> documentOptions)
|
||||
{
|
||||
_detailData = detailData;
|
||||
|
|
@ -40,6 +45,8 @@ namespace SeaHaven.Services.Implementation
|
|||
_documentData = documentData;
|
||||
_documentStorage = documentStorage;
|
||||
_accountResolver = accountResolver;
|
||||
_permissionOverrideData = permissionOverrideData;
|
||||
_permissionPolicy = permissionPolicy;
|
||||
_documentOptions = documentOptions.Value;
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +56,12 @@ namespace SeaHaven.Services.Implementation
|
|||
ClaimsPrincipal user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await WorkOrderUpliftRequestAuthorization.EnsureCanRequestUpliftAsync(
|
||||
_permissionOverrideData,
|
||||
_permissionPolicy,
|
||||
user,
|
||||
cancellationToken);
|
||||
|
||||
var workOrder = await FindAccessibleWorkOrderAsync(workOrderId, user, cancellationToken);
|
||||
if (workOrder == null)
|
||||
return null;
|
||||
|
|
@ -63,6 +76,7 @@ namespace SeaHaven.Services.Implementation
|
|||
RejectTerminalTarget(workOrder, dispatch);
|
||||
|
||||
var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken);
|
||||
await EnsureWithinMediaCountsAsync(workOrderId, contentType, file.FileName, cancellationToken);
|
||||
var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken);
|
||||
var version = (latest?.Version ?? 0) + 1;
|
||||
var storedFileName = $"{dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}";
|
||||
|
|
@ -190,6 +204,29 @@ namespace SeaHaven.Services.Implementation
|
|||
throw new InvalidOperationException($"Cannot attach uplift evidence on a '{dispatch.Status}' dispatch");
|
||||
}
|
||||
|
||||
private async Task EnsureWithinMediaCountsAsync(
|
||||
int workOrderId,
|
||||
string contentType,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Same per-work-order photo/video count as the vendor uplift-evidence upload:
|
||||
// dispatcher attachments plus every active vendor document, with no purpose filter.
|
||||
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
|
||||
workOrderId,
|
||||
null,
|
||||
cancellationToken);
|
||||
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
|
||||
workOrderId,
|
||||
cancellationToken);
|
||||
var countMessage = WorkOrderMediaContract.ValidateCount(
|
||||
WorkOrderMediaContract.ResolveKind(contentType, fileName),
|
||||
attachmentUrls,
|
||||
vendorTypes);
|
||||
if (countMessage != null)
|
||||
throw new InvalidOperationException(countMessage);
|
||||
}
|
||||
|
||||
private async Task<(string ContentType, byte[] Bytes)> ReadAcceptedFileAsync(
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ using Microsoft.Extensions.Options;
|
|||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
|
@ -81,7 +80,11 @@ namespace SeaHaven.Services.Implementation
|
|||
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
|
||||
return null;
|
||||
|
||||
await EnsureCanRequestUpliftAsync(user, cancellationToken);
|
||||
await WorkOrderUpliftRequestAuthorization.EnsureCanRequestUpliftAsync(
|
||||
_permissionOverrideData,
|
||||
_permissionPolicy,
|
||||
user,
|
||||
cancellationToken);
|
||||
|
||||
if (request.Amount <= 0)
|
||||
throw new InvalidOperationException("Uplift amount must be greater than zero");
|
||||
|
|
@ -114,31 +117,6 @@ namespace SeaHaven.Services.Implementation
|
|||
}
|
||||
}
|
||||
|
||||
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(
|
||||
int workOrderId,
|
||||
decimal amount,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
|
|||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static async Task<WorkOrder> SeedWorkOrderAsync(ApplicationDbContext context)
|
||||
private static async Task<WorkOrder> SeedWorkOrderAsync(
|
||||
ApplicationDbContext context,
|
||||
string role = "Admin")
|
||||
{
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||
context.Users.Add(new ApplicationUser
|
||||
|
|
@ -34,6 +36,13 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
|
|||
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
|
||||
{
|
||||
|
|
@ -69,6 +78,8 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
|
|||
new VendorDocumentDataService(context),
|
||||
storage,
|
||||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new TeamPermissionOverrideDataService(context),
|
||||
new TeamPermissionPolicy(),
|
||||
Options.Create(new VendorDocumentsOptions
|
||||
{
|
||||
PassWhenScannerUnavailable = passWhenScannerUnavailable,
|
||||
|
|
@ -158,6 +169,64 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
|
|||
Assert.Empty(storage.Saved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadAsync_EleventhPhoto_RejectsAndPersistsNothing()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var workOrder = await SeedWorkOrderAsync(context);
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
|
||||
{
|
||||
VendorId = 1,
|
||||
DispatchId = 10,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = $"photo-{i}.jpg",
|
||||
StoredFileName = $"photo-{i}.jpg",
|
||||
ContentType = "image/jpeg",
|
||||
Purpose = "Completion",
|
||||
ScanStatus = "Passed",
|
||||
Version = 1,
|
||||
});
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
var (service, storage) = NewService(context);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.UploadAsync(
|
||||
workOrder.Id,
|
||||
FormFile(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, "quote.jpg", "image/jpeg"),
|
||||
Dispatcher(),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal("A work order can have at most 10 photos.", ex.Message);
|
||||
Assert.Equal(10, context.VendorCompletionDocuments.Count());
|
||||
Assert.DoesNotContain(context.VendorCompletionDocuments, document => document.Purpose == VendorDocumentPurpose.UpliftEvidence);
|
||||
Assert.Empty(context.WorkOrderAuditLogs);
|
||||
Assert.Empty(storage.Saved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var workOrder = await SeedWorkOrderAsync(context, role: "Scheduler");
|
||||
var (service, storage) = NewService(context);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<UpliftForbiddenException>(() =>
|
||||
service.UploadAsync(
|
||||
workOrder.Id,
|
||||
FormFile(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, "quote.jpg", "image/jpeg"),
|
||||
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, ex.Message);
|
||||
Assert.Empty(context.VendorCompletionDocuments);
|
||||
Assert.Empty(context.WorkOrderAuditLogs);
|
||||
Assert.Empty(storage.Saved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatusAsync_ReturnsScanStatusOnlyForUpliftEvidenceOnTheDispatch()
|
||||
{
|
||||
|
|
@ -281,13 +350,6 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
|
|||
await using var context = CreateContext();
|
||||
var workOrder = await SeedWorkOrderAsync(context);
|
||||
workOrder.PrimaryDispatchId = null;
|
||||
var role = new IdentityRole("Admin");
|
||||
context.Roles.Add(role);
|
||||
context.UserRoles.Add(new IdentityUserRole<string>
|
||||
{
|
||||
UserId = "dispatcher-1",
|
||||
RoleId = role.Id,
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var (evidence, _) = NewService(context);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue