mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
Merge branch 'dev' into feat/ab/sh-278-vendor-area
This commit is contained in:
commit
3047c2ba59
9 changed files with 486 additions and 13 deletions
|
|
@ -0,0 +1,52 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 (<see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>),
|
||||
/// not a <see cref="LifecycleStatus"/> value, so it cannot be requested at all.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,27 @@ namespace SeaHaven.Services.Helpers
|
|||
new HashSet<string>(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<string> 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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FieldChange>();
|
||||
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>SH-169 / SH-357 / SH-358: automatic lifecycle states cannot be forged through the board PATCH.</summary>
|
||||
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<ApplicationDbContext>()
|
||||
.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<WorkOrderBoardRowDto> 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<WorkOrderBoardValidationException>(
|
||||
() => 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<WorkOrderBoardValidationException>(
|
||||
() => 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<WorkOrderBoardValidationException>(
|
||||
() => 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<WorkOrderBoardValidationException>(ex).Code);
|
||||
else
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<WorkOrderBoardValidationException>(() =>
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("<html>not an image</html>");
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue