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 }; }