diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs
new file mode 100644
index 0000000..b963b73
--- /dev/null
+++ b/SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs
@@ -0,0 +1,52 @@
+using Data.SeaHavenIndustries.Enums;
+using SeaHaven.Services.Exceptions;
+
+namespace SeaHaven.Services.Helpers
+{
+ ///
+ /// SH-169 manual transition guard for the board lifecycleStatus PATCH.
+ /// Incomplete and Scheduled are derived by the server; a direct request may only
+ /// restate what derivation already produces, never forge a new automatic state.
+ /// Past Due is a read-model overlay (),
+ /// not a value, so it cannot be requested at all.
+ ///
+ public static class WorkOrderBoardPatchLifecycleRules
+ {
+ public const string AutomaticStatusCode = "AutomaticLifecycleStatus";
+
+ public static bool IsAutomaticStatus(LifecycleStatus status)
+ => status == LifecycleStatus.Incomplete || status == LifecycleStatus.Scheduled;
+
+ public static void EnsureRequestAllowed(
+ LifecycleStatus? current,
+ LifecycleStatus requested,
+ DateTime? scheduledDate,
+ bool? scheduleWeekOnly)
+ {
+ if (!IsAutomaticStatus(requested))
+ return;
+
+ if (requested == LifecycleStatus.Scheduled
+ && !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly))
+ {
+ throw new WorkOrderBoardValidationException(
+ "ScheduledRequiresDate",
+ "Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true.");
+ }
+
+ // why: the board client restates the derived status after a schedule patch; that is a no-op, not a forge.
+ if (current == requested)
+ return;
+
+ if (requested == LifecycleStatus.Scheduled
+ && WorkOrderBoardMutationRules.ShouldPromoteToScheduled(current, scheduledDate, scheduleWeekOnly))
+ {
+ return;
+ }
+
+ throw new WorkOrderBoardValidationException(
+ AutomaticStatusCode,
+ $"{LifecycleStatusMapper.ToFeLabel(requested)} is set automatically and cannot be selected manually.");
+ }
+ }
+}
diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
index 1132a4f..e1281d4 100644
--- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
@@ -32,6 +32,27 @@ namespace SeaHaven.Services.Helpers
new HashSet(StringComparer.OrdinalIgnoreCase) { ".docx" }
};
+ // A browser fills the multipart part's Content-Type from File.type, which mobile
+ // browsers leave empty (or the client sends octet-stream) when the OS cannot
+ // classify a picked file. Only then is the type resolved from the extension; the
+ // category rule, extension pairing, and magic-byte signature still decide.
+ private static readonly HashSet UndeterminedContentTypes =
+ new(StringComparer.OrdinalIgnoreCase) { string.Empty, "application/octet-stream" };
+
+ private static string? ResolveContentType(string declaredType, string extension)
+ {
+ if (!UndeterminedContentTypes.Contains(declaredType))
+ return AllowedContentTypes.Contains(declaredType) ? declaredType : null;
+
+ foreach (var (contentType, extensions) in ExtensionsByContentType)
+ {
+ if (extensions.Contains(extension))
+ return contentType;
+ }
+
+ return null;
+ }
+
private static string CanonicalContentType(string contentType)
{
if (contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase))
@@ -47,10 +68,12 @@ namespace SeaHaven.Services.Helpers
return false;
var declaredType = (file.ContentType ?? string.Empty).Trim();
- if (string.IsNullOrWhiteSpace(declaredType) || !AllowedContentTypes.Contains(declaredType))
+ var extension = Path.GetExtension(file.FileName ?? string.Empty);
+ var resolvedType = ResolveContentType(declaredType, extension);
+ if (resolvedType == null)
return false;
- var contentType = CanonicalContentType(declaredType);
+ var contentType = CanonicalContentType(resolvedType);
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
if (IsDocument(contentType)
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
@@ -58,7 +81,6 @@ namespace SeaHaven.Services.Helpers
return false;
}
- var extension = Path.GetExtension(file.FileName ?? string.Empty);
if (string.IsNullOrWhiteSpace(extension)
|| !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions)
|| !allowedExtensions.Contains(extension))
diff --git a/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs b/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs
index 59f8494..8e97f43 100644
--- a/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs
@@ -5,6 +5,8 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderNumberNormalizer
{
private const int RequiredLength = 11;
+ private const string ProvisionalPrefix = "SH";
+ private const int MinimumProvisionalDigits = 5;
public static bool TryNormalize(string? input, out string normalized, out string? error)
{
@@ -17,7 +19,11 @@ namespace SeaHaven.Services.Helpers
return false;
}
- var digits = Regex.Replace(input.Trim(), @"\D", "");
+ var trimmed = input.Trim();
+ if (WorkOrderNumberRules.IsProvisional(trimmed))
+ return TryNormalizeProvisional(trimmed, out normalized, out error);
+
+ var digits = Regex.Replace(trimmed, @"\D", "");
if (digits.Length == 0)
{
error = "WO number must contain digits.";
@@ -33,5 +39,30 @@ namespace SeaHaven.Services.Helpers
normalized = digits.PadLeft(RequiredLength, '0');
return true;
}
+
+ // SH-320: an SH-prefixed number is the documented placeholder for a WO still
+ // awaiting its real APM number; keep the prefix instead of stripping it to digits.
+ private static bool TryNormalizeProvisional(string trimmed, out string normalized, out string? error)
+ {
+ normalized = "";
+ error = null;
+
+ var digits = Regex.Replace(trimmed[ProvisionalPrefix.Length..], @"\D", "");
+ if (digits.Length == 0)
+ {
+ error = "WO number must contain digits.";
+ return false;
+ }
+
+ var candidate = ProvisionalPrefix + digits.PadLeft(MinimumProvisionalDigits, '0');
+ if (candidate.Length > RequiredLength)
+ {
+ error = $"WO number cannot exceed {RequiredLength} characters.";
+ return false;
+ }
+
+ normalized = candidate;
+ return true;
+ }
}
}
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
index 18604b2..b415035 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
@@ -334,6 +334,14 @@ namespace SeaHaven.Services.Implementation
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
+ // SH-320: a saved real APM number can never be downgraded to an SH placeholder.
+ if (WorkOrderNumberRules.IsProvisional(normalized)
+ && !string.IsNullOrWhiteSpace(workOrder.InternalWONumber)
+ && !WorkOrderNumberRules.IsProvisional(workOrder.InternalWONumber))
+ throw new WorkOrderBoardValidationException(
+ "WoNumberDowngrade",
+ "Cannot replace an APM work order number with an internal number.");
+
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id, cancellationToken))
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
@@ -516,15 +524,11 @@ namespace SeaHaven.Services.Implementation
"Replace the provisional SH work order number before marking the work order Completed.");
}
- if (parsed == LifecycleStatus.Scheduled
- && !WorkOrderBoardMutationRules.HasConcreteSchedule(
- workOrder.ScheduledDate,
- workOrder.ScheduleWeekOnly))
- {
- throw new WorkOrderBoardValidationException(
- "ScheduledRequiresDate",
- "Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true.");
- }
+ WorkOrderBoardPatchLifecycleRules.EnsureRequestAllowed(
+ workOrder.LifecycleStatus,
+ parsed.Value,
+ workOrder.ScheduledDate,
+ workOrder.ScheduleWeekOnly);
var changes = new List();
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
index dd2fd10..8a327e4 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
@@ -87,6 +87,24 @@ public class WorkOrderBoardCreateServiceTests
Assert.Equal("00000012345", result.WoNumber);
}
+ [Theory]
+ [InlineData("SH00001", "SH00001")]
+ [InlineData("sh00042", "SH00042")]
+ public async Task Create_WithManualShPlaceholderWoNumber_KeepsShPrefix(string requested, string expected)
+ {
+ var (_, service) = CreateSut();
+
+ var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WoNumber = requested,
+ WorkOrderType = WorkOrderType.PM,
+ SiteCode = "DAL",
+ LocationId = 1
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
+
+ Assert.Equal(expected, result.WoNumber);
+ }
+
[Fact]
public async Task Create_DuplicateWoNumber_Throws()
{
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs
new file mode 100644
index 0000000..050b78b
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs
@@ -0,0 +1,171 @@
+using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using Microsoft.EntityFrameworkCore;
+using SeaHaven.DataServices.Implementation;
+using SeaHaven.Services.DTOs;
+using SeaHaven.Services.Exceptions;
+using SeaHaven.Services.Helpers;
+using SeaHaven.Services.Implementation;
+
+namespace SeaHavenIndustries.Tests;
+
+/// SH-169 / SH-357 / SH-358: automatic lifecycle states cannot be forged through the board PATCH.
+public class WorkOrderBoardPatchLifecycleRulesTests
+{
+ private static readonly byte[] Version = { 1, 0, 0, 0, 0, 0, 0, 1 };
+
+ private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ var context = new ApplicationDbContext(options);
+ var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
+ var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
+ var service = new WorkOrderBoardUpdateService(
+ new WorkOrderBoardDataService(context),
+ new WorkOrderBoardMutationDataService(context),
+ audit);
+ return (context, service);
+ }
+
+ private static async Task SeedAsync(
+ ApplicationDbContext context,
+ LifecycleStatus status,
+ DateTime? scheduledDate = null)
+ {
+ context.workOrders.Add(new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = status,
+ ScheduledDate = scheduledDate,
+ ScheduleWeekOnly = false,
+ RowVersion = Version
+ });
+ await context.SaveChangesAsync();
+ }
+
+ private static Task PatchStatusAsync(WorkOrderBoardUpdateService service, string value)
+ => service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.LifecycleStatus,
+ Value = value,
+ WorkOrderVersion = Convert.ToBase64String(Version)
+ }, "actor-1");
+
+ [Theory]
+ [InlineData(LifecycleStatus.Pending, "Incomplete")]
+ [InlineData(LifecycleStatus.InProgress, "Incomplete")]
+ [InlineData(LifecycleStatus.EnRoute, "incomplete")]
+ [InlineData(LifecycleStatus.OnSite, "1")]
+ [InlineData(LifecycleStatus.Pending, "Open")]
+ public async Task PatchField_IncompleteRequestFromAnotherState_IsRejectedWithoutChangeOrAudit(
+ LifecycleStatus current,
+ string requested)
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, current);
+
+ var ex = await Assert.ThrowsAsync(
+ () => PatchStatusAsync(service, requested));
+
+ Assert.Equal(WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode, ex.Code);
+ Assert.Equal(current, (await context.workOrders.SingleAsync()).LifecycleStatus);
+ Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
+ }
+
+ [Theory]
+ [InlineData(LifecycleStatus.EnRoute)]
+ [InlineData(LifecycleStatus.OnSite)]
+ [InlineData(LifecycleStatus.InProgress)]
+ [InlineData(LifecycleStatus.PendingQuote)]
+ public async Task PatchField_ScheduledRequestWhenDerivationWouldNotPromote_IsRejected(LifecycleStatus current)
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, current, DateTime.UtcNow.Date.AddDays(7));
+
+ var ex = await Assert.ThrowsAsync(
+ () => PatchStatusAsync(service, "Scheduled"));
+
+ Assert.Equal(WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode, ex.Code);
+ Assert.Equal(current, (await context.workOrders.SingleAsync()).LifecycleStatus);
+ Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
+ }
+
+ [Theory]
+ [InlineData("Past Due")]
+ [InlineData("PastDue")]
+ public async Task PatchField_PastDueIsNotARequestableLifecycleStatus(string requested)
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, LifecycleStatus.Pending);
+
+ var ex = await Assert.ThrowsAsync(
+ () => PatchStatusAsync(service, requested));
+
+ Assert.Equal("InvalidValue", ex.Code);
+ Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus);
+ }
+
+ [Fact]
+ public async Task PatchField_RestatingDerivedScheduledStatus_IsANoOp()
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, LifecycleStatus.Scheduled, DateTime.UtcNow.Date.AddDays(7));
+
+ var result = await PatchStatusAsync(service, "Scheduled");
+
+ Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
+ Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
+ }
+
+ [Theory]
+ [InlineData(LifecycleStatus.Incomplete, "Pending")]
+ [InlineData(LifecycleStatus.Pending, "En Route")]
+ [InlineData(LifecycleStatus.EnRoute, "On Site")]
+ [InlineData(LifecycleStatus.OnSite, "In Progress")]
+ [InlineData(LifecycleStatus.InProgress, "Pending")]
+ public async Task PatchField_ManualTransition_PersistsAndAuditsActorPreviousAndNew(
+ LifecycleStatus current,
+ string requested)
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, current);
+
+ var result = await PatchStatusAsync(service, requested);
+
+ var expected = LifecycleStatusMapper.ParseLifecycleStatus(requested)!.Value;
+ Assert.Equal(expected, result.LifecycleStatus);
+ var audit = Assert.Single(await context.WorkOrderAuditLogs.ToListAsync());
+ Assert.Equal(AuditActionType.StatusChanged.ToString(), audit.Action);
+ Assert.Equal("actor-1", audit.UserId);
+ Assert.Equal(current.ToString(), audit.OldValue);
+ Assert.Equal(expected.ToString(), audit.NewValue);
+ Assert.NotEqual(default, audit.CreatedAt);
+ }
+
+ [Theory]
+ [InlineData(LifecycleStatus.Incomplete, LifecycleStatus.Incomplete, false)]
+ [InlineData(LifecycleStatus.Pending, LifecycleStatus.Incomplete, true)]
+ [InlineData(LifecycleStatus.Pending, LifecycleStatus.Pending, false)]
+ [InlineData(LifecycleStatus.Pending, LifecycleStatus.EnRoute, false)]
+ [InlineData(LifecycleStatus.InProgress, LifecycleStatus.Scheduled, true)]
+ [InlineData(LifecycleStatus.Incomplete, LifecycleStatus.Scheduled, false)]
+ [InlineData(LifecycleStatus.Pending, LifecycleStatus.Scheduled, false)]
+ public void EnsureRequestAllowed_WithConcreteSchedule(
+ LifecycleStatus current,
+ LifecycleStatus requested,
+ bool rejects)
+ {
+ var date = new DateTime(2026, 9, 21);
+ var ex = Record.Exception(() =>
+ WorkOrderBoardPatchLifecycleRules.EnsureRequestAllowed(current, requested, date, false));
+
+ if (rejects)
+ Assert.Equal(
+ WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode,
+ Assert.IsType(ex).Code);
+ else
+ Assert.Null(ex);
+ }
+}
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
index 975c58a..b2c715e 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
@@ -1158,6 +1158,57 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("00000012345", result.WoNumber);
}
+ [Fact]
+ public async Task PatchField_ShPlaceholderReplacesShPlaceholder()
+ {
+ var (context, service) = CreateSut();
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ InternalWONumber = "SH00001",
+ LifecycleStatus = LifecycleStatus.Incomplete,
+ RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
+ };
+ context.workOrders.Add(wo);
+ await context.SaveChangesAsync();
+
+ var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.WoNumber,
+ Value = "SH00007",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.Equal("SH00007", result.WoNumber);
+ }
+
+ [Fact]
+ public async Task PatchField_RejectsReplacingRealWoNumberWithShPlaceholder()
+ {
+ var (context, service) = CreateSut();
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ InternalWONumber = "00000012345",
+ LifecycleStatus = LifecycleStatus.Incomplete,
+ RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
+ };
+ context.workOrders.Add(wo);
+ await context.SaveChangesAsync();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.WoNumber,
+ Value = "SH00001",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1"));
+
+ Assert.Equal("WoNumberDowngrade", ex.Code);
+ var stored = await context.workOrders.SingleAsync(w => w.Id == 1);
+ Assert.Equal("00000012345", stored.InternalWONumber);
+ }
+
[Fact]
public async Task PatchField_RejectsDuplicateWoNumber()
{
diff --git a/SeaHavenIndustries.Tests/WorkOrderNumberNormalizerTests.cs b/SeaHavenIndustries.Tests/WorkOrderNumberNormalizerTests.cs
index dcc4755..708f25d 100644
--- a/SeaHavenIndustries.Tests/WorkOrderNumberNormalizerTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderNumberNormalizerTests.cs
@@ -40,4 +40,30 @@ public class WorkOrderNumberNormalizerTests
Assert.False(ok);
Assert.Contains("11", error);
}
+
+ [Theory]
+ [InlineData("SH00001", "SH00001")]
+ [InlineData(" sh00001 ", "SH00001")]
+ [InlineData("SH-00001", "SH00001")]
+ [InlineData("SH1", "SH00001")]
+ [InlineData("SH123456789", "SH123456789")]
+ public void TryNormalize_KeepsShPlaceholderPrefix(string input, string expected)
+ {
+ var ok = WorkOrderNumberNormalizer.TryNormalize(input, out var normalized, out var error);
+
+ Assert.True(ok);
+ Assert.Null(error);
+ Assert.Equal(expected, normalized);
+ }
+
+ [Theory]
+ [InlineData("SH")]
+ [InlineData("SH1234567890")]
+ public void TryNormalize_RejectsInvalidShPlaceholder(string input)
+ {
+ var ok = WorkOrderNumberNormalizer.TryNormalize(input, out _, out var error);
+
+ Assert.False(ok);
+ Assert.NotNull(error);
+ }
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
index ce285c1..468e2b0 100644
--- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
@@ -2004,6 +2004,104 @@ public class WorkOrderMediaFileRulesTests
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(new byte[] { 1, 2, 3 }, "photo.jpg", "image/jpeg")));
}
+ // Signatures below are the leading bytes of files produced by real encoders:
+ // ffmpeg (mp4 isom, mov qt, jpeg JFIF, png) and an iPhone/macOS .MOV (ftyp qt).
+ private static readonly byte[] RealJpeg =
+ { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01 };
+ private static readonly byte[] RealPng =
+ { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52 };
+ private static readonly byte[] RealMp4 =
+ { 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00 };
+ private static readonly byte[] RealMov =
+ { 0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00 };
+ private static readonly byte[] RealPdf = Encoding.ASCII.GetBytes("%PDF-1.7\n%\u00e2\u00e3\n1 0 obj\n");
+
+ [Theory]
+ [InlineData("photo.jpg", "image/jpeg")]
+ [InlineData("IMG_0001.JPG", "image/jpeg")]
+ [InlineData("photo.jpeg", "image/jpg")]
+ [InlineData("scan.png", "image/png")]
+ [InlineData("clip.mp4", "video/mp4")]
+ [InlineData("VID_0001.MP4", "video/mp4")]
+ [InlineData("IMG_1587.MOV", "video/quicktime")]
+ public void IsAllowed_RealMediaSignatures_ReturnsTrueForEveryCategory(string fileName, string contentType)
+ {
+ var bytes = BytesFor(fileName);
+ foreach (var category in new[]
+ {
+ WorkOrderMediaCategory.Extra, WorkOrderMediaCategory.Before, WorkOrderMediaCategory.After
+ })
+ {
+ Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(bytes, fileName, contentType), category));
+ }
+ }
+
+ [Theory]
+ [InlineData("photo.jpg", "")]
+ [InlineData("IMG_0001.JPG", "application/octet-stream")]
+ [InlineData("scan.png", "")]
+ [InlineData("clip.mp4", "application/octet-stream")]
+ [InlineData("IMG_1587.MOV", "")]
+ [InlineData("IMG_1587.mov", "application/octet-stream")]
+ public void IsAllowed_UndeterminedMimeFromMobileBrowser_ResolvesFromExtensionAndSignature(
+ string fileName,
+ string contentType)
+ {
+ // Some mobile browsers leave File.type empty (or send octet-stream) when the OS
+ // cannot classify a picked file; the extension plus magic bytes must decide.
+ Assert.True(WorkOrderMediaFileRules.IsAllowed(
+ FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before));
+ }
+
+ [Fact]
+ public void IsAllowed_UndeterminedMime_StillRequiresMatchingSignature()
+ {
+ var html = Encoding.UTF8.GetBytes("not an image");
+ Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(html, "photo.jpg", "")));
+ Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealPng, "photo.jpg", "application/octet-stream")));
+ Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mov", "")));
+ Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.heic", "")));
+ Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo", "application/octet-stream")));
+ }
+
+ [Theory]
+ [InlineData(WorkOrderMediaCategory.Completion)]
+ [InlineData(WorkOrderMediaCategory.Before)]
+ [InlineData(WorkOrderMediaCategory.After)]
+ public void IsAllowed_UndeterminedMimePdf_StillRefusedOutsideDocumentCategories(WorkOrderMediaCategory category)
+ {
+ // SH-337 / SH-171: resolving an empty MIME from ".pdf" must not reopen photo slots to documents.
+ Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealPdf, "report.pdf", ""), category));
+ Assert.False(WorkOrderMediaFileRules.IsAllowed(
+ FormFile(RealPdf, "report.pdf", "application/octet-stream"), category));
+ }
+
+ [Fact]
+ public void IsAllowed_UndeterminedMimePdf_AcceptedForExtra()
+ {
+ Assert.True(WorkOrderMediaFileRules.IsAllowed(
+ FormFile(RealPdf, "report.pdf", ""), WorkOrderMediaCategory.Extra));
+ }
+
+ [Fact]
+ public void IsAllowed_DeclaredMimeMismatchingExtension_StillRejected()
+ {
+ // A concrete declared type is authoritative; only an undetermined one falls back to the extension.
+ Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4")));
+ Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.jpg", "image/heic")));
+ }
+
+ private static byte[] BytesFor(string fileName)
+ => Path.GetExtension(fileName).ToLowerInvariant() switch
+ {
+ ".jpg" or ".jpeg" => RealJpeg,
+ ".png" => RealPng,
+ ".mp4" => RealMp4,
+ ".mov" => RealMov,
+ ".pdf" => RealPdf,
+ _ => throw new ArgumentOutOfRangeException(nameof(fileName))
+ };
+
[Fact]
public void IsAllowed_ValidMp4Ftyp_ReturnsTrue()
{