shoc-backend/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
Alexandre Brandizzi fd59a3da13 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.
2026-09-24 21:38:00 -03:00

112 lines
4 KiB
C#

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