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 # 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 # the API. The cap sits above the API's own request limit
# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get # (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. // of being sized under a kind it does not have.
WorkOrderMediaFileRules.EnsureAllowed(file, category); 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. // Classify by the same resolved type EnsureAllowed validates against.
var sizeMessage = WorkOrderMediaContract.ValidateSize( var sizeMessage = WorkOrderMediaContract.ValidateSize(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length); 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); Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Content types of the work order's current vendor documents (not deleted, not replaced by a /// 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> /// </summary>
Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync( Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
int workOrderId, int workOrderId,
int? excludingDocumentId, int? excludingDocumentId,
CancellationToken cancellationToken); 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( Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
int workOrderId, int workOrderId,
CancellationToken cancellationToken); CancellationToken cancellationToken);

View file

@ -23,14 +23,14 @@ namespace SeaHaven.DataServices.Interfaces
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment); 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( Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
int workOrderId, int workOrderId,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Content types of the work order's current vendor-portal documents (not deleted, not /// 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> /// </summary>
Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync( Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
int workOrderId, int workOrderId,

View file

@ -138,7 +138,7 @@ namespace SeaHaven.Services.DTOs
} }
/// <summary> /// <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. /// before sending it. The server still enforces the limit on upload.
/// </summary> /// </summary>
public class PortalMediaCountsDTO 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 /// 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 /// 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 /// 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). /// metadata never blocks an upload).
/// </summary> /// </summary>
public static class VideoDurationProbe public static class VideoDurationProbe

View file

@ -1,7 +1,7 @@
namespace SeaHaven.Services.Helpers namespace SeaHaven.Services.Helpers
{ {
/// <summary> /// <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 /// 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 read /// 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( throw new Exceptions.WorkOrderBoardValidationException(
"UnsupportedMediaType", "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."); 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. // (MP4/MOV). Documents keep the configured VendorDocuments cap.
var contentType = ResolveUploadContentType(file); var contentType = ResolveUploadContentType(file);
if (contentType == null) if (contentType == null)
@ -980,7 +980,7 @@ namespace SeaHaven.Services.Implementation
async Task<VendorCompletionDocument> PersistAsync(CancellationToken ct) 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 // vendor surfaces. A new completion version replaces its predecessor, so that one
// does not count against the upload that supersedes it. // does not count against the upload that supersedes it.
if (dispatch.WorkOrderId is int workOrderId) 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. // upload, so the count and the insert run under the per-work-order mutation lock.
var attachment = await _upliftData.ExecuteWorkOrderMutationAsync( var attachment = await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId, workOrderId,
@ -363,7 +363,7 @@ namespace SeaHaven.Services.Implementation
} }
/// <summary> /// <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 /// 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 /// Before/After column slots replace in place and are not counted. Documents have no
/// per-work-order count limit. /// per-work-order count limit.

View file

@ -1,6 +1,8 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.IO.Compression; using System.IO.Compression;
using System.Reflection;
using System.Text.RegularExpressions;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -2373,6 +2375,32 @@ public class WorkOrderMediaFileRulesTests
return stream.ToArray(); 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] [Fact]
public void IsAllowed_ValidJpeg_ReturnsTrue() 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' \ ' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \
"$webhook_file" "$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" unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file"
grep -Fxq 'client_max_body_size 120M;' "$nginx_file" grep -Fxq 'client_max_body_size 120M;' "$nginx_file"