From dc251c42d42dff0a1d5da8cadb8d6b24c623de77 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 20:52:44 -0300 Subject: [PATCH 01/10] fix(media): apply SH-116 media contract and lift the 1 MB proxy body cap The Elastic Beanstalk nginx proxy kept its 1 MB default body limit, so every media upload over ~1 MB got an nginx 413 before reaching the API. Ship a .platform nginx override (120M) in the bundle and assert it in the bundle contract. Apply the client-confirmed contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB (MP4/MOV), at most 10 photos and 3 videos per work order, with stable generic rejection messages. The request ceiling (110 MB) sits between the per-kind caps and the proxy so oversize files get the generic message. The vendor portal accepts the same photo/video types and caps. --- .../nginx/conf.d/01_upload_body_size.conf | 6 + .../VendorPortalDocumentTests.cs | 110 +++++++ .../WorkOrderMediaControllerTests.cs | 167 ++++++++++- .../Controllers/VendorPortalController.cs | 3 +- .../Controllers/WorkOrderMediaController.cs | 11 +- .../WorkOrderMediaDataService.cs | 12 + .../Interfaces/IWorkOrderMediaDataService.cs | 5 + .../Helpers/WorkOrderMediaContract.cs | 84 ++++++ .../Helpers/WorkOrderMediaFileRules.cs | 28 +- .../Implementation/VendorPortalService.cs | 86 +++--- .../Implementation/WorkOrderMediaService.cs | 38 +++ .../WorkOrderPhase6Tests.cs | 283 ++++++++++++++++++ scripts/check_app_terraform_isolation.py | 2 +- scripts/package-elastic-beanstalk.sh | 5 + scripts/test_check_app_terraform_isolation.py | 11 + scripts/validate-elastic-beanstalk-bundle.sh | 8 +- 16 files changed, 807 insertions(+), 52 deletions(-) create mode 100644 .platform/nginx/conf.d/01_upload_body_size.conf create mode 100644 SeaHaven.Services/Helpers/WorkOrderMediaContract.cs diff --git a/.platform/nginx/conf.d/01_upload_body_size.conf b/.platform/nginx/conf.d/01_upload_body_size.conf new file mode 100644 index 0000000..a7bd889 --- /dev/null +++ b/.platform/nginx/conf.d/01_upload_body_size.conf @@ -0,0 +1,6 @@ +# SH-383: the Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m, +# which returned 413 for every media upload over ~1 MB before the request reached +# the API. The cap sits above the API's own request limit +# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get +# the API's generic per-kind message instead of an nginx error page. +client_max_body_size 120M; diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 0d8f6e3..83d9d1d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -363,6 +363,116 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(1); } + [Fact] + public void UploadCompletionDocument_AdvertisesContractRequestLimit() + { + var attribute = Assert.Single( + typeof(VendorPortalController) + .GetMethod(nameof(VendorPortalController.UploadCompletionDocument))! + .CustomAttributes, + candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); + var bytes = Assert.Single(attribute.ConstructorArguments); + + bytes.Value.Should().Be(110_000_000L); + } + + private static byte[] MediaBytes(int size, params byte[] header) + { + var bytes = new byte[size]; + header.AsSpan().CopyTo(bytes); + return bytes; + } + + private static byte[] FtypVideoBytes(int size) => MediaBytes( + size, 0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p', + (byte)'i', (byte)'s', (byte)'o', (byte)'m'); + + [Fact] + public async Task UploadCompletionDocument_AcceptsSixtyMegabyteVideo() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(60_000_000), "site-clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ContentType.Should().Be("video/mp4"); + document.SizeBytes.Should().Be(60_000_000L); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsMovWithEmptyContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "")); + + result.Should().BeOfType(); + (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("video/quicktime"); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsIosTranscodedJpegLabelledHeic() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(4_096, 0xFF, 0xD8, 0xFF, 0xE0), "IMG_2044.jpg", "image/heic")); + + result.Should().BeOfType(); + (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg"); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(100_000_001), "site-clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsPhotoOverTenMegabytes() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(10_000_001, 0xFF, 0xD8, 0xFF, 0xE0), "photo.jpg", "image/jpeg")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsUnsupportedVideoContainer() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile("not a video at all"u8.ToArray(), "clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + [Fact] public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index a29b332..32c70df 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -1,5 +1,6 @@ using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; @@ -31,7 +32,7 @@ public class WorkOrderMediaControllerTests } [Fact] - public void AddMedia_HasTwoHundredMegabyteRequestLimit() + public void AddMedia_HasContractRequestLimit() { var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); var attribute = Assert.Single( @@ -39,24 +40,26 @@ public class WorkOrderMediaControllerTests candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); var bytes = Assert.Single(attribute.ConstructorArguments); - Assert.Equal(200_000_000L, bytes.Value); + Assert.Equal(110_000_000L, bytes.Value); } [Fact] - public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit() + public void AddMedia_HasContractMultipartBodyLimit() { var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); var attribute = Assert.IsType(Assert.Single( method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true))); - Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit); + Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit); } [Fact] - public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity() + public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity() { var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(200_000_001); + file.SetupGet(candidate => candidate.Length).Returns(100_000_001); + file.SetupGet(candidate => candidate.FileName).Returns("clip.mp4"); + file.SetupGet(candidate => candidate.ContentType).Returns("video/mp4"); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); @@ -65,10 +68,160 @@ public class WorkOrderMediaControllerTests var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("FileTooLarge", error.Code); - Assert.Equal("The uploaded file must not exceed 200 MB.", error.Message); + Assert.Equal("Videos must be 100 MB or smaller.", error.Message); storage.VerifyNoOtherCalls(); } + [Fact] + public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity() + { + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(10_000_001); + file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); + file.SetupGet(candidate => candidate.ContentType).Returns("image/jpeg"); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Photos must be 10 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() + { + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(50_000_001); + file.SetupGet(candidate => candidate.FileName).Returns("report.pdf"); + file.SetupGet(candidate => candidate.ContentType).Returns("application/pdf"); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Documents must be 50 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + + private static FormFile VideoFormFile(int size, string fileName, string contentType) + { + var bytes = new byte[size]; + // ISO BMFF ftyp box so the media type rules accept the payload as MP4/MOV. + bytes[4] = (byte)'f'; + bytes[5] = (byte)'t'; + bytes[6] = (byte)'y'; + bytes[7] = (byte)'p'; + bytes[8] = (byte)'i'; + bytes[9] = (byte)'s'; + bytes[10] = (byte)'o'; + bytes[11] = (byte)'m'; + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static (Mock Service, Mock Storage) SetupSuccessfulAddMedia() + { + var service = new Mock(MockBehavior.Strict); + service + .Setup(candidate => candidate.EnsureCanMutateMediaAsync( + 1, It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Returns(Task.CompletedTask); + service + .Setup(candidate => candidate.AddMediaAsync( + 1, null, "https://storage.test/stored", It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" }); + var storage = new Mock(MockBehavior.Strict); + storage + .Setup(candidate => candidate.SaveFileAsync(It.IsAny())) + .ReturnsAsync("https://storage.test/stored"); + return (service, storage); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMp4_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mp4", "video/mp4"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var ok = Assert.IsType(result); + Assert.Equal(5, Assert.IsType(ok.Value).Id); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mov", "video/quicktime"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMovWithEmptyContentType_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.MOV", ""); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMp4WithOctetStreamContentType_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mp4", "application/octet-stream"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_HeicPhoto_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var bytes = new byte[512]; + bytes[4] = (byte)'f'; + bytes[5] = (byte)'t'; + bytes[6] = (byte)'y'; + bytes[7] = (byte)'p'; + bytes[8] = (byte)'h'; + bytes[9] = (byte)'e'; + bytes[10] = (byte)'i'; + bytes[11] = (byte)'c'; + var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "capture.heic") + { + Headers = new HeaderDictionary(), + ContentType = "image/heic" + }; + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + [Fact] public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity() { diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 39dd90b..1e7e59c 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -294,7 +295,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("dispatches/{id:int}/completion-documents")] [HttpPost("dispatches/{id:int}/documents")] - [RequestSizeLimit(10_000_000)] + [RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)] public async Task UploadCompletionDocument( int id, [FromForm] IFormFile file, diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 17b3ba3..4f854ef 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/workorders")] public class WorkOrderMediaController : Controller { - public const long MaxUploadBytes = 200_000_000; + public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes; private readonly IWorkOrderMediaService _workOrderMediaService; private readonly IFileStoragePort _fileStorage; @@ -88,11 +88,12 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { - if (file.Length > MaxUploadBytes) + // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). + var sizeMessage = WorkOrderMediaContract.ValidateSize( + file.ContentType, file.FileName, file.Length); + if (sizeMessage != null) { - throw new WorkOrderBoardValidationException( - "FileTooLarge", - "The uploaded file must not exceed 200 MB."); + throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); } WorkOrderMediaFileRules.EnsureAllowed(file, category); diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index fd05a45..9ad9dbe 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -56,6 +56,18 @@ namespace SeaHaven.DataServices.Implementation public void TrackAttachment(WorkOrderAttachments attachment) => _context.workOrderAttachments.Add(attachment); + public async Task> ListActiveAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken) + { + var urls = await _context.workOrderAttachments + .AsNoTracking() + .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true && a.Attachments != null) + .Select(a => a.Attachments!) + .ToListAsync(cancellationToken); + return urls; + } + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index 57cbbf3..30d0005 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -22,6 +22,11 @@ namespace SeaHaven.DataServices.Interfaces Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); void TrackAttachment(WorkOrderAttachments attachment); + + /// Stored URLs of the work order's non-deleted attachments (SH-116 photo/video counts). + Task> ListActiveAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); void MarkWorkOrderModified(WorkOrder workOrder); Task SaveAsync(CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs new file mode 100644 index 0000000..243dcc7 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -0,0 +1,84 @@ +namespace SeaHaven.Services.Helpers +{ + /// + /// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), + /// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work + /// order, across the dispatcher media modal, the completion-doc media tab and the vendor + /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is only + /// checkable client-side (the server cannot probe it cheaply), so it is enforced in the + /// browser and documented here as part of the same contract. + /// + public static class WorkOrderMediaContract + { + public const long MaxPhotoBytes = 10_000_000; + public const long MaxVideoBytes = 100_000_000; + public const long MaxDocumentBytes = 50_000_000; + public const int MaxPhotosPerWorkOrder = 10; + public const int MaxVideosPerWorkOrder = 3; + public const int MaxVideoDurationSeconds = 90; + + /// + /// Request-level ceiling for media endpoints (RequestSizeLimit / multipart limit). It sits + /// above plus multipart overhead so an oversize file reaches the + /// per-kind check and gets its generic message instead of a framework 413. The EB nginx + /// proxy (.platform/nginx/conf.d/01_upload_body_size.conf) must stay above this value. + /// + public const long MaxUploadRequestBytes = 110_000_000; + + public enum UploadKind + { + Photo, + Video, + Document, + Unknown + } + + public static UploadKind ResolveKind(string? contentType, string? fileName) + { + var type = (contentType ?? string.Empty).Trim(); + var extension = Path.GetExtension(fileName ?? string.Empty); + + if (type.StartsWith("video/", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".mov", StringComparison.OrdinalIgnoreCase)) + return UploadKind.Video; + + if (type.StartsWith("image/", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".png", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".heic", StringComparison.OrdinalIgnoreCase)) + return UploadKind.Photo; + + if (type.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) + || type.Equals("application/msword", StringComparison.OrdinalIgnoreCase) + || type.Equals( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + StringComparison.OrdinalIgnoreCase) + || extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) + return UploadKind.Document; + + return UploadKind.Unknown; + } + + /// Stable, generic per-kind size message; never echoes file metadata. + public static string? ValidateSize(long length, UploadKind kind) + { + return kind switch + { + UploadKind.Photo when length > MaxPhotoBytes => "Photos must be 10 MB or smaller.", + UploadKind.Video when length > MaxVideoBytes => "Videos must be 100 MB or smaller.", + UploadKind.Document when length > MaxDocumentBytes => + "Documents must be 50 MB or smaller.", + UploadKind.Unknown when length > MaxVideoBytes => + "Videos must be 100 MB or smaller.", + _ => null + }; + } + + public static string? ValidateSize(string? contentType, string? fileName, long length) + => ValidateSize(length, ResolveKind(contentType, fileName)); + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index aa6439a..ee02d9b 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -12,6 +12,7 @@ namespace SeaHaven.Services.Helpers "image/jpeg", "image/jpg", "image/png", + "image/heic", "video/mp4", "video/quicktime", "application/pdf", @@ -24,6 +25,7 @@ namespace SeaHaven.Services.Helpers { ["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" }, ["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" }, + ["image/heic"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".heic" }, ["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }, ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" }, ["application/pdf"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".pdf" }, @@ -42,7 +44,11 @@ namespace SeaHaven.Services.Helpers // the accepted set of files. private static string? ResolveContentType(string declaredType, string extension) { - if (AllowedContentTypes.Contains(declaredType)) + // iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic as + // its type, so a declared image/heic is only authoritative on a real .heic file. + var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase); + if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic) return declaredType; foreach (var (contentType, extensions) in ExtensionsByContentType) @@ -139,6 +145,11 @@ namespace SeaHaven.Services.Helpers && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; } + if (contentType.Equals("image/heic", StringComparison.OrdinalIgnoreCase)) + { + return IsHeifBrand(bytes); + } + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; @@ -206,5 +217,20 @@ namespace SeaHaven.Services.Helpers && bytes[6] == (byte)'y' && bytes[7] == (byte)'p'; } + + private static bool IsHeifBrand(byte[] bytes) + { + if (!HasFtypBox(bytes)) + return false; + + // HEIC/HEIF containers identify by the ftyp major brand (bytes 8..11). + var brand = System.Text.Encoding.ASCII.GetString(bytes, 8, 4); + return brand switch + { + "heic" or "heix" or "hevc" or "hevx" or "heim" or "heis" or "hevm" or "hevs" + or "mif1" or "msf1" => true, + _ => false + }; + } } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 5fc5140..cbe8192 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -4,6 +4,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 @@ -12,9 +13,36 @@ namespace SeaHaven.Services.Implementation { private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) { - "application/pdf", "image/jpeg", "image/jpg", "image/png" + "application/pdf", "image/jpeg", "image/jpg", "image/png", "image/heic", + "video/mp4", "video/quicktime" }; + // The browser's File.type is unreliable on mobile (empty or application/octet-stream), + // so an extension pairing against the same allowlist resolves the real content type. + private static string? ResolveUploadContentType(IFormFile file) + { + var declaredType = (file.ContentType ?? string.Empty).Trim(); + var extension = Path.GetExtension(file.FileName ?? string.Empty); + // iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic. + var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase); + if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic) + return declaredType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) + ? "image/jpeg" + : declaredType; + + return extension.ToLowerInvariant() switch + { + ".pdf" => "application/pdf", + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".heic" => "image/heic", + ".mp4" => "video/mp4", + ".mov" => "video/quicktime", + _ => null + }; + } + private const int MaxRefusalReasonLength = 500; private readonly IVendorPortalTokenService _tokens; @@ -820,15 +848,30 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("A completion document file is required."); } - if (file.Length > _documentOptions.MaxSizeBytes) + // SH-116 contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB + // (MP4/MOV). Documents keep the configured VendorDocuments cap. + var contentType = ResolveUploadContentType(file); + if (contentType == null) { - throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); + throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted."); } - var contentType = (file.ContentType ?? string.Empty).Trim(); - if (!AllowedContentTypes.Contains(contentType)) + var kind = WorkOrderMediaContract.ResolveKind(contentType, file.FileName); + if (kind == WorkOrderMediaContract.UploadKind.Photo + && file.Length > WorkOrderMediaContract.MaxPhotoBytes) { - throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted."); + throw new InvalidOperationException("Photos must be 10 MB or smaller."); + } + if (kind == WorkOrderMediaContract.UploadKind.Video + && file.Length > WorkOrderMediaContract.MaxVideoBytes) + { + throw new InvalidOperationException("Videos must be 100 MB or smaller."); + } + if (kind != WorkOrderMediaContract.UploadKind.Photo + && kind != WorkOrderMediaContract.UploadKind.Video + && file.Length > _documentOptions.MaxSizeBytes) + { + throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); } var resolvedPurpose = string.IsNullOrWhiteSpace(purpose) @@ -851,7 +894,7 @@ namespace SeaHaven.Services.Implementation await file.CopyToAsync(buffer, cancellationToken); var bytes = buffer.ToArray(); - if (!MatchesSignature(contentType, bytes)) + if (!WorkOrderMediaFileRules.MatchesSignature(contentType, bytes)) { throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); } @@ -984,35 +1027,6 @@ namespace SeaHaven.Services.Implementation }; } - private static bool MatchesSignature(string contentType, byte[] bytes) - { - if (bytes.Length == 0) - { - return false; - } - - if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) - { - return bytes.Length >= 4 - && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF - } - - if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) - { - return bytes.Length >= 8 - && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 - && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; - } - - if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) - || contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) - { - return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; - } - - return false; - } - private static string GetSafeExtension(string fileName) { var extension = Path.GetExtension(fileName); diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 0b0e38c..dccbffd 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -153,6 +153,8 @@ namespace SeaHaven.Services.Implementation }; } + await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, cancellationToken); + var attachment = new WorkOrderAttachments { WorkorderId = workOrderId, @@ -348,6 +350,42 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); } + /// + /// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the + /// stored attachment rows. Legacy Before/After column slots replace in place and are + /// not counted. Documents have no per-work-order count limit. + /// + private async Task EnsureWithinMediaCountsAsync( + int workOrderId, + string fileUrl, + CancellationToken cancellationToken) + { + var kind = WorkOrderMediaContract.ResolveKind(null, fileUrl); + if (kind != WorkOrderMediaContract.UploadKind.Photo + && kind != WorkOrderMediaContract.UploadKind.Video) + return; + + var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken); + var sameKindCount = urls.Count(url => + WorkOrderMediaContract.ResolveKind(null, url) == kind); + + if (kind == WorkOrderMediaContract.UploadKind.Photo + && sameKindCount >= WorkOrderMediaContract.MaxPhotosPerWorkOrder) + { + throw new WorkOrderBoardValidationException( + "MediaCountExceeded", + "A work order can have at most 10 photos."); + } + + if (kind == WorkOrderMediaContract.UploadKind.Video + && sameKindCount >= WorkOrderMediaContract.MaxVideosPerWorkOrder) + { + throw new WorkOrderBoardValidationException( + "MediaCountExceeded", + "A work order can have at most 3 videos."); + } + } + /// /// Account filter for data queries. Null means org-wide (skip ApplyAccountScope). /// Call only after EnsureCan* has verified scope is not Missing. diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index cd24883..d5771a9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1309,6 +1309,289 @@ public class WorkOrderMediaServiceTests Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); } + [Fact] + public async Task AddMedia_EleventhPhoto_ThrowsMediaCountExceeded() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 10; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://example.com/photo-10.jpg", + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 10 photos.", ex.Message); + Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); + } + + [Fact] + public async Task AddMedia_FourthVideo_CountsStoredPhoneFileUrls() + { + // Same URL shape FileStorageAdapter.SaveFileAsync returns for an iPhone upload. + static string StoredUrl(string name) => + $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}"; + + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.mov", "clip.MP4" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = StoredUrl(name), + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + StoredUrl("IMG_0004.MOV"), + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 3 videos.", ex.Message); + } + + [Fact] + public async Task AddMedia_CrossAccount_FullWorkOrder_ThrowsNotFoundNotCount() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + AccountId = 20, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 10; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://example.com/photo-10.jpg", + AuthenticatedUser(accountId: 10), + "actor-1")); + + // Another account must not learn the work order exists or how full it is. + Assert.Equal("NotFound", ex.Code); + Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); + } + + [Fact] + public async Task AddMedia_TenthPhoto_Succeeds() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 9; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo-9.jpg", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + } + + [Fact] + public async Task AddMedia_FourthVideo_ThrowsMediaCountExceeded() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-a.mp4", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-b.mov", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-c.mp4", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://example.com/clip-d.mov", + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 3 videos.", ex.Message); + } + + [Fact] + public async Task AddMedia_ThirdVideo_Succeeds() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-a.mp4", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-b.mov", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/clip-c.mp4", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + } + + [Fact] + public async Task AddMedia_DeletedPhoto_DoesNotConsumePhotoCount() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 10; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra, + IsDeleted = i == 0 + }); + } + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo-new.jpg", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + } + + [Fact] + public async Task AddMedia_DocumentsDoNotConsumePhotoOrVideoCounts() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 5; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/report-{i}.pdf", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var photo = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo.jpg", + AuthenticatedUser(), + "actor-1"); + var video = await service.AddMediaAsync( + 1, + null, + "https://example.com/clip.mp4", + AuthenticatedUser(), + "actor-1"); + + Assert.True(photo.Id > 0); + Assert.True(video.Id > 0); + } + [Fact] public async Task DeleteMedia_Technician_ThrowsForbidden() { diff --git a/scripts/check_app_terraform_isolation.py b/scripts/check_app_terraform_isolation.py index 9f87212..11db9a8 100644 --- a/scripts/check_app_terraform_isolation.py +++ b/scripts/check_app_terraform_isolation.py @@ -26,7 +26,7 @@ def is_app_path(path: str) -> bool: normalized = path.replace("\\", "/") if normalized in APP_SCRIPT_NAMES: return True - if normalized.startswith(".ebextensions/"): + if normalized.startswith((".ebextensions/", ".platform/")): return True return normalized.endswith(APP_SUFFIXES) diff --git a/scripts/package-elastic-beanstalk.sh b/scripts/package-elastic-beanstalk.sh index bef20cf..5d42d50 100755 --- a/scripts/package-elastic-beanstalk.sh +++ b/scripts/package-elastic-beanstalk.sh @@ -8,6 +8,7 @@ # ./ published Api.SeaHavenIndustries (self-contained, linux-x64) # ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x) # ./.ebextensions/* leader-only migration container command +# ./.platform/nginx/conf.d/* nginx proxy overrides (upload body size) # # The bundle reads ConnectionStrings__DefaultConnection from the runtime # environment (Elastic Beanstalk property). No connection string or secret is @@ -137,6 +138,10 @@ log "copy .ebextensions into bundle root" mkdir -p "$STAGING_DIR/.ebextensions" cp -R .ebextensions/. "$STAGING_DIR/.ebextensions/" +log "copy .platform (nginx proxy overrides) into bundle root" +mkdir -p "$STAGING_DIR/.platform" +cp -R .platform/. "$STAGING_DIR/.platform/" + log "verify no committed secret placeholders survived publish" if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}' "$STAGING_DIR" 2>/dev/null; then die "potential secret detected in publish output; refusing to package." diff --git a/scripts/test_check_app_terraform_isolation.py b/scripts/test_check_app_terraform_isolation.py index 94899a6..b13e0e7 100644 --- a/scripts/test_check_app_terraform_isolation.py +++ b/scripts/test_check_app_terraform_isolation.py @@ -54,6 +54,17 @@ class IsolationTests(unittest.TestCase): self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"]) self.assertTrue(any(path.endswith(".cs") for path in app_files)) + def test_platform_proxy_config_is_app_side(self) -> None: + violation = isolation_violation( + [ + "terraform/live/dev/main.tf", + ".platform/nginx/conf.d/01_upload_body_size.conf", + ] + ) + self.assertIsNotNone(violation) + _, app_files = violation or ([], []) + self.assertEqual(app_files, [".platform/nginx/conf.d/01_upload_body_size.conf"]) + if __name__ == "__main__": unittest.main() diff --git a/scripts/validate-elastic-beanstalk-bundle.sh b/scripts/validate-elastic-beanstalk-bundle.sh index 139eb6b..1d88367 100755 --- a/scripts/validate-elastic-beanstalk-bundle.sh +++ b/scripts/validate-elastic-beanstalk-bundle.sh @@ -15,13 +15,15 @@ die() { contents_file="$(mktemp)" webhook_file="$(mktemp)" -trap 'rm -f "$contents_file" "$webhook_file"' EXIT +nginx_file="$(mktemp)" +trap 'rm -f "$contents_file" "$webhook_file" "$nginx_file"' EXIT unzip -tq "$BUNDLE" unzip -Z1 "$BUNDLE" > "$contents_file" grep -Fxq "efbundle" "$contents_file" grep -Fxq ".ebextensions/01_migrations.config" "$contents_file" grep -Fxq ".ebextensions/02_webhook_config.config" "$contents_file" +grep -Fxq ".platform/nginx/conf.d/01_upload_body_size.conf" "$contents_file" unzip -p "$BUNDLE" .ebextensions/02_webhook_config.config > "$webhook_file" grep -Fxq ' WorkOrderWebhook__Enabled: "true"' "$webhook_file" @@ -30,5 +32,9 @@ grep -Fxq \ ' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \ "$webhook_file" +# Without this override the platform nginx caps request bodies at 1 MB (SH-383). +unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file" +grep -Fxq 'client_max_body_size 120M;' "$nginx_file" + printf 'PASS: Elastic Beanstalk bundle contract (%s bytes)\n' \ "$(wc -c < "$BUNDLE" | tr -d ' ')" From 07bc81cc5270a963edc313bf31e92cdbffd8d91c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:18:00 -0300 Subject: [PATCH 02/10] fix(media): size uploads by the validated content type A misleading file name could move a file into a larger size class: a real PDF or JPEG named .mp4/.mov got the 100 MB video cap on the vendor portal, and a .jpg declared with a foreign video type got it on the media endpoint. Classify by the same resolved type the signature check validates; the extension only decides when no allowlisted type is known. --- .../VendorPortalDocumentTests.cs | 22 +++++++ .../WorkOrderMediaControllerTests.cs | 21 ++++++ .../Controllers/WorkOrderMediaController.cs | 3 +- .../Helpers/WorkOrderMediaContract.cs | 65 ++++++++++++------- .../Helpers/WorkOrderMediaFileRules.cs | 19 ++++-- .../Implementation/VendorPortalService.cs | 3 +- 6 files changed, 103 insertions(+), 30 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 83d9d1d..4a6ac0d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -431,6 +431,28 @@ public sealed class VendorPortalDocumentTests : IDisposable (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg"); } + [Theory] + // Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must + // follow the validated type, not the name, or the document/photo caps are bypassed. + [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46 })] + [InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })] + public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap( + string fileName, + string contentType, + int size, + byte[] header) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(size, header), fileName, contentType)); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 32c70df..c990b2e 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -91,6 +91,27 @@ public class WorkOrderMediaControllerTests storage.VerifyNoOtherCalls(); } + [Fact] + public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto() + { + // A .jpg with a foreign video type resolves to image/jpeg for validation, so it must + // also be sized as a photo, not given the 100 MB video allowance. + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(60_000_000); + file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); + file.SetupGet(candidate => candidate.ContentType).Returns("video/3gpp"); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Photos must be 10 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + [Fact] public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 4f854ef..b19efb9 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -89,8 +89,9 @@ namespace Api.SeaHavenIndustries.Controllers try { // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). + // Classify by the same resolved type EnsureAllowed validates against. var sizeMessage = WorkOrderMediaContract.ValidateSize( - file.ContentType, file.FileName, file.Length); + WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length); if (sizeMessage != null) { throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index 243dcc7..f36fcf9 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -33,34 +33,51 @@ namespace SeaHaven.Services.Helpers Unknown } + private static readonly Dictionary KindByContentType = + new(StringComparer.OrdinalIgnoreCase) + { + ["image/jpeg"] = UploadKind.Photo, + ["image/jpg"] = UploadKind.Photo, + ["image/png"] = UploadKind.Photo, + ["image/heic"] = UploadKind.Photo, + ["video/mp4"] = UploadKind.Video, + ["video/quicktime"] = UploadKind.Video, + ["application/pdf"] = UploadKind.Document, + ["application/msword"] = UploadKind.Document, + ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] = + UploadKind.Document, + }; + + private static readonly Dictionary KindByExtension = + new(StringComparer.OrdinalIgnoreCase) + { + [".jpg"] = UploadKind.Photo, + [".jpeg"] = UploadKind.Photo, + [".png"] = UploadKind.Photo, + [".heic"] = UploadKind.Photo, + [".mp4"] = UploadKind.Video, + [".mov"] = UploadKind.Video, + [".pdf"] = UploadKind.Document, + [".doc"] = UploadKind.Document, + [".docx"] = UploadKind.Document, + }; + + /// + /// Classifies an upload. Pass the validated (resolved) content type: it decides whenever + /// it is an allowlisted type, so a misleading file name cannot move a file into a larger + /// size class. The extension only decides when no allowlisted type is known (for example + /// counting stored attachment URLs). + /// public static UploadKind ResolveKind(string? contentType, string? fileName) { var type = (contentType ?? string.Empty).Trim(); + if (KindByContentType.TryGetValue(type, out var byType)) + return byType; + var extension = Path.GetExtension(fileName ?? string.Empty); - - if (type.StartsWith("video/", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".mov", StringComparison.OrdinalIgnoreCase)) - return UploadKind.Video; - - if (type.StartsWith("image/", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".png", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".heic", StringComparison.OrdinalIgnoreCase)) - return UploadKind.Photo; - - if (type.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) - || type.Equals("application/msword", StringComparison.OrdinalIgnoreCase) - || type.Equals( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - StringComparison.OrdinalIgnoreCase) - || extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) - return UploadKind.Document; - - return UploadKind.Unknown; + return KindByExtension.TryGetValue(extension, out var byExtension) + ? byExtension + : UploadKind.Unknown; } /// Stable, generic per-kind size message; never echoes file metadata. diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index ee02d9b..c438759 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -67,6 +67,19 @@ namespace SeaHaven.Services.Helpers return contentType; } + /// + /// The canonical content type validates the file as, or null when + /// no allowlisted type applies. Size classification must use this same type so a declared + /// type or a misleading extension cannot move a file into a larger size class. + /// + public static string? ResolveUploadContentType(IFormFile file) + { + var declaredType = (file.ContentType ?? string.Empty).Trim(); + var extension = Path.GetExtension(file.FileName ?? string.Empty); + var resolvedType = ResolveContentType(declaredType, extension); + return resolvedType == null ? null : CanonicalContentType(resolvedType); + } + public static bool IsAllowed( IFormFile file, WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) @@ -74,13 +87,11 @@ namespace SeaHaven.Services.Helpers if (file == null || file.Length <= 0) return false; - var declaredType = (file.ContentType ?? string.Empty).Trim(); var extension = Path.GetExtension(file.FileName ?? string.Empty); - var resolvedType = ResolveContentType(declaredType, extension); - if (resolvedType == null) + var contentType = ResolveUploadContentType(file); + if (contentType == null) return false; - var contentType = CanonicalContentType(resolvedType); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; if (IsDocument(contentType) && resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta) diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index cbe8192..0893fbb 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -856,7 +856,8 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted."); } - var kind = WorkOrderMediaContract.ResolveKind(contentType, file.FileName); + // Classify by the resolved type the signature check validates, never by the file name. + var kind = WorkOrderMediaContract.ResolveKind(contentType, fileName: null); if (kind == WorkOrderMediaContract.UploadKind.Photo && file.Length > WorkOrderMediaContract.MaxPhotoBytes) { From 16845a55f3ff7ddc04aab4ab3ba23d06a743f184 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:18:49 -0300 Subject: [PATCH 03/10] test(vendor-portal): use a valid PDF signature in the size-class regression --- Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 4a6ac0d..132f538 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -434,7 +434,7 @@ public sealed class VendorPortalDocumentTests : IDisposable [Theory] // Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must // follow the validated type, not the name, or the document/photo caps are bypassed. - [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46 })] + [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D })] [InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })] public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap( string fileName, From e15de6e90bde6d1e02dd87fbace6c0376f7d43ac Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:27:01 -0300 Subject: [PATCH 04/10] fix(media): enforce the per-work-order photo/video limit across both surfaces The 10-photo / 3-video limit only counted dispatcher attachments, so vendor portal uploads could push a work order past it (and vice versa). Count the work order's current vendor documents alongside its attachments on both the dispatcher media endpoint and the vendor portal. A new completion version does not count the version it replaces. --- .../VendorPortalDocumentTests.cs | 56 +++++++++++++++++++ .../VendorDocumentDataService.cs | 33 +++++++++++ .../WorkOrderMediaDataService.cs | 18 ++++++ .../Interfaces/IVendorDocumentDataService.cs | 13 +++++ .../Interfaces/IWorkOrderMediaDataService.cs | 8 +++ .../Helpers/WorkOrderMediaContract.cs | 23 ++++++++ .../Implementation/VendorPortalService.cs | 20 +++++++ .../Implementation/WorkOrderMediaService.cs | 27 +++------ .../WorkOrderPhase6Tests.cs | 51 +++++++++++++++++ 9 files changed, 229 insertions(+), 20 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 132f538..2ef7eca 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -453,6 +453,62 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().BeEmpty(); } + [Fact] + public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "clip.mp4" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + await context.SaveChangesAsync(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "video/quicktime")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_NewVersionDoesNotCountTheVideoItReplaces() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + ContentType = "video/mp4", + StoredFileName = "v1.mp4", + Version = 1, + Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip-v2.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(2); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs index a952f45..d2a6890 100644 --- a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs @@ -17,6 +17,39 @@ namespace SeaHaven.DataServices.Implementation // supporting evidence never participates in completion versioning or replacement. private const string CompletionPurpose = "Completion"; + public async Task> ListActiveContentTypesForWorkOrderAsync( + int workOrderId, + int? excludingDocumentId, + CancellationToken cancellationToken) + { + // Uplift evidence also records the latest completion id in ReplacesDocumentId, so only + // a newer completion version supersedes a document. + return await _context.VendorCompletionDocuments + .AsNoTracking() + .Where(document => document.WorkOrderId == workOrderId + && (document.IsDeleted == null || document.IsDeleted == false) + && (excludingDocumentId == null || document.Id != excludingDocumentId) + && !_context.VendorCompletionDocuments.Any(newer => + newer.ReplacesDocumentId == document.Id + && newer.Purpose == CompletionPurpose + && (newer.IsDeleted == null || newer.IsDeleted == false))) + .Select(document => document.ContentType) + .ToListAsync(cancellationToken); + } + + public async Task> ListActiveWorkOrderAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken) + { + return await _context.workOrderAttachments + .AsNoTracking() + .Where(attachment => attachment.WorkorderId == workOrderId + && attachment.IsDeleted != true + && attachment.Attachments != null) + .Select(attachment => attachment.Attachments!) + .ToListAsync(cancellationToken); + } + public Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken) { return _context.VendorCompletionDocuments diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index 9ad9dbe..2d226e6 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -68,6 +68,24 @@ namespace SeaHaven.DataServices.Implementation return urls; } + public async Task> ListActiveVendorMediaContentTypesAsync( + int workOrderId, + CancellationToken cancellationToken) + { + // Uplift evidence also records the latest completion id in ReplacesDocumentId, so only + // a newer completion version supersedes a document. + return await _context.VendorCompletionDocuments + .AsNoTracking() + .Where(document => document.WorkOrderId == workOrderId + && (document.IsDeleted == null || document.IsDeleted == false) + && !_context.VendorCompletionDocuments.Any(newer => + newer.ReplacesDocumentId == document.Id + && newer.Purpose == "Completion" + && (newer.IsDeleted == null || newer.IsDeleted == false))) + .Select(document => document.ContentType) + .ToListAsync(cancellationToken); + } + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index b56948c..6986ef2 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -9,6 +9,19 @@ namespace SeaHaven.DataServices.Interfaces Task GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); Task> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken); Task GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); + /// + /// Content types of the work order's current vendor documents (not deleted, not replaced by a + /// newer completion version), optionally excluding one being replaced. SH-116 photo/video counts. + /// + Task> ListActiveContentTypesForWorkOrderAsync( + int workOrderId, + int? excludingDocumentId, + CancellationToken cancellationToken); + + /// Stored URLs of the work order's non-deleted dispatcher attachments (SH-116 counts). + Task> ListActiveWorkOrderAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken); Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index 30d0005..caf6071 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -27,6 +27,14 @@ namespace SeaHaven.DataServices.Interfaces Task> ListActiveAttachmentUrlsAsync( int workOrderId, CancellationToken cancellationToken); + + /// + /// Content types of the work order's current vendor-portal documents (not deleted, not + /// replaced by a newer completion version). SH-116 counts span both upload surfaces. + /// + Task> ListActiveVendorMediaContentTypesAsync( + int workOrderId, + CancellationToken cancellationToken); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); void MarkWorkOrderModified(WorkOrder workOrder); Task SaveAsync(CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index f36fcf9..5ba60e3 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -80,6 +80,29 @@ namespace SeaHaven.Services.Helpers : UploadKind.Unknown; } + /// + /// Per-work-order count check across both upload surfaces: dispatcher attachments + /// (classified by stored URL) and vendor-portal documents (classified by validated + /// content type). Returns the stable rejection message, or null when there is room. + /// + public static string? ValidateCount( + UploadKind kind, + IEnumerable attachmentUrls, + IEnumerable vendorContentTypes) + { + if (kind != UploadKind.Photo && kind != UploadKind.Video) + return null; + + var count = attachmentUrls.Count(url => ResolveKind(null, url) == kind) + + vendorContentTypes.Count(type => ResolveKind(type, null) == kind); + + if (kind == UploadKind.Photo && count >= MaxPhotosPerWorkOrder) + return "A work order can have at most 10 photos."; + if (kind == UploadKind.Video && count >= MaxVideosPerWorkOrder) + return "A work order can have at most 3 videos."; + return null; + } + /// Stable, generic per-kind size message; never echoes file metadata. public static string? ValidateSize(long length, UploadKind kind) { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 0893fbb..0aa4353 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -926,6 +926,26 @@ namespace SeaHaven.Services.Implementation "The completion document could not be replaced."); } } + + // SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and + // vendor surfaces. A new completion version replaces its predecessor, so that one + // does not count against the upload that supersedes it. + if (dispatch.WorkOrderId is int workOrderId) + { + var excluded = resolvedPurpose == VendorDocumentPurpose.Completion + ? replacedDocument?.Id + : null; + var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, excluded, cancellationToken); + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, cancellationToken); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes); + if (countMessage != null) + { + throw new InvalidOperationException(countMessage); + } + } + var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index dccbffd..10ad8ff 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -352,8 +352,9 @@ namespace SeaHaven.Services.Implementation /// /// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the - /// stored attachment rows. Legacy Before/After column slots replace in place and are - /// not counted. Documents have no per-work-order count limit. + /// stored attachment rows plus the work order's current vendor-portal documents. Legacy + /// Before/After column slots replace in place and are not counted. Documents have no + /// per-work-order count limit. /// private async Task EnsureWithinMediaCountsAsync( int workOrderId, @@ -366,24 +367,10 @@ namespace SeaHaven.Services.Implementation return; var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken); - var sameKindCount = urls.Count(url => - WorkOrderMediaContract.ResolveKind(null, url) == kind); - - if (kind == WorkOrderMediaContract.UploadKind.Photo - && sameKindCount >= WorkOrderMediaContract.MaxPhotosPerWorkOrder) - { - throw new WorkOrderBoardValidationException( - "MediaCountExceeded", - "A work order can have at most 10 photos."); - } - - if (kind == WorkOrderMediaContract.UploadKind.Video - && sameKindCount >= WorkOrderMediaContract.MaxVideosPerWorkOrder) - { - throw new WorkOrderBoardValidationException( - "MediaCountExceeded", - "A work order can have at most 3 videos."); - } + var vendorTypes = await _mediaData.ListActiveVendorMediaContentTypesAsync(workOrderId, cancellationToken); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, urls, vendorTypes); + if (countMessage != null) + throw new WorkOrderBoardValidationException("MediaCountExceeded", countMessage); } /// diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index d5771a9..b1fe06f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1380,6 +1380,57 @@ public class WorkOrderMediaServiceTests Assert.Equal("A work order can have at most 3 videos.", ex.Message); } + [Fact] + public async Task AddMedia_FourthVideo_CountsVendorPortalVideos() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + Category = WorkOrderMediaCategory.Extra + }); + } + // Vendor v1 video was replaced by v2 (also a video): only v2 is current. + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 50, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 1, + ContentType = "video/mp4", Purpose = "Completion" + }); + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 51, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 2, + ContentType = "video/quicktime", Purpose = "Completion", ReplacesDocumentId = 50 + }); + // Another work order's vendor video never counts here. + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 60, WorkOrderId = 2, DispatchId = 8, VendorId = 3, Version = 1, + ContentType = "video/mp4", Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://api.example.com/Assets/Documents/x_IMG_0004.MOV", + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 3 videos.", ex.Message); + Assert.Equal(2, context.workOrderAttachments.Count()); + } + [Fact] public async Task AddMedia_CrossAccount_FullWorkOrder_ThrowsNotFoundNotCount() { From a8414cd4fa258fe608691ce35cacc96001275ccc Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:29:51 -0300 Subject: [PATCH 05/10] style(tests): format vendor quota test initializers --- .../WorkOrderPhase6Tests.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index b1fe06f..3c299ba 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1402,19 +1402,35 @@ public class WorkOrderMediaServiceTests // Vendor v1 video was replaced by v2 (also a video): only v2 is current. context.VendorCompletionDocuments.Add(new VendorCompletionDocument { - Id = 50, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 1, - ContentType = "video/mp4", Purpose = "Completion" + Id = 50, + WorkOrderId = 1, + DispatchId = 7, + VendorId = 3, + Version = 1, + ContentType = "video/mp4", + Purpose = "Completion" }); context.VendorCompletionDocuments.Add(new VendorCompletionDocument { - Id = 51, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 2, - ContentType = "video/quicktime", Purpose = "Completion", ReplacesDocumentId = 50 + Id = 51, + WorkOrderId = 1, + DispatchId = 7, + VendorId = 3, + Version = 2, + ContentType = "video/quicktime", + Purpose = "Completion", + ReplacesDocumentId = 50 }); // Another work order's vendor video never counts here. context.VendorCompletionDocuments.Add(new VendorCompletionDocument { - Id = 60, WorkOrderId = 2, DispatchId = 8, VendorId = 3, Version = 1, - ContentType = "video/mp4", Purpose = "Completion" + Id = 60, + WorkOrderId = 2, + DispatchId = 8, + VendorId = 3, + Version = 1, + ContentType = "video/mp4", + Purpose = "Completion" }); await context.SaveChangesAsync(); From fd59a3da1353988f52e1376486abea89db01de7d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:38:00 -0300 Subject: [PATCH 06/10] fix(media): enforce the 90-second video limit on the server Read the duration from the MP4/MOV movie header (moov/mvhd) on both the dispatcher media endpoint and the vendor portal completion upload, so a direct request cannot bypass the browser check. Unreadable metadata still never blocks an upload. --- .../VendorPortalDocumentTests.cs | 44 ++++++ .../WorkOrderMediaControllerTests.cs | 54 ++++++++ .../Controllers/WorkOrderMediaController.cs | 9 ++ .../Helpers/VideoDurationProbe.cs | 129 ++++++++++++++++++ .../Helpers/WorkOrderMediaContract.cs | 18 ++- .../Implementation/VendorPortalService.cs | 10 ++ .../VideoDurationProbeTests.cs | 112 +++++++++++++++ 7 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 SeaHaven.Services/Helpers/VideoDurationProbe.cs create mode 100644 SeaHavenIndustries.Tests/VideoDurationProbeTests.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 2ef7eca..1258703 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -453,6 +453,50 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().BeEmpty(); } + /// ftyp + mdat + moov/mvhd (moov last, as phones write it). + private static byte[] PhoneVideoBytes(uint durationSeconds) + { + static byte[] Box(string type, byte[] body) + { + var box = new byte[8 + body.Length]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + var mvhd = new byte[20]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 600); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600); + return Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0")) + .Concat(Box("mdat", new byte[4096])) + .Concat(Box("moov", Box("mvhd", mvhd))) + .ToArray(); + } + + [Theory] + [InlineData(95u, false)] + [InlineData(89u, true)] + public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime")); + + if (accepted) + { + result.Should().BeOfType(); + } + else + { + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + } + [Fact] public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index c990b2e..8234c92 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -182,6 +182,60 @@ public class WorkOrderMediaControllerTests Assert.Equal(5, Assert.IsType(ok.Value).Id); } + [Fact] + public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity() + { + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("VideoTooLong", error.Code); + Assert.Equal("Videos must be 90 seconds or shorter.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", ""); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + /// ftyp + mdat + moov/mvhd (moov last, as phones write it). + private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType) + { + static byte[] Box(string type, byte[] body) + { + var box = new byte[8 + body.Length]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + var mvhd = new byte[20]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000); + var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0")) + .Concat(Box("mdat", new byte[4096])) + .Concat(Box("moov", Box("mvhd", mvhd))) + .ToArray(); + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + [Fact] public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted() { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index b19efb9..e795f3c 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -98,6 +98,15 @@ namespace Api.SeaHavenIndustries.Controllers } WorkOrderMediaFileRules.EnsureAllowed(file, category); + if (WorkOrderMediaContract.ResolveKind( + WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName) + == WorkOrderMediaContract.UploadKind.Video) + { + using var content = file.OpenReadStream(); + var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content); + if (durationMessage != null) + throw new WorkOrderBoardValidationException("VideoTooLong", durationMessage); + } var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category); diff --git a/SeaHaven.Services/Helpers/VideoDurationProbe.cs b/SeaHaven.Services/Helpers/VideoDurationProbe.cs new file mode 100644 index 0000000..8355c46 --- /dev/null +++ b/SeaHaven.Services/Helpers/VideoDurationProbe.cs @@ -0,0 +1,129 @@ +using System.Buffers.Binary; +using System.Text; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Reads a video's duration from the ISO base media (MP4 / QuickTime) movie header: + /// top-level moov box → mvhd timescale and duration. It seeks over box + /// headers only (the moov box may sit after a large mdat), never decodes + /// media and never allocates more than a few bytes. Returns null whenever the structure + /// cannot be read, so callers can let unreadable files through (SH-116: unreadable + /// metadata never blocks an upload). + /// + public static class VideoDurationProbe + { + private const int MaxBoxesPerLevel = 1024; + + public static double? TryReadDurationSeconds(Stream? stream) + { + if (stream == null || !stream.CanSeek || !stream.CanRead) + return null; + + try + { + var moov = FindBox(stream, 0, stream.Length, "moov"); + if (moov == null) + return null; + + var mvhd = FindBox(stream, moov.Value.BodyStart, moov.Value.End, "mvhd"); + return mvhd == null ? null : ReadMovieHeaderSeconds(stream, mvhd.Value); + } + catch (IOException) + { + return null; + } + } + + private readonly record struct Box(long BodyStart, long End); + + private static Box? FindBox(Stream stream, long start, long end, string type) + { + var header = new byte[8]; + var position = start; + for (var i = 0; i < MaxBoxesPerLevel && position + 8 <= end; i++) + { + stream.Position = position; + if (!ReadExactly(stream, header, 8)) + return null; + + long size = BinaryPrimitives.ReadUInt32BigEndian(header); + var boxType = Encoding.ASCII.GetString(header, 4, 4); + var headerLength = 8; + if (size == 1) + { + // 64-bit "largesize" follows the type. + var large = new byte[8]; + if (!ReadExactly(stream, large, 8)) + return null; + var largeSize = BinaryPrimitives.ReadUInt64BigEndian(large); + if (largeSize > long.MaxValue) + return null; + size = (long)largeSize; + headerLength = 16; + } + else if (size == 0) + { + size = end - position; + } + + if (size < headerLength || position + size > end) + return null; + + if (boxType == type) + return new Box(position + headerLength, position + size); + + position += size; + } + + return null; + } + + private static double? ReadMovieHeaderSeconds(Stream stream, Box mvhd) + { + // version(1) flags(3), then v0: creation(4) modification(4) timescale(4) duration(4) + // v1: creation(8) modification(8) timescale(4) duration(8) + var body = new byte[32]; + stream.Position = mvhd.BodyStart; + var available = (int)Math.Min(body.Length, mvhd.End - mvhd.BodyStart); + if (available < 20 || !ReadExactly(stream, body, available)) + return null; + + uint timescale; + ulong duration; + if (body[0] == 0) + { + timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(12, 4)); + duration = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(16, 4)); + } + else if (body[0] == 1 && available >= 32) + { + timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(20, 4)); + duration = BinaryPrimitives.ReadUInt64BigEndian(body.AsSpan(24, 8)); + } + else + { + return null; + } + + // All-ones duration means "unknown" in the spec. + if (timescale == 0 || duration == uint.MaxValue || duration == ulong.MaxValue) + return null; + + return (double)duration / timescale; + } + + private static bool ReadExactly(Stream stream, byte[] buffer, int count) + { + var read = 0; + while (read < count) + { + var n = stream.Read(buffer, read, count - read); + if (n <= 0) + return false; + read += n; + } + return true; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index 5ba60e3..a0bfa56 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -4,9 +4,9 @@ namespace SeaHaven.Services.Helpers /// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), /// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work /// order, across the dispatcher media modal, the completion-doc media tab and the vendor - /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is only - /// checkable client-side (the server cannot probe it cheaply), so it is enforced in the - /// browser and documented here as part of the same contract. + /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read + /// from the MP4/MOV movie header (); the browser pre-checks + /// it and the server enforces it, and unreadable metadata never blocks an upload. /// public static class WorkOrderMediaContract { @@ -103,6 +103,18 @@ namespace SeaHaven.Services.Helpers return null; } + /// + /// 90-second video limit, read from the MP4/QuickTime movie header. A video whose + /// duration cannot be read is not blocked. Returns the stable message or null. + /// + public static string? ValidateVideoDuration(Stream? content) + { + var seconds = VideoDurationProbe.TryReadDurationSeconds(content); + return seconds > MaxVideoDurationSeconds + ? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter." + : null; + } + /// Stable, generic per-kind size message; never echoes file metadata. public static string? ValidateSize(long length, UploadKind kind) { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 0aa4353..c1307dc 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -900,6 +900,16 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); } + if (kind == WorkOrderMediaContract.UploadKind.Video) + { + using var content = new MemoryStream(bytes, writable: false); + var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content); + if (durationMessage != null) + { + throw new InvalidOperationException(durationMessage); + } + } + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); if (dispatch == null) { diff --git a/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs b/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs new file mode 100644 index 0000000..df5913d --- /dev/null +++ b/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs @@ -0,0 +1,112 @@ +using System.Buffers.Binary; +using System.Text; +using SeaHaven.Services.Helpers; + +namespace SeaHavenIndustries.Tests; + +public class VideoDurationProbeTests +{ + internal static byte[] Box(string type, params byte[][] children) + { + var body = children.SelectMany(child => child).ToArray(); + var box = new byte[8 + body.Length]; + BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + internal static byte[] MovieHeader(uint timescale, ulong duration, bool version1 = false) + { + var body = new byte[version1 ? 32 : 20]; + body[0] = version1 ? (byte)1 : (byte)0; + if (version1) + { + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(20), timescale); + BinaryPrimitives.WriteUInt64BigEndian(body.AsSpan(24), duration); + } + else + { + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(12), timescale); + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(16), (uint)duration); + } + return Box("mvhd", body); + } + + /// A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). + internal static byte[] Mp4(uint timescale, ulong duration, bool version1 = false, int mediaBytes = 4096) + { + var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]); + var mdat = Box("mdat", new byte[mediaBytes]); + var moov = Box("moov", MovieHeader(timescale, duration, version1)); + return ftyp.Concat(mdat).Concat(moov).ToArray(); + } + + [Fact] + public void ReadsDurationFromMoovAfterMediaData() + { + Assert.Equal(95.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(Mp4(600, 57_000)))); + } + + [Fact] + public void ReadsVersionOneMovieHeader() + { + Assert.Equal(30.5, VideoDurationProbe.TryReadDurationSeconds( + new MemoryStream(Mp4(1000, 30_500, version1: true)))); + } + + [Fact] + public void FollowsSixtyFourBitBoxSizes() + { + var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("qt "), new byte[4]); + var mdatBody = new byte[512]; + var mdat = new byte[16 + mdatBody.Length]; + BinaryPrimitives.WriteUInt32BigEndian(mdat, 1); + Encoding.ASCII.GetBytes("mdat").CopyTo(mdat, 4); + BinaryPrimitives.WriteUInt64BigEndian(mdat.AsSpan(8), (ulong)mdat.Length); + var moov = Box("moov", MovieHeader(90_000, 90_000UL * 120)); + var file = ftyp.Concat(mdat).Concat(moov).ToArray(); + + Assert.Equal(120.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(file))); + } + + [Theory] + [InlineData("no-moov")] + [InlineData("truncated")] + [InlineData("zero-timescale")] + [InlineData("oversized-box")] + public void UnreadableStructureReturnsNull(string shape) + { + var bytes = shape switch + { + "no-moov" => Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]).Concat(Box("mdat", new byte[64])).ToArray(), + "truncated" => Mp4(600, 57_000)[..^10], + "zero-timescale" => Mp4(0, 57_000), + _ => Box("ftyp", new byte[8]).Concat(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, (byte)'m', (byte)'o', (byte)'o', (byte)'v' }).ToArray(), + }; + + Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(bytes))); + } + + [Fact] + public void NonSeekableStreamReturnsNull() + { + Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new NonSeekableStream(Mp4(600, 57_000)))); + } + + [Theory] + [InlineData(90.0, true)] + [InlineData(90.5, false)] + public void ContractAllowsUpToNinetySeconds(double seconds, bool allowed) + { + var message = WorkOrderMediaContract.ValidateVideoDuration( + new MemoryStream(Mp4(1000, (ulong)(seconds * 1000)))); + + Assert.Equal(allowed ? null : "Videos must be 90 seconds or shorter.", message); + } + + private sealed class NonSeekableStream(byte[] bytes) : MemoryStream(bytes) + { + public override bool CanSeek => false; + } +} From eecc2a61bd95284ccc6afd3f77176e0905639e6c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:24:07 -0300 Subject: [PATCH 07/10] feat(vendor-portal): report work-order media counts on the dispatch detail Adds MediaCounts (limits, current photos/videos, and the counts a new completion version would see) so the portal can refuse an 11th photo or 4th video before uploading it. Uses the same count and replacement rule the upload check enforces. --- .../VendorPortalDocumentTests.cs | 40 +++++++++++++++++++ .../DTOs/VendorPortalServiceDTOs.cs | 20 ++++++++++ .../Helpers/WorkOrderMediaContract.cs | 21 ++++++++-- .../Implementation/VendorPortalService.cs | 29 ++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 1258703..2eefb5c 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -553,6 +553,46 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(2); } + [Fact] + public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + ContentType = "video/mp4", + StoredFileName = "v1.mp4", + Version = 1, + Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO + { + MaxPhotos = 10, + MaxVideos = 3, + Photos = 1, + Videos = 3, + CompletionPhotos = 1, + CompletionVideos = 2, + }); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index afdaf18..ace32c8 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -134,6 +134,26 @@ namespace SeaHaven.Services.DTOs public IEnumerable Comments { get; set; } = Enumerable.Empty(); public IEnumerable UpliftRequests { get; set; } = Enumerable.Empty(); public IEnumerable Documents { get; set; } = Enumerable.Empty(); + public PortalMediaCountsDTO? MediaCounts { get; set; } + } + + /// + /// SH-116 per-work-order photo/video usage, so the portal can pre-check an upload + /// before sending it. The server still enforces the limit on upload. + /// + public class PortalMediaCountsDTO + { + public int MaxPhotos { get; set; } + public int MaxVideos { get; set; } + /// Photos/videos on the work order, counted for evidence uploads. + public int Photos { get; set; } + public int Videos { get; set; } + /// + /// Photos/videos counted for a new completion version, which replaces the dispatch's + /// latest document and so does not count it. + /// + public int CompletionPhotos { get; set; } + public int CompletionVideos { get; set; } } public class VendorPortalDocumentDTO diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index a0bfa56..34f8862 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -93,16 +93,29 @@ namespace SeaHaven.Services.Helpers if (kind != UploadKind.Photo && kind != UploadKind.Video) return null; - var count = attachmentUrls.Count(url => ResolveKind(null, url) == kind) - + vendorContentTypes.Count(type => ResolveKind(type, null) == kind); + var (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes); - if (kind == UploadKind.Photo && count >= MaxPhotosPerWorkOrder) + if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder) return "A work order can have at most 10 photos."; - if (kind == UploadKind.Video && count >= MaxVideosPerWorkOrder) + if (kind == UploadKind.Video && videos >= MaxVideosPerWorkOrder) return "A work order can have at most 3 videos."; return null; } + /// + /// Photos and videos on a work order: dispatcher attachments (kind from the stored URL) + /// plus current vendor-portal documents (kind from the validated content type). + /// + public static (int Photos, int Videos) CountKinds( + IEnumerable attachmentUrls, + IEnumerable vendorContentTypes) + { + var kinds = attachmentUrls.Select(url => ResolveKind(null, url)) + .Concat(vendorContentTypes.Select(type => ResolveKind(type, null))) + .ToList(); + return (kinds.Count(k => k == UploadKind.Photo), kinds.Count(k => k == UploadKind.Video)); + } + /// /// 90-second video limit, read from the MP4/QuickTime movie header. A video whose /// duration cannot be read is not blocked. Returns the stable message or null. diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index c1307dc..6ddcd95 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -206,8 +206,37 @@ namespace SeaHaven.Services.Implementation } } + PortalMediaCountsDTO? mediaCounts = null; + if (dispatch.WorkOrderId is int workOrderId) + { + // Same basis as the upload check: a new completion version replaces the + // dispatch's latest document, so that one is not counted for it. + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, cancellationToken); + var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, null, cancellationToken); + var latest = await _documentData.GetLatestForDispatchAsync(id, cancellationToken); + var completionTypes = latest == null + ? vendorTypes + : await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, latest.Id, cancellationToken); + var (photos, videos) = WorkOrderMediaContract.CountKinds(attachmentUrls, vendorTypes); + var (completionPhotos, completionVideos) = + WorkOrderMediaContract.CountKinds(attachmentUrls, completionTypes); + mediaCounts = new PortalMediaCountsDTO + { + MaxPhotos = WorkOrderMediaContract.MaxPhotosPerWorkOrder, + MaxVideos = WorkOrderMediaContract.MaxVideosPerWorkOrder, + Photos = photos, + Videos = videos, + CompletionPhotos = completionPhotos, + CompletionVideos = completionVideos + }; + } + return new VendorDispatchDetailDTO { + MediaCounts = mediaCounts, Id = dispatch.Id, DispatchNumber = dispatch.DispatchNumber, PONumber = dispatch.PONumber, From 3e3f0f383d0b05ff3b2b1a6188c353721baf2bdb Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:56:21 -0300 Subject: [PATCH 08/10] fix(media): serialize SH-116 media counts under the work-order lock and serve HEIC as image/heic The 10-photo / 3-video cap was a check-then-insert with no lock on both upload surfaces, so two overlapping uploads could both take the last slot. The board media upload and the vendor portal upload now run count, insert and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic to image/heic. --- .../VendorPortalDocumentTests.cs | 5 + .../WorkOrderMediaControllerTests.cs | 36 ++++- .../Implementation/VendorPortalService.cs | 126 ++++++++++-------- .../Implementation/WorkOrderMediaService.cs | 51 ++++--- .../WorkOrderCompletedSelectiveLockTests.cs | 3 +- ...orkOrderMediaConcurrencyRelationalTests.cs | 3 +- .../WorkOrderPhase6Tests.cs | 57 +++++++- 7 files changed, 200 insertions(+), 81 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 2eefb5c..e454b5f 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable var upliftData = new Mock(); upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); + upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns((int _, Func> work, CancellationToken ct) => work(ct)); var commentData = new Mock(); commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 8234c92..3aab33e 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -395,7 +395,8 @@ public class WorkOrderMediaServiceCancellationTests mediaData.Object, new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, - storage.Object); + storage.Object, + new Mock(MockBehavior.Strict).Object); var user = new ClaimsPrincipal(new ClaimsIdentity( new[] { @@ -412,4 +413,37 @@ public class WorkOrderMediaServiceCancellationTests mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once); mediaData.VerifyNoOtherCalls(); } + + [Fact] + public async Task GetMediaContent_ServesHeicAsImageHeic() + { + const string url = "https://example.test/Assets/Images/photo.heic"; + var mediaData = new Mock(MockBehavior.Strict); + mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny())) + .ReturnsAsync(new WorkOrder { Id = 1 }); + mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny())) + .ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url }); + var storage = new Mock(MockBehavior.Strict); + storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3])); + var service = new WorkOrderMediaService( + mediaData.Object, + new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, + storage.Object, + new Mock(MockBehavior.Strict).Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "actor-1"), + new Claim(ClaimTypes.Role, "Admin"), + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) + }, + "Test")); + + var result = await service.GetMediaContentAsync(1, 10, user, "actor-1"); + result.Content.Dispose(); + + Assert.Equal("image/heic", result.ContentType); + Assert.Equal("photo.heic", result.FileName); + } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 6ddcd95..b6c8b3c 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -966,69 +966,79 @@ namespace SeaHaven.Services.Implementation } } - // SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and - // vendor surfaces. A new completion version replaces its predecessor, so that one - // does not count against the upload that supersedes it. - if (dispatch.WorkOrderId is int workOrderId) - { - var excluded = resolvedPurpose == VendorDocumentPurpose.Completion - ? replacedDocument?.Id - : null; - var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( - workOrderId, excluded, cancellationToken); - var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( - workOrderId, cancellationToken); - var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes); - if (countMessage != null) - { - throw new InvalidOperationException(countMessage); - } - } - var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; - - var now = DateTime.UtcNow; - var document = new VendorCompletionDocument - { - VendorId = session.Id, - DispatchId = dispatchId, - WorkOrderId = dispatch.WorkOrderId ?? 0, - OriginalFileName = Path.GetFileName(file.FileName), - StoredFileName = storedFileName, - ContentType = contentType, - SizeBytes = bytes.Length, - ScanStatus = "Pending", - ReviewStatus = "Processing", - Version = version, - ReplacesDocumentId = replacedDocument?.Id, - Purpose = resolvedPurpose, - CreatedDate = now - }; - - await _documentData.AddAsync(document, cancellationToken); - var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence; - var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document"; - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + async Task PersistAsync(CancellationToken ct) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - DispatchId = dispatchId, - FieldName = fieldName, - OldValue = isUpliftEvidence || replacedDocument == null - ? null - : $"v{replacedDocument.Version}", - NewValue = isUpliftEvidence - ? $"evidence {document.OriginalFileName}" - : $"v{version}", - Action = isUpliftEvidence - ? "vendor_uplift_evidence_uploaded" - : "vendor_completion_document_uploaded", - ActorType = "vendor", - CreatedAt = now - }, cancellationToken); - await _documentData.SaveChangesAsync(cancellationToken); + // SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and + // vendor surfaces. A new completion version replaces its predecessor, so that one + // does not count against the upload that supersedes it. + if (dispatch.WorkOrderId is int workOrderId) + { + var excluded = resolvedPurpose == VendorDocumentPurpose.Completion + ? replacedDocument?.Id + : null; + var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, excluded, ct); + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, ct); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes); + if (countMessage != null) + { + throw new InvalidOperationException(countMessage); + } + } + + var now = DateTime.UtcNow; + var created = new VendorCompletionDocument + { + VendorId = session.Id, + DispatchId = dispatchId, + WorkOrderId = dispatch.WorkOrderId ?? 0, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = storedFileName, + ContentType = contentType, + SizeBytes = bytes.Length, + ScanStatus = "Pending", + ReviewStatus = "Processing", + Version = version, + ReplacesDocumentId = replacedDocument?.Id, + Purpose = resolvedPurpose, + CreatedDate = now + }; + + await _documentData.AddAsync(created, ct); + + var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document"; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + DispatchId = dispatchId, + FieldName = fieldName, + OldValue = isUpliftEvidence || replacedDocument == null + ? null + : $"v{replacedDocument.Version}", + NewValue = isUpliftEvidence + ? $"evidence {created.OriginalFileName}" + : $"v{version}", + Action = isUpliftEvidence + ? "vendor_uplift_evidence_uploaded" + : "vendor_completion_document_uploaded", + ActorType = "vendor", + CreatedAt = now + }, ct); + await _documentData.SaveChangesAsync(ct); + return created; + } + + // The count and the insert run under the per-work-order mutation lock the dispatcher + // media upload also takes, so concurrent uploads cannot both claim the last slot. + var document = dispatch.WorkOrderId is int lockedWorkOrderId + ? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistAsync, cancellationToken) + : await PersistAsync(cancellationToken); using var stored = new MemoryStream(bytes); await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken); diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 10ad8ff..0a0d1d5 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -16,17 +16,20 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAuditService _auditService; private readonly IFileStoragePort _fileStorage; + private readonly IUpliftDataService _upliftData; public WorkOrderMediaService( IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData, IWorkOrderAuditService auditService, - IFileStoragePort fileStorage) + IFileStoragePort fileStorage, + IUpliftDataService upliftData) { _mediaData = mediaData; _detailData = detailData; _auditService = auditService; _fileStorage = fileStorage; + _upliftData = upliftData; } public async Task?> GetMediaAsync( @@ -153,25 +156,34 @@ namespace SeaHaven.Services.Implementation }; } - await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, cancellationToken); - - var attachment = new WorkOrderAttachments - { - WorkorderId = workOrderId, - Attachments = fileUrl, - Category = category.HasValue ? resolvedCategory : null, - CreatedDate = DateTime.UtcNow, - createdby = actorId - }; - - _mediaData.TrackAttachment(attachment); - await _auditService.StageFieldChangedAsync( + // SH-116 photo/video caps are a work-order aggregate shared with the vendor portal + // upload, so the count and the insert run under the per-work-order mutation lock. + var attachment = await _upliftData.ExecuteWorkOrderMutationAsync( workOrderId, - "MediaCategory", - null, - FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()), - actorId); - await SaveMediaAsync(cancellationToken); + async ct => + { + await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, ct); + + var created = new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = fileUrl, + Category = category.HasValue ? resolvedCategory : null, + CreatedDate = DateTime.UtcNow, + createdby = actorId + }; + + _mediaData.TrackAttachment(created); + await _auditService.StageFieldChangedAsync( + workOrderId, + "MediaCategory", + null, + FormatMediaAuditValue(null, (created.Category ?? WorkOrderMediaCategory.Extra).ToString()), + actorId); + await SaveMediaAsync(ct); + return created; + }, + cancellationToken); return new WorkOrderMediaFileDto { @@ -457,6 +469,7 @@ namespace SeaHaven.Services.Implementation { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", + ".heic" => "image/heic", ".mp4" => "video/mp4", ".mov" => "video/quicktime", ".pdf" => "application/pdf", diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs index a51311c..cc765ed 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -41,7 +41,8 @@ public class WorkOrderCompletedSelectiveLockTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - new TestFileStoragePort()); + new TestFileStoragePort(), + new UpliftDataService(context)); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs index 1f2eea6..191dcca 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs @@ -176,7 +176,8 @@ public class WorkOrderMediaConcurrencyRelationalTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - new TestFileStoragePort()); + new TestFileStoragePort(), + new UpliftDataService(context)); } private static async Task LoadVersionAsync(ApplicationDbContext context, int workOrderId) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 3c299ba..224d73b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -812,7 +812,8 @@ public class WorkOrderMediaServiceTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - fileStorage ?? new TestFileStoragePort()); + fileStorage ?? new TestFileStoragePort(), + new UpliftDataService(context)); return (context, service); } @@ -1343,6 +1344,60 @@ public class WorkOrderMediaServiceTests Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); } + [Fact] + public async Task AddMedia_CountsAndInsertsUnderTheWorkOrderMutationLock() + { + // Distinct id: the mutation gate is process-wide per work order. + const int workOrderId = 173_001; + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = workOrderId, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 9; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holder = new UpliftDataService(context).ExecuteWorkOrderMutationAsync( + workOrderId, + async _ => + { + held.SetResult(); + await release.Task; + return 0; + }, + CancellationToken.None); + await held.Task; + + var upload = service.AddMediaAsync( + workOrderId, + null, + "https://example.com/photo-9.jpg", + AuthenticatedUser(), + "actor-1"); + await Task.Delay(200); + + Assert.False(upload.IsCompleted); + Assert.Equal(9, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true)); + + release.SetResult(); + await holder; + await upload; + + Assert.Equal(10, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true)); + } + [Fact] public async Task AddMedia_FourthVideo_CountsStoredPhoneFileUrls() { From 0d8f32d1489b7a3a2d1675d13757a73c1351b23f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 23:47:18 -0300 Subject: [PATCH 09/10] fix(media): check the file type before the size cap on media upload AddMedia sized a file before validating its type, and ValidateSize's Unknown arm returned the video message, so a 150 MB .exe sent as application/octet-stream was rejected as FileTooLarge with "Videos must be 100 MB or smaller." EnsureAllowed now runs first, so an unsupported file always reports UnsupportedMediaType, and the Unknown arm uses a type-neutral message. The oversize controller tests now use real file headers so they pass the type check before reaching the size cap. --- .../WorkOrderMediaControllerTests.cs | 70 +++++++++++++------ .../Controllers/WorkOrderMediaController.cs | 5 +- .../Helpers/WorkOrderMediaContract.cs | 2 +- 3 files changed, 55 insertions(+), 22 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 3aab33e..f0ae1af 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -56,14 +56,11 @@ public class WorkOrderMediaControllerTests [Fact] public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity() { - var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(100_000_001); - file.SetupGet(candidate => candidate.FileName).Returns("clip.mp4"); - file.SetupGet(candidate => candidate.ContentType).Returns("video/mp4"); + var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, null, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); @@ -75,14 +72,11 @@ public class WorkOrderMediaControllerTests [Fact] public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity() { - var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(10_000_001); - file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); - file.SetupGet(candidate => candidate.ContentType).Returns("image/jpeg"); + var file = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, null, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); @@ -96,14 +90,11 @@ public class WorkOrderMediaControllerTests { // A .jpg with a foreign video type resolves to image/jpeg for validation, so it must // also be sized as a photo, not given the 100 MB video allowance. - var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(60_000_000); - file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); - file.SetupGet(candidate => candidate.ContentType).Returns("video/3gpp"); + var file = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, null, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); @@ -115,14 +106,11 @@ public class WorkOrderMediaControllerTests [Fact] public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() { - var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(50_000_001); - file.SetupGet(candidate => candidate.FileName).Returns("report.pdf"); - file.SetupGet(candidate => candidate.ContentType).Returns("application/pdf"); + var file = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); @@ -131,6 +119,48 @@ public class WorkOrderMediaControllerTests storage.VerifyNoOtherCalls(); } + [Fact] + public async Task AddMedia_OversizeUnsupportedType_ReportsUnsupportedTypeNotOversizeVideo() + { + var file = OversizeFile(150_000_000, "payload.exe", "application/octet-stream", [0x4D, 0x5A]); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("UnsupportedMediaType", error.Code); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public void ValidateSize_OversizeUnknownKind_UsesTypeNeutralMessage() + { + Assert.Equal( + "Files must be 100 MB or smaller.", + WorkOrderMediaContract.ValidateSize(150_000_000, WorkOrderMediaContract.UploadKind.Unknown)); + } + + private static readonly byte[] FtypHeader = [0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m']; + private static readonly byte[] JpegHeader = [0xFF, 0xD8, 0xFF, 0xE0]; + private static readonly byte[] PdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D]; + + /// + /// A file that reports bytes but only backs the 512-byte header the + /// type rules read, so oversize cases run through real signature validation cheaply. + /// + private static FormFile OversizeFile(long length, string fileName, string contentType, byte[] header) + { + var bytes = new byte[512]; + header.CopyTo(bytes, 0); + return new FormFile(new MemoryStream(bytes), 0, length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + private static FormFile VideoFormFile(int size, string fileName, string contentType) { var bytes = new byte[size]; diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index e795f3c..fd7dac1 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -88,6 +88,10 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { + // Type first, so an unsupported file always reports UnsupportedMediaType instead + // of being sized under a kind it does not have. + WorkOrderMediaFileRules.EnsureAllowed(file, category); + // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). // Classify by the same resolved type EnsureAllowed validates against. var sizeMessage = WorkOrderMediaContract.ValidateSize( @@ -97,7 +101,6 @@ namespace Api.SeaHavenIndustries.Controllers throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); } - WorkOrderMediaFileRules.EnsureAllowed(file, category); if (WorkOrderMediaContract.ResolveKind( WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName) == WorkOrderMediaContract.UploadKind.Video) diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index 34f8862..e064b02 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -138,7 +138,7 @@ namespace SeaHaven.Services.Helpers UploadKind.Document when length > MaxDocumentBytes => "Documents must be 50 MB or smaller.", UploadKind.Unknown when length > MaxVideoBytes => - "Videos must be 100 MB or smaller.", + "Files must be 100 MB or smaller.", _ => null }; } From 207bf59208ce3f97babd0139dbc50a7fc51d057b Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:58:15 -0300 Subject: [PATCH 10/10] fix(media): name HEIC in the unsupported-type message and keep ticket keys out of comments The rejection message now lists every type the media allowlist accepts, and a test fails if the message and the allowlist drift apart. --- .../nginx/conf.d/01_upload_body_size.conf | 2 +- .../Controllers/WorkOrderMediaController.cs | 2 +- .../Interfaces/IVendorDocumentDataService.cs | 4 +-- .../Interfaces/IWorkOrderMediaDataService.cs | 4 +-- .../DTOs/VendorPortalServiceDTOs.cs | 2 +- .../Helpers/VideoDurationProbe.cs | 2 +- .../Helpers/WorkOrderMediaContract.cs | 2 +- .../Helpers/WorkOrderMediaFileRules.cs | 2 +- .../Implementation/VendorPortalService.cs | 4 +-- .../Implementation/WorkOrderMediaService.cs | 4 +-- .../WorkOrderPhase6Tests.cs | 28 +++++++++++++++++++ scripts/validate-elastic-beanstalk-bundle.sh | 2 +- 12 files changed, 43 insertions(+), 15 deletions(-) diff --git a/.platform/nginx/conf.d/01_upload_body_size.conf b/.platform/nginx/conf.d/01_upload_body_size.conf index a7bd889..da18278 100644 --- a/.platform/nginx/conf.d/01_upload_body_size.conf +++ b/.platform/nginx/conf.d/01_upload_body_size.conf @@ -1,4 +1,4 @@ -# SH-383: the Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m, +# The Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m, # which returned 413 for every media upload over ~1 MB before the request reached # the API. The cap sits above the API's own request limit # (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index fd7dac1..32122ca 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -92,7 +92,7 @@ namespace Api.SeaHavenIndustries.Controllers // of being sized under a kind it does not have. WorkOrderMediaFileRules.EnsureAllowed(file, category); - // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). + // Media contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). // Classify by the same resolved type EnsureAllowed validates against. var sizeMessage = WorkOrderMediaContract.ValidateSize( WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length); diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index 6986ef2..46a4274 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -11,14 +11,14 @@ namespace SeaHaven.DataServices.Interfaces Task GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); /// /// Content types of the work order's current vendor documents (not deleted, not replaced by a - /// newer completion version), optionally excluding one being replaced. SH-116 photo/video counts. + /// newer completion version), optionally excluding one being replaced. Feeds the per-work-order photo/video counts. /// Task> ListActiveContentTypesForWorkOrderAsync( int workOrderId, int? excludingDocumentId, CancellationToken cancellationToken); - /// Stored URLs of the work order's non-deleted dispatcher attachments (SH-116 counts). + /// Stored URLs of the work order's non-deleted dispatcher attachments (photo/video counts). Task> ListActiveWorkOrderAttachmentUrlsAsync( int workOrderId, CancellationToken cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index caf6071..144a521 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -23,14 +23,14 @@ namespace SeaHaven.DataServices.Interfaces Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); void TrackAttachment(WorkOrderAttachments attachment); - /// Stored URLs of the work order's non-deleted attachments (SH-116 photo/video counts). + /// Stored URLs of the work order's non-deleted attachments (photo/video counts). Task> ListActiveAttachmentUrlsAsync( int workOrderId, CancellationToken cancellationToken); /// /// Content types of the work order's current vendor-portal documents (not deleted, not - /// replaced by a newer completion version). SH-116 counts span both upload surfaces. + /// replaced by a newer completion version). The counts span both upload surfaces. /// Task> ListActiveVendorMediaContentTypesAsync( int workOrderId, diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index ace32c8..088c761 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -138,7 +138,7 @@ namespace SeaHaven.Services.DTOs } /// - /// SH-116 per-work-order photo/video usage, so the portal can pre-check an upload + /// Per-work-order photo/video usage, so the portal can pre-check an upload /// before sending it. The server still enforces the limit on upload. /// public class PortalMediaCountsDTO diff --git a/SeaHaven.Services/Helpers/VideoDurationProbe.cs b/SeaHaven.Services/Helpers/VideoDurationProbe.cs index 8355c46..1818c50 100644 --- a/SeaHaven.Services/Helpers/VideoDurationProbe.cs +++ b/SeaHaven.Services/Helpers/VideoDurationProbe.cs @@ -8,7 +8,7 @@ namespace SeaHaven.Services.Helpers /// top-level moov box → mvhd timescale and duration. It seeks over box /// headers only (the moov box may sit after a large mdat), never decodes /// media and never allocates more than a few bytes. Returns null whenever the structure - /// cannot be read, so callers can let unreadable files through (SH-116: unreadable + /// cannot be read, so callers can let unreadable files through (per the media contract: unreadable /// metadata never blocks an upload). /// public static class VideoDurationProbe diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index e064b02..46865bf 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -1,7 +1,7 @@ namespace SeaHaven.Services.Helpers { /// - /// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), + /// Client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), /// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work /// order, across the dispatcher media modal, the completion-doc media tab and the vendor /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index c438759..b4db302 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -140,7 +140,7 @@ namespace SeaHaven.Services.Helpers { throw new Exceptions.WorkOrderBoardValidationException( "UnsupportedMediaType", - "Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); + "Supported file types are JPG, PNG, HEIC, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 782f446..a98c57d 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -885,7 +885,7 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("A completion document file is required."); } - // SH-116 contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB + // Media contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB // (MP4/MOV). Documents keep the configured VendorDocuments cap. var contentType = ResolveUploadContentType(file); if (contentType == null) @@ -980,7 +980,7 @@ namespace SeaHaven.Services.Implementation async Task PersistAsync(CancellationToken ct) { - // SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and + // The 10-photo / 3-video limit is per work order across the dispatcher and // vendor surfaces. A new completion version replaces its predecessor, so that one // does not count against the upload that supersedes it. if (dispatch.WorkOrderId is int workOrderId) diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 0a0d1d5..d2c266d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -156,7 +156,7 @@ namespace SeaHaven.Services.Implementation }; } - // SH-116 photo/video caps are a work-order aggregate shared with the vendor portal + // Photo/video caps are a work-order aggregate shared with the vendor portal // upload, so the count and the insert run under the per-work-order mutation lock. var attachment = await _upliftData.ExecuteWorkOrderMutationAsync( workOrderId, @@ -363,7 +363,7 @@ namespace SeaHaven.Services.Implementation } /// - /// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the + /// Media contract: at most 10 photos and 3 videos per work order, counted over the /// stored attachment rows plus the work order's current vendor-portal documents. Legacy /// Before/After column slots replace in place and are not counted. Documents have no /// per-work-order count limit. diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 224d73b..ea77fb4 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1,6 +1,8 @@ using System.Security.Claims; using System.Text; using System.IO.Compression; +using System.Reflection; +using System.Text.RegularExpressions; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; @@ -2373,6 +2375,32 @@ public class WorkOrderMediaFileRulesTests return stream.ToArray(); } + [Fact] + public void EnsureAllowed_RejectionMessage_NamesEveryAllowedType() + { + const BindingFlags privateStatic = BindingFlags.NonPublic | BindingFlags.Static; + var allowedTypes = (HashSet?)typeof(WorkOrderMediaFileRules) + .GetField("AllowedContentTypes", privateStatic)?.GetValue(null); + var extensionsByType = (Dictionary>?)typeof(WorkOrderMediaFileRules) + .GetField("ExtensionsByContentType", privateStatic)?.GetValue(null); + Assert.NotNull(allowedTypes); + Assert.NotNull(extensionsByType); + Assert.NotEmpty(allowedTypes); + + var ex = Assert.Throws( + () => WorkOrderMediaFileRules.EnsureAllowed(FormFile(Encoding.UTF8.GetBytes("plain text"), "notes.txt", "text/plain"))); + + foreach (var contentType in allowedTypes) + { + var canonical = contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : contentType; + Assert.True(extensionsByType.TryGetValue(canonical, out var extensions), $"No extensions mapped for {contentType}."); + var labels = extensions.Select(extension => extension.TrimStart('.').ToUpperInvariant()).ToList(); + Assert.True( + labels.Any(label => Regex.IsMatch(ex.Message, $@"\b{Regex.Escape(label)}\b")), + $"Rejection message does not name {contentType} ({string.Join("/", labels)}): {ex.Message}"); + } + } + [Fact] public void IsAllowed_ValidJpeg_ReturnsTrue() { diff --git a/scripts/validate-elastic-beanstalk-bundle.sh b/scripts/validate-elastic-beanstalk-bundle.sh index 1d88367..83fcc9e 100755 --- a/scripts/validate-elastic-beanstalk-bundle.sh +++ b/scripts/validate-elastic-beanstalk-bundle.sh @@ -32,7 +32,7 @@ grep -Fxq \ ' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \ "$webhook_file" -# Without this override the platform nginx caps request bodies at 1 MB (SH-383). +# Without this override the platform nginx caps request bodies at 1 MB. unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file" grep -Fxq 'client_max_body_size 120M;' "$nginx_file"