mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
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.
This commit is contained in:
parent
a8414cd4fa
commit
fd59a3da13
7 changed files with 373 additions and 3 deletions
|
|
@ -453,6 +453,50 @@ public sealed class VendorPortalDocumentTests : IDisposable
|
||||||
context.VendorCompletionDocuments.Should().BeEmpty();
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
|
||||||
|
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<OkObjectResult>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads()
|
public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,60 @@ public class WorkOrderMediaControllerTests
|
||||||
Assert.Equal(5, Assert.IsType<WorkOrderMediaFileDto>(ok.Value).Id);
|
Assert.Equal(5, Assert.IsType<WorkOrderMediaFileDto>(ok.Value).Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity()
|
||||||
|
{
|
||||||
|
var storage = new Mock<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
|
||||||
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<OkObjectResult>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
|
||||||
|
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]
|
[Fact]
|
||||||
public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted()
|
public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,15 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkOrderMediaFileRules.EnsureAllowed(file, category);
|
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);
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category);
|
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category);
|
||||||
|
|
||||||
|
|
|
||||||
129
SeaHaven.Services/Helpers/VideoDurationProbe.cs
Normal file
129
SeaHaven.Services/Helpers/VideoDurationProbe.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a video's duration from the ISO base media (MP4 / QuickTime) movie header:
|
||||||
|
/// top-level <c>moov</c> box → <c>mvhd</c> timescale and duration. It seeks over box
|
||||||
|
/// headers only (the <c>moov</c> box may sit after a large <c>mdat</c>), 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).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
/// 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
|
/// 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
|
/// 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
|
/// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read
|
||||||
/// checkable client-side (the server cannot probe it cheaply), so it is enforced in the
|
/// from the MP4/MOV movie header (<see cref="VideoDurationProbe"/>); the browser pre-checks
|
||||||
/// browser and documented here as part of the same contract.
|
/// it and the server enforces it, and unreadable metadata never blocks an upload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class WorkOrderMediaContract
|
public static class WorkOrderMediaContract
|
||||||
{
|
{
|
||||||
|
|
@ -103,6 +103,18 @@ namespace SeaHaven.Services.Helpers
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ValidateVideoDuration(Stream? content)
|
||||||
|
{
|
||||||
|
var seconds = VideoDurationProbe.TryReadDurationSeconds(content);
|
||||||
|
return seconds > MaxVideoDurationSeconds
|
||||||
|
? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter."
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>
|
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>
|
||||||
public static string? ValidateSize(long length, UploadKind kind)
|
public static string? ValidateSize(long length, UploadKind kind)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -900,6 +900,16 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
|
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);
|
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken);
|
||||||
if (dispatch == null)
|
if (dispatch == null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
112
SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
Normal file
112
SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue