fix(media): name HEIC in the unsupported-type message and keep ticket keys out of comments

The rejection message now lists every type the media allowlist accepts, and a
test fails if the message and the allowlist drift apart.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 02:58:15 -03:00
parent 0d8f32d148
commit 207bf59208
12 changed files with 43 additions and 15 deletions

View file

@ -1,4 +1,4 @@
# SH-383: the Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m,
# The Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m,
# which returned 413 for every media upload over ~1 MB before the request reached
# the API. The cap sits above the API's own request limit
# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get

View file

@ -92,7 +92,7 @@ namespace Api.SeaHavenIndustries.Controllers
// 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).
// Media 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(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length);

View file

@ -11,14 +11,14 @@ namespace SeaHaven.DataServices.Interfaces
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
/// <summary>
/// Content types of the work order's current vendor documents (not deleted, not replaced by a
/// newer completion version), optionally excluding one being replaced. SH-116 photo/video counts.
/// newer completion version), optionally excluding one being replaced. Feeds the per-work-order photo/video counts.
/// </summary>
Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
int workOrderId,
int? excludingDocumentId,
CancellationToken cancellationToken);
/// <summary>Stored URLs of the work order's non-deleted dispatcher attachments (SH-116 counts).</summary>
/// <summary>Stored URLs of the work order's non-deleted dispatcher attachments (photo/video counts).</summary>
Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken);

View file

@ -23,14 +23,14 @@ namespace SeaHaven.DataServices.Interfaces
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment);
/// <summary>Stored URLs of the work order's non-deleted attachments (SH-116 photo/video counts).</summary>
/// <summary>Stored URLs of the work order's non-deleted attachments (photo/video counts).</summary>
Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken);
/// <summary>
/// Content types of the work order's current vendor-portal documents (not deleted, not
/// replaced by a newer completion version). SH-116 counts span both upload surfaces.
/// replaced by a newer completion version). The counts span both upload surfaces.
/// </summary>
Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
int workOrderId,

View file

@ -138,7 +138,7 @@ namespace SeaHaven.Services.DTOs
}
/// <summary>
/// SH-116 per-work-order photo/video usage, so the portal can pre-check an upload
/// Per-work-order photo/video usage, so the portal can pre-check an upload
/// before sending it. The server still enforces the limit on upload.
/// </summary>
public class PortalMediaCountsDTO

View file

@ -8,7 +8,7 @@ namespace SeaHaven.Services.Helpers
/// 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
/// cannot be read, so callers can let unreadable files through (per the media contract: unreadable
/// metadata never blocks an upload).
/// </summary>
public static class VideoDurationProbe

View file

@ -1,7 +1,7 @@
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC),
/// 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
/// 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 read

View file

@ -140,7 +140,7 @@ namespace SeaHaven.Services.Helpers
{
throw new Exceptions.WorkOrderBoardValidationException(
"UnsupportedMediaType",
"Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
"Supported file types are JPG, PNG, HEIC, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
}
}

View file

@ -885,7 +885,7 @@ namespace SeaHaven.Services.Implementation
throw new InvalidOperationException("A completion document file is required.");
}
// SH-116 contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB
// Media contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB
// (MP4/MOV). Documents keep the configured VendorDocuments cap.
var contentType = ResolveUploadContentType(file);
if (contentType == null)
@ -980,7 +980,7 @@ namespace SeaHaven.Services.Implementation
async Task<VendorCompletionDocument> PersistAsync(CancellationToken ct)
{
// SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and
// The 10-photo / 3-video limit is per work order across the dispatcher and
// vendor surfaces. A new completion version replaces its predecessor, so that one
// does not count against the upload that supersedes it.
if (dispatch.WorkOrderId is int workOrderId)

View file

@ -156,7 +156,7 @@ namespace SeaHaven.Services.Implementation
};
}
// SH-116 photo/video caps are a work-order aggregate shared with the vendor portal
// Photo/video caps are a work-order aggregate shared with the vendor portal
// upload, so the count and the insert run under the per-work-order mutation lock.
var attachment = await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
@ -363,7 +363,7 @@ namespace SeaHaven.Services.Implementation
}
/// <summary>
/// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the
/// Media contract: at most 10 photos and 3 videos per work order, counted over the
/// stored attachment rows plus the work order's current vendor-portal documents. Legacy
/// Before/After column slots replace in place and are not counted. Documents have no
/// per-work-order count limit.

View file

@ -1,6 +1,8 @@
using System.Security.Claims;
using System.Text;
using System.IO.Compression;
using System.Reflection;
using System.Text.RegularExpressions;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
@ -2373,6 +2375,32 @@ public class WorkOrderMediaFileRulesTests
return stream.ToArray();
}
[Fact]
public void EnsureAllowed_RejectionMessage_NamesEveryAllowedType()
{
const BindingFlags privateStatic = BindingFlags.NonPublic | BindingFlags.Static;
var allowedTypes = (HashSet<string>?)typeof(WorkOrderMediaFileRules)
.GetField("AllowedContentTypes", privateStatic)?.GetValue(null);
var extensionsByType = (Dictionary<string, HashSet<string>>?)typeof(WorkOrderMediaFileRules)
.GetField("ExtensionsByContentType", privateStatic)?.GetValue(null);
Assert.NotNull(allowedTypes);
Assert.NotNull(extensionsByType);
Assert.NotEmpty(allowedTypes);
var ex = Assert.Throws<WorkOrderBoardValidationException>(
() => WorkOrderMediaFileRules.EnsureAllowed(FormFile(Encoding.UTF8.GetBytes("plain text"), "notes.txt", "text/plain")));
foreach (var contentType in allowedTypes)
{
var canonical = contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : contentType;
Assert.True(extensionsByType.TryGetValue(canonical, out var extensions), $"No extensions mapped for {contentType}.");
var labels = extensions.Select(extension => extension.TrimStart('.').ToUpperInvariant()).ToList();
Assert.True(
labels.Any(label => Regex.IsMatch(ex.Message, $@"\b{Regex.Escape(label)}\b")),
$"Rejection message does not name {contentType} ({string.Join("/", labels)}): {ex.Message}");
}
}
[Fact]
public void IsAllowed_ValidJpeg_ReturnsTrue()
{

View file

@ -32,7 +32,7 @@ grep -Fxq \
' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \
"$webhook_file"
# Without this override the platform nginx caps request bodies at 1 MB (SH-383).
# Without this override the platform nginx caps request bodies at 1 MB.
unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file"
grep -Fxq 'client_max_body_size 120M;' "$nginx_file"