mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 08:52:09 +00:00
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:
parent
f3ef11b504
commit
0d8f32d148
3 changed files with 55 additions and 22 deletions
|
|
@ -56,14 +56,11 @@ public class WorkOrderMediaControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
|
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
|
||||||
{
|
{
|
||||||
var file = new Mock<IFormFile>();
|
var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader);
|
||||||
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<IFileStoragePort>(MockBehavior.Strict);
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
var controller = CreateController(storage: storage);
|
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 response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
|
|
@ -75,14 +72,11 @@ public class WorkOrderMediaControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity()
|
public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity()
|
||||||
{
|
{
|
||||||
var file = new Mock<IFormFile>();
|
var file = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader);
|
||||||
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<IFileStoragePort>(MockBehavior.Strict);
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
var controller = CreateController(storage: storage);
|
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 response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
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
|
// 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.
|
// also be sized as a photo, not given the 100 MB video allowance.
|
||||||
var file = new Mock<IFormFile>();
|
var file = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader);
|
||||||
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<IFileStoragePort>(MockBehavior.Strict);
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
var controller = CreateController(storage: storage);
|
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 response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
|
|
@ -115,14 +106,11 @@ public class WorkOrderMediaControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
|
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
|
||||||
{
|
{
|
||||||
var file = new Mock<IFormFile>();
|
var file = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader);
|
||||||
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<IFileStoragePort>(MockBehavior.Strict);
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
var controller = CreateController(storage: storage);
|
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 response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
|
|
@ -131,6 +119,48 @@ public class WorkOrderMediaControllerTests
|
||||||
storage.VerifyNoOtherCalls();
|
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)
|
private static FormFile VideoFormFile(int size, string fileName, string contentType)
|
||||||
{
|
{
|
||||||
var bytes = new byte[size];
|
var bytes = new byte[size];
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
string? fileUrl = null;
|
string? fileUrl = null;
|
||||||
try
|
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).
|
// SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB).
|
||||||
// Classify by the same resolved type EnsureAllowed validates against.
|
// Classify by the same resolved type EnsureAllowed validates against.
|
||||||
var sizeMessage = WorkOrderMediaContract.ValidateSize(
|
var sizeMessage = WorkOrderMediaContract.ValidateSize(
|
||||||
|
|
@ -97,7 +101,6 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
|
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkOrderMediaFileRules.EnsureAllowed(file, category);
|
|
||||||
if (WorkOrderMediaContract.ResolveKind(
|
if (WorkOrderMediaContract.ResolveKind(
|
||||||
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName)
|
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName)
|
||||||
== WorkOrderMediaContract.UploadKind.Video)
|
== WorkOrderMediaContract.UploadKind.Video)
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
UploadKind.Document when length > MaxDocumentBytes =>
|
UploadKind.Document when length > MaxDocumentBytes =>
|
||||||
"Documents must be 50 MB or smaller.",
|
"Documents must be 50 MB or smaller.",
|
||||||
UploadKind.Unknown when length > MaxVideoBytes =>
|
UploadKind.Unknown when length > MaxVideoBytes =>
|
||||||
"Videos must be 100 MB or smaller.",
|
"Files must be 100 MB or smaller.",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue