mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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:
parent
0d8f32d148
commit
207bf59208
12 changed files with 43 additions and 15 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue