From caba84ea08f67264f923b7ec7c52a786d814c3d6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 14:41:23 -0300 Subject: [PATCH 1/3] fix(work-orders): reject forged automatic lifecycle statuses on board patch (SH-357, SH-358) (#120) Incomplete and Scheduled are derived by the server. A direct lifecycleStatus PATCH may only restate the status derivation already produced; any other request to move a work order into an automatic state returns the stable AutomaticLifecycleStatus validation error and leaves status and audit untouched. --- .../WorkOrderBoardPatchLifecycleRules.cs | 52 ++++++ .../WorkOrderBoardUpdateService.cs | 14 +- .../WorkOrderBoardPatchLifecycleRulesTests.cs | 171 ++++++++++++++++++ 3 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs 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/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 18604b2..533ece6 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -516,15 +516,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/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); + } +} From 776c8be5cb50c9555c85854df304e921e32a2e96 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 14:53:58 -0300 Subject: [PATCH 2/3] fix(work-orders): resolve undetermined media MIME from the extension (SH-370) (#122) The media allowlist refused any upload whose multipart part had an empty or application/octet-stream Content-Type before looking at the extension or the bytes. Browsers take that header from File.type, which mobile browsers leave empty when the OS cannot classify a picked file, while the client-side gate already accepts such files on extension alone. A real JPG/MP4/MOV could pass the dialog and still be refused by the API. Only an undetermined type now falls back to the extension. The resolved type still goes through the SH-171 document/category rule, the extension pairing, and the magic-byte signature check, so an octet-stream .pdf stays refused for Completion, Before and After, and a declared type is never overridden. --- .../Helpers/WorkOrderMediaFileRules.cs | 28 +++++- .../WorkOrderPhase6Tests.cs | 98 +++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) 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/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() { From 4b772c8db35e2e17d99abf4a56d98106fb0a376e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 15:01:10 -0300 Subject: [PATCH 3/3] fix(work-orders): keep SH placeholder WO numbers and block downgrades (#123) SH-320: the WO number normalizer stripped every non-digit, so a manually entered SH placeholder (e.g. SH00001) was saved as 00000000001 on both create and patch. Keep the SH prefix, and reject replacing a saved real APM number with an SH placeholder. --- .../Helpers/WorkOrderNumberNormalizer.cs | 33 +++++++++++- .../WorkOrderBoardUpdateService.cs | 8 +++ .../WorkOrderBoardCreateServiceTests.cs | 18 +++++++ .../WorkOrderBoardUpdateServiceTests.cs | 51 +++++++++++++++++++ .../WorkOrderNumberNormalizerTests.cs | 26 ++++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) 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 533ece6..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."); 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/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); + } }