diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index c9465b9..0dec122 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -18,12 +18,18 @@ public sealed class WorkOrderUpliftControllerTests private static WorkOrderDetailController NewController( Mock upliftService, params string[] roles) + => NewController(upliftService, new Mock(), roles); + + private static WorkOrderDetailController NewController( + Mock upliftService, + Mock evidenceService, + params string[] roles) { var controller = new WorkOrderDetailController( Mock.Of(), Mock.Of(), upliftService.Object, - Mock.Of(), + evidenceService.Object, Mock.Of>()); var claims = new List { 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(); + evidence.Setup(x => x.UploadAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage)); + + var controller = NewController(new Mock(), 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().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + var response = forbidden.Value.Should().BeOfType().Subject; + response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage); + } + [Fact] public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403() { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index 7d032fb..0ad2f8c 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -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") }); diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftRequestAuthorization.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftRequestAuthorization.cs new file mode 100644 index 0000000..3cb354a --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftRequestAuthorization.cs @@ -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 +{ + /// + /// Shared gate for creating an uplift and uploading its evidence. Both paths load the + /// caller's database role and overrides, then require . + /// + 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); + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs index a1ac913..be9b344 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftEvidenceService.cs @@ -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 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) diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 0e3af52..8153673 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -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 CreateLockedAsync( int workOrderId, decimal amount, diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs index 1be029d..60a5e14 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs @@ -24,7 +24,9 @@ public sealed class WorkOrderUpliftEvidenceServiceTests return new ApplicationDbContext(options); } - private static async Task SeedWorkOrderAsync(ApplicationDbContext context) + private static async Task 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 + { + 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(() => + 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(() => + 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 - { - UserId = "dispatcher-1", - RoleId = role.Id, - }); await context.SaveChangesAsync(); var (evidence, _) = NewService(context);