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.
This commit is contained in:
Alexandre Brandizzi 2026-09-24 23:47:18 -03:00
parent f3ef11b504
commit 0d8f32d148
3 changed files with 55 additions and 22 deletions

View file

@ -56,14 +56,11 @@ public class WorkOrderMediaControllerTests
[Fact]
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
{
var file = new Mock<IFormFile>();
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<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
@ -75,14 +72,11 @@ public class WorkOrderMediaControllerTests
[Fact]
public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity()
{
var file = new Mock<IFormFile>();
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<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFormFile>();
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<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
@ -115,14 +106,11 @@ public class WorkOrderMediaControllerTests
[Fact]
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
{
var file = new Mock<IFormFile>();
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<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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];
/// <summary>
/// A file that reports <paramref name="length"/> bytes but only backs the 512-byte header the
/// type rules read, so oversize cases run through real signature validation cheaply.
/// </summary>
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];

View file

@ -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)

View file

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