From 47022c7761e5f54e7da89f8ef83f5d4979afcbd3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:02:40 -0300 Subject: [PATCH 1/8] feat(work-orders): allow Extra Docs PDF/DOC by category --- .../Helpers/WorkOrderMediaFileRules.cs | 71 ++++++++++++++++-- .../WorkOrderPhase6Tests.cs | 75 +++++++++++++++++++ 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index bc03e6c..4786552 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Http; +using Data.SeaHavenIndustries.Enums; +using System.IO.Compression; namespace SeaHaven.Services.Helpers { @@ -10,7 +12,10 @@ namespace SeaHaven.Services.Helpers "image/jpeg", "image/png", "video/mp4", - "video/quicktime" + "video/quicktime", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }; private static readonly Dictionary> ExtensionsByContentType = @@ -19,10 +24,16 @@ namespace SeaHaven.Services.Helpers ["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" }, ["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" }, ["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }, - ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" } + ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" }, + ["application/pdf"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".pdf" }, + ["application/msword"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc" }, + ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] = + new HashSet(StringComparer.OrdinalIgnoreCase) { ".docx" } }; - public static bool IsAllowed(IFormFile file) + public static bool IsAllowed( + IFormFile file, + WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { if (file == null || file.Length <= 0) return false; @@ -31,6 +42,13 @@ namespace SeaHaven.Services.Helpers if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType)) return false; + var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; + if (IsDocument(contentType) + && resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta) + { + return false; + } + var extension = Path.GetExtension(file.FileName ?? string.Empty); if (string.IsNullOrWhiteSpace(extension) || !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions) @@ -42,7 +60,7 @@ namespace SeaHaven.Services.Helpers try { using var stream = file.OpenReadStream(); - var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64); + var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 512); if (headerLength == 0) return false; @@ -54,6 +72,9 @@ namespace SeaHaven.Services.Helpers if (read < header.Length) Array.Resize(ref header, read); + if (IsDocx(contentType)) + return IsWordDocumentArchive(stream); + return MatchesSignature(contentType, header); } catch @@ -62,13 +83,15 @@ namespace SeaHaven.Services.Helpers } } - public static void EnsureAllowed(IFormFile file) + public static void EnsureAllowed( + IFormFile file, + WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { - if (!IsAllowed(file)) + if (!IsAllowed(file, category)) { throw new Exceptions.WorkOrderBoardValidationException( "UnsupportedMediaType", - "Supported media types are JPG, PNG, MP4, and MOV."); + "Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); } } @@ -95,9 +118,43 @@ namespace SeaHaven.Services.Helpers return HasFtypBox(bytes); } + if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) + return bytes.Length >= 4 && bytes.AsSpan(0, 4).SequenceEqual("%PDF"u8); + + if (contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase)) + { + ReadOnlySpan oleSignature = stackalloc byte[] + { + 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 + }; + return bytes.Length >= oleSignature.Length + && bytes.AsSpan(0, oleSignature.Length).SequenceEqual(oleSignature); + } + return false; } + private static bool IsDocument(string contentType) + => contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) + || contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase) + || IsDocx(contentType); + + private static bool IsDocx(string contentType) + => contentType.Equals( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + StringComparison.OrdinalIgnoreCase); + + private static bool IsWordDocumentArchive(Stream stream) + { + if (!stream.CanSeek) + return false; + + stream.Position = 0; + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + return archive.Entries.Any(entry => + entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)); + } + private static bool HasFtypBox(byte[] bytes) { if (bytes.Length < 12) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 1ee9a4f..a0f81cd 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System.Text; +using System.IO.Compression; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; @@ -1741,6 +1742,19 @@ public class WorkOrderMediaFileRulesTests ContentType = contentType }; + private static byte[] DocxBytes(bool includeWordEntry) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(includeWordEntry ? "word/document.xml" : "not-word/content.xml"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(""); + } + + return stream.ToArray(); + } + [Fact] public void IsAllowed_ValidJpeg_ReturnsTrue() { @@ -1790,4 +1804,65 @@ public class WorkOrderMediaFileRulesTests WorkOrderMediaFileRules.EnsureAllowed(FormFile(new byte[] { 0x00 }, "a.png", "image/png"))); Assert.Equal("UnsupportedMediaType", ex.Code); } + + [Theory] + [InlineData(WorkOrderMediaCategory.Extra)] + [InlineData(WorkOrderMediaCategory.Aveta)] + public void EnsureAllowed_PdfForDocumentCategory_Succeeds(WorkOrderMediaCategory category) + { + var pdf = Encoding.ASCII.GetBytes("%PDF-1.7"); + + WorkOrderMediaFileRules.EnsureAllowed( + FormFile(pdf, "report.pdf", "application/pdf"), + category); + } + + [Fact] + public void EnsureAllowed_DocWithOleSignatureForExtra_Succeeds() + { + var doc = new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0x00 }; + + WorkOrderMediaFileRules.EnsureAllowed( + FormFile(doc, "report.doc", "application/msword"), + WorkOrderMediaCategory.Extra); + } + + [Fact] + public void EnsureAllowed_RealDocxForExtra_Succeeds() + { + WorkOrderMediaFileRules.EnsureAllowed( + FormFile( + DocxBytes(includeWordEntry: true), + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + WorkOrderMediaCategory.Extra); + } + + [Theory] + [InlineData(WorkOrderMediaCategory.Before)] + [InlineData(WorkOrderMediaCategory.After)] + public void EnsureAllowed_PdfForPhotoCategory_ThrowsUnsupportedMediaType( + WorkOrderMediaCategory category) + { + var ex = Assert.Throws(() => + WorkOrderMediaFileRules.EnsureAllowed( + FormFile(Encoding.ASCII.GetBytes("%PDF-1.7"), "report.pdf", "application/pdf"), + category)); + + Assert.Equal("UnsupportedMediaType", ex.Code); + } + + [Fact] + public void EnsureAllowed_ZipWithoutWordEntry_ThrowsUnsupportedMediaType() + { + var ex = Assert.Throws(() => + WorkOrderMediaFileRules.EnsureAllowed( + FormFile( + DocxBytes(includeWordEntry: false), + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + WorkOrderMediaCategory.Extra)); + + Assert.Equal("UnsupportedMediaType", ex.Code); + } } From b6b8d2e58f9ca2315a7be169592af8e1dfd8af1a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:06:05 -0300 Subject: [PATCH 2/8] feat(work-orders): cap media uploads at 50MB --- .../WorkOrderMediaControllerTests.cs | 75 +++++++++++++++++++ .../Controllers/WorkOrderMediaController.cs | 13 +++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs new file mode 100644 index 0000000..18d1a31 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -0,0 +1,75 @@ +using Api.SeaHavenIndustries.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class WorkOrderMediaControllerTests +{ + private static WorkOrderMediaController CreateController( + Mock? service = null, + Mock? storage = null) + { + var controller = new WorkOrderMediaController( + (service ?? new Mock()).Object, + (storage ?? new Mock()).Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + return controller; + } + + [Fact] + public void AddMedia_HasFiftyMegabyteRequestLimit() + { + var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); + var attribute = Assert.Single( + method!.CustomAttributes, + candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); + var bytes = Assert.Single(attribute.ConstructorArguments); + + Assert.Equal(50_000_000L, bytes.Value); + } + + [Fact] + public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity() + { + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(50_000_001); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("The uploaded file must not exceed 50 MB.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity() + { + var bytes = new byte[] { 1, 2, 3 }; + var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "payload.exe") + { + Headers = new HeaderDictionary(), + ContentType = "application/octet-stream" + }; + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("UnsupportedMediaType", error.Code); + storage.VerifyNoOtherCalls(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 0867b1a..b9bc629 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -17,6 +17,8 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/workorders")] public class WorkOrderMediaController : Controller { + public const long MaxUploadBytes = 50_000_000; + private readonly IWorkOrderMediaService _workOrderMediaService; private readonly IFileStoragePort _fileStorage; @@ -51,7 +53,7 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost("{id:int}/media")] - [RequestSizeLimit(30_000_000)] + [RequestSizeLimit(MaxUploadBytes)] public async Task AddMedia( int id, [FromForm] WorkOrderMediaCategory? category, @@ -64,7 +66,14 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { - WorkOrderMediaFileRules.EnsureAllowed(file); + if (file.Length > MaxUploadBytes) + { + throw new WorkOrderBoardValidationException( + "FileTooLarge", + "The uploaded file must not exceed 50 MB."); + } + + WorkOrderMediaFileRules.EnsureAllowed(file, category); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category); From 02df093798382c37d939b0587f05357d8a8484ee Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:07:17 -0300 Subject: [PATCH 3/8] feat(work-orders): align completion-doc size with Extra Docs --- .../WorkOrderMediaControllerTests.cs | 16 ++++++++++++++++ .../Controllers/WorkOrderCompletionController.cs | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 18d1a31..01d88fd 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -73,3 +73,19 @@ public class WorkOrderMediaControllerTests storage.VerifyNoOtherCalls(); } } + +public class WorkOrderCompletionControllerUploadLimitTests +{ + [Fact] + public void UploadCompletionDoc_HasFiftyMegabyteRequestLimit() + { + var method = typeof(WorkOrderCompletionController).GetMethod( + nameof(WorkOrderCompletionController.UploadCompletionDoc)); + var attribute = Assert.Single( + method!.CustomAttributes, + candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); + var bytes = Assert.Single(attribute.ConstructorArguments); + + Assert.Equal(50_000_000L, bytes.Value); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs index bf90361..f2ea1cc 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs @@ -95,7 +95,7 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost("{id:int}/completion-doc")] - [RequestSizeLimit(30_000_000)] + [RequestSizeLimit(50_000_000)] public async Task UploadCompletionDoc( int id, [FromForm] IFormFile file, From bb651bb54714d1d0b130a2a60f128b5ff5f99906 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:08:26 -0300 Subject: [PATCH 4/8] feat(work-orders): add file storage OpenRead port --- .../Infrastructure/PortAdapters.cs | 27 +++++++++++++++++++ SeaHaven.Services/Interfaces/IServicePorts.cs | 6 +++++ 2 files changed, 33 insertions(+) diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index adcc357..a3ebbf3 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -70,6 +70,33 @@ namespace Api.SeaHavenIndustries.Infrastructure } } + public Stream? OpenRead(string fileUrl) + { + if (string.IsNullOrWhiteSpace(fileUrl)) + return null; + + try + { + if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + return null; + + var relativePath = uri.AbsolutePath.TrimStart('/'); + if (string.IsNullOrWhiteSpace(relativePath) + || relativePath.Contains("..", StringComparison.Ordinal) + || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); + return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null; + } + catch + { + return null; + } + } + private string ResolveWebRoot() => _webHostEnvironment.WebRootPath ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); diff --git a/SeaHaven.Services/Interfaces/IServicePorts.cs b/SeaHaven.Services/Interfaces/IServicePorts.cs index 10c1718..c2d00a6 100644 --- a/SeaHaven.Services/Interfaces/IServicePorts.cs +++ b/SeaHaven.Services/Interfaces/IServicePorts.cs @@ -24,6 +24,12 @@ namespace SeaHaven.Services.Interfaces /// cannot be resolved or removed; never throws for missing paths. /// bool TryDelete(string fileUrl); + + /// + /// Opens a previously saved file URL for reading. Returns null when the URL or file + /// cannot be safely resolved. + /// + Stream? OpenRead(string fileUrl); } /// From a25fd0bd5d3e5a90cab92104d35644109f480956 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:09:55 -0300 Subject: [PATCH 5/8] feat(work-orders): stream stored WO media safely --- .../FileStorageAdapterTests.cs | 56 +++++++++++++++++++ .../Infrastructure/PortAdapters.cs | 49 +++++++++------- 2 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs diff --git a/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs b/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs new file mode 100644 index 0000000..04126b8 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs @@ -0,0 +1,56 @@ +using Api.SeaHavenIndustries.Infrastructure; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Moq; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class FileStorageAdapterTests : IDisposable +{ + private readonly string _webRoot = Path.Combine(Path.GetTempPath(), $"shoc-storage-{Guid.NewGuid():N}"); + private readonly FileStorageAdapter _adapter; + + public FileStorageAdapterTests() + { + Directory.CreateDirectory(_webRoot); + var environment = new Mock(); + environment.SetupGet(candidate => candidate.WebRootPath).Returns(_webRoot); + environment.SetupGet(candidate => candidate.ContentRootPath).Returns(_webRoot); + _adapter = new FileStorageAdapter(environment.Object, new HttpContextAccessor()); + } + + [Fact] + public void OpenRead_ValidStoredUrl_ReturnsReadableStream() + { + var directory = Path.Combine(_webRoot, "Assets", "Documents"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "report.pdf"), "stored"); + + using var stream = _adapter.OpenRead("https://example.test/Assets/Documents/report.pdf"); + using var reader = new StreamReader(Assert.IsAssignableFrom(stream)); + + Assert.Equal("stored", reader.ReadToEnd()); + } + + [Theory] + [InlineData("https://example.test/Assets/Images/report.pdf")] + [InlineData("https://example.test/Assets/Documents/../secret.txt")] + [InlineData("https://example.test/Assets/Documents/%2e%2e/secret.txt")] + public void OpenRead_UnsafeUrl_ReturnsNull(string fileUrl) + { + Assert.Null(_adapter.OpenRead(fileUrl)); + } + + [Fact] + public void OpenRead_MissingFile_ReturnsNull() + { + Assert.Null(_adapter.OpenRead("https://example.test/Assets/Documents/missing.pdf")); + } + + public void Dispose() + { + if (Directory.Exists(_webRoot)) + Directory.Delete(_webRoot, recursive: true); + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index a3ebbf3..8b4aad2 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -46,18 +46,9 @@ namespace Api.SeaHavenIndustries.Infrastructure try { - if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + if (!TryResolveDocumentPath(fileUrl, out var fullPath)) return false; - var relativePath = uri.AbsolutePath.TrimStart('/'); - if (string.IsNullOrWhiteSpace(relativePath) - || relativePath.Contains("..", StringComparison.Ordinal) - || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); if (!System.IO.File.Exists(fullPath)) return false; @@ -77,18 +68,9 @@ namespace Api.SeaHavenIndustries.Infrastructure try { - if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + if (!TryResolveDocumentPath(fileUrl, out var fullPath)) return null; - var relativePath = uri.AbsolutePath.TrimStart('/'); - if (string.IsNullOrWhiteSpace(relativePath) - || relativePath.Contains("..", StringComparison.Ordinal) - || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null; } catch @@ -97,6 +79,33 @@ namespace Api.SeaHavenIndustries.Infrastructure } } + private bool TryResolveDocumentPath(string fileUrl, out string fullPath) + { + fullPath = string.Empty; + if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + return false; + + var relativePath = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/'); + if (string.IsNullOrWhiteSpace(relativePath) + || relativePath.Contains("..", StringComparison.Ordinal) + || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var webRoot = Path.GetFullPath(ResolveWebRoot()); + var documentsRoot = Path.GetFullPath(Path.Combine(webRoot, "Assets", "Documents")) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var candidatePath = Path.GetFullPath( + Path.Combine(webRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!candidatePath.StartsWith(documentsRoot, StringComparison.OrdinalIgnoreCase)) + return false; + + fullPath = candidatePath; + return true; + } + private string ResolveWebRoot() => _webHostEnvironment.WebRootPath ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); From b4f2c8b763cc6a9ecc39cef96d3e9c2668299bcc Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:19:41 -0300 Subject: [PATCH 6/8] feat(work-orders): authorize WO media content reads --- .../WorkOrderMediaControllerTests.cs | 49 ++++++++++ .../WorkOrderMediaDataService.cs | 10 ++ .../Interfaces/IWorkOrderMediaDataService.cs | 5 + SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 7 ++ .../Implementation/WorkOrderMediaService.cs | 73 +++++++++++++- .../Interfaces/IWorkOrderMediaService.cs | 7 ++ .../TestFileStoragePort.cs | 28 ++++++ .../WorkOrderCompletedSelectiveLockTests.cs | 3 +- ...orkOrderMediaConcurrencyRelationalTests.cs | 3 +- .../WorkOrderPhase6Tests.cs | 98 ++++++++++++++++++- 10 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 SeaHavenIndustries.Tests/TestFileStoragePort.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 01d88fd..8c4f826 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -1,9 +1,14 @@ using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; +using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -89,3 +94,47 @@ public class WorkOrderCompletionControllerUploadLimitTests Assert.Equal(50_000_000L, bytes.Value); } } + +public class WorkOrderMediaServiceCancellationTests +{ + [Fact] + public async Task GetMediaContent_ForwardsCancellationTokenToAllDataReads() + { + using var source = new CancellationTokenSource(); + var token = source.Token; + var mediaData = new Mock(MockBehavior.Strict); + mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token)) + .ReturnsAsync(new WorkOrder { Id = 1 }); + mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, token)) + .ReturnsAsync(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.test/Assets/Documents/report.pdf" + }); + var storage = new Mock(MockBehavior.Strict); + storage.Setup(candidate => candidate.OpenRead( + "https://example.test/Assets/Documents/report.pdf")) + .Returns(new MemoryStream([1, 2, 3])); + var service = new WorkOrderMediaService( + mediaData.Object, + new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, + storage.Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "actor-1"), + new Claim(ClaimTypes.Role, "Admin"), + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) + }, + "Test")); + + var result = await service.GetMediaContentAsync(1, 10, user, "actor-1", token); + result.Content.Dispose(); + + mediaData.Verify(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token), Times.Once); + mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once); + mediaData.VerifyNoOtherCalls(); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index a1b887c..fd05a45 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -38,6 +38,16 @@ namespace SeaHaven.DataServices.Implementation return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); } + public Task GetAttachmentForReadAsync( + int mediaId, + int workOrderId, + CancellationToken cancellationToken) + => _context.workOrderAttachments + .AsNoTracking() + .FirstOrDefaultAsync( + a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true, + cancellationToken); + public Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken) => _context.workOrderAttachments.FirstOrDefaultAsync( a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true, diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index f8db574..57cbbf3 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -15,6 +15,11 @@ namespace SeaHaven.DataServices.Interfaces int? accountId, CancellationToken cancellationToken); + Task GetAttachmentForReadAsync( + int mediaId, + int workOrderId, + CancellationToken cancellationToken); + Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); void TrackAttachment(WorkOrderAttachments attachment); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index 9832b01..c16da2e 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -94,6 +94,13 @@ namespace SeaHaven.Services.DTOs public bool IsLegacy { get; set; } } + public sealed class WorkOrderMediaContentDto + { + public required Stream Content { get; init; } + public required string ContentType { get; init; } + public required string FileName { get; init; } + } + public class WorkOrderCompletionDocUploadDto { public string? SignOffName { get; set; } diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index bcb8872..4a94fa1 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -15,15 +15,18 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderMediaDataService _mediaData; private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAuditService _auditService; + private readonly IFileStoragePort _fileStorage; public WorkOrderMediaService( IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData, - IWorkOrderAuditService auditService) + IWorkOrderAuditService auditService, + IFileStoragePort fileStorage) { _mediaData = mediaData; _detailData = detailData; _auditService = auditService; + _fileStorage = fileStorage; } public async Task?> GetMediaAsync( @@ -48,6 +51,42 @@ namespace SeaHaven.Services.Implementation return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); } + public async Task GetMediaContentAsync( + int workOrderId, + int mediaId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default) + { + WorkOrderMediaAuthorization.EnsureCanRead(user, actorId); + if (mediaId <= 0) + throw MediaNotFound(); + + var workOrder = await GetMutableWorkOrderForAuthAsync( + workOrderId, + user, + actorId!, + cancellationToken); + var attachment = await _mediaData.GetAttachmentForReadAsync( + mediaId, + workOrder.Id, + cancellationToken); + if (attachment == null || string.IsNullOrWhiteSpace(attachment.Attachments)) + throw MediaNotFound(); + + var content = _fileStorage.OpenRead(attachment.Attachments); + if (content == null) + throw MediaNotFound(); + + var fileName = GetSafeFileName(attachment.Attachments); + return new WorkOrderMediaContentDto + { + Content = content, + ContentType = GetContentType(fileName), + FileName = fileName + }; + } + public async Task EnsureCanMutateMediaAsync( int workOrderId, ClaimsPrincipal user, @@ -361,5 +400,37 @@ namespace SeaHaven.Services.Implementation private static string FormatMediaAuditValue(int? mediaId, string category) => mediaId.HasValue ? $"{mediaId.Value}:{category}" : category; + + private static WorkOrderBoardValidationException MediaNotFound() + => new("NotFound", "Media not found."); + + private static string GetSafeFileName(string fileUrl) + { + if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + return "download"; + + var fileName = Path.GetFileName(Uri.UnescapeDataString(uri.AbsolutePath)); + if (fileName.Length > 37 + && fileName[36] == '_' + && Guid.TryParse(fileName[..36], out _)) + { + fileName = fileName[37..]; + } + + return string.IsNullOrWhiteSpace(fileName) ? "download" : Path.GetFileName(fileName); + } + + private static string GetContentType(string fileName) + => Path.GetExtension(fileName).ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".mp4" => "video/mp4", + ".mov" => "video/quicktime", + ".pdf" => "application/pdf", + ".doc" => "application/msword", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + _ => "application/octet-stream" + }; } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index bb105eb..fd24997 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -12,6 +12,13 @@ namespace SeaHaven.Services.Interfaces string? actorId, CancellationToken cancellationToken = default); + Task GetMediaContentAsync( + int workOrderId, + int mediaId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default); + /// /// Authorizes the caller and validates the work order is mutable before any blob storage write. /// diff --git a/SeaHavenIndustries.Tests/TestFileStoragePort.cs b/SeaHavenIndustries.Tests/TestFileStoragePort.cs new file mode 100644 index 0000000..9d5d44c --- /dev/null +++ b/SeaHavenIndustries.Tests/TestFileStoragePort.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +internal sealed class TestFileStoragePort : IFileStoragePort +{ + private readonly Func _openRead; + + public TestFileStoragePort(Func? openRead = null) + { + _openRead = openRead ?? (_ => null); + } + + public int TryDeleteCalls { get; private set; } + + public Task SaveFileAsync(IFormFile file) + => Task.FromResult("https://example.test/Assets/Documents/saved.bin"); + + public bool TryDelete(string fileUrl) + { + TryDeleteCalls++; + return true; + } + + public Stream? OpenRead(string fileUrl) + => _openRead(fileUrl); +} diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs index 733c589..a51311c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -40,7 +40,8 @@ public class WorkOrderCompletedSelectiveLockTests var service = new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + new TestFileStoragePort()); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs index b39d3d6..bfde333 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs @@ -175,7 +175,8 @@ public class WorkOrderMediaConcurrencyRelationalTests return new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + new TestFileStoragePort()); } private static async Task LoadVersionAsync(ApplicationDbContext context, int workOrderId) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index a0f81cd..8bf1480 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -10,6 +10,7 @@ using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; @@ -798,7 +799,8 @@ public class WorkOrderCommentServiceTests public class WorkOrderMediaServiceTests { - private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut() + private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut( + IFileStoragePort? fileStorage = null) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) @@ -809,7 +811,8 @@ public class WorkOrderMediaServiceTests var service = new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + fileStorage ?? new TestFileStoragePort()); return (context, service); } @@ -1731,6 +1734,97 @@ public class WorkOrderMediaServiceTests Assert.Equal("NotFound", ex.Code); } + + [Fact] + public async Task ExtraPdf_ListContentDeleteContent_FollowsSoftDeleteLifecycle() + { + var storage = new TestFileStoragePort(_ => new MemoryStream(Encoding.ASCII.GetBytes("%PDF-1.7"))); + var (context, service) = CreateSut(storage); + var workOrder = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + var fileUrl = + "https://example.test/Assets/Documents/11111111-1111-1111-1111-111111111111_report.pdf"; + + var added = await service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Extra, + fileUrl, + AuthenticatedUser(), + "actor-1"); + var listed = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); + + Assert.Contains(listed!, candidate => candidate.Id == added.Id); + var content = await service.GetMediaContentAsync( + 1, + added.Id, + AuthenticatedUser(), + "actor-1"); + using (content.Content) + { + Assert.Equal("application/pdf", content.ContentType); + Assert.Equal("report.pdf", content.FileName); + } + + await service.DeleteMediaAsync( + 1, + added.Id, + ToVersion(workOrder), + AuthenticatedUser(), + "actor-1"); + var ex = await Assert.ThrowsAsync(() => + service.GetMediaContentAsync(1, added.Id, AuthenticatedUser(), "actor-1")); + + Assert.Equal("NotFound", ex.Code); + Assert.Equal(0, storage.TryDeleteCalls); + } + + [Fact] + public async Task GetMediaContent_CrossAccount_ThrowsNotFound() + { + var storage = new TestFileStoragePort(_ => new MemoryStream([1])); + var (context, service) = CreateSut(storage); + context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 20 }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.test/Assets/Documents/report.pdf" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.GetMediaContentAsync( + 1, + 10, + AuthenticatedUser(accountId: 10), + "actor-1")); + + Assert.Equal("NotFound", ex.Code); + } + + [Fact] + public async Task GetMediaContent_CanceledToken_ForwardsCancellation() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder { Id = 1 }); + await context.SaveChangesAsync(); + using var source = new CancellationTokenSource(); + source.Cancel(); + + await Assert.ThrowsAnyAsync(() => + service.GetMediaContentAsync( + 1, + 10, + AuthenticatedUser(), + "actor-1", + source.Token)); + } } public class WorkOrderMediaFileRulesTests From 9d8ae5ec24c48a1e7e1bf4f3131cc0452265b30e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:23:52 -0300 Subject: [PATCH 7/8] feat(work-orders): add authorized media content GET --- .../WorkOrderMediaControllerTests.cs | 37 +++++++++++++++++++ .../Controllers/WorkOrderMediaController.cs | 21 +++++++++++ 2 files changed, 58 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 8c4f826..b377b40 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -77,6 +78,42 @@ public class WorkOrderMediaControllerTests Assert.Equal("UnsupportedMediaType", error.Code); storage.VerifyNoOtherCalls(); } + + [Fact] + public async Task GetMediaContent_DeletedMedia_ReturnsNotFound() + { + var service = new Mock(MockBehavior.Strict); + service.Setup(candidate => candidate.GetMediaContentAsync( + 1, 10, It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new WorkOrderBoardValidationException("NotFound", "Media not found.")); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(service, storage); + + var result = await controller.GetMediaContent(1, 10, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("NotFound", error.Code); + storage.Verify(candidate => candidate.TryDelete(It.IsAny()), Times.Never); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task DeleteMedia_Success_DoesNotCallTryDelete() + { + var service = new Mock(MockBehavior.Strict); + service.Setup(candidate => candidate.DeleteMediaAsync( + 1, 10, null, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(service, storage); + + var result = await controller.DeleteMedia(1, 10); + + Assert.IsType(result); + storage.Verify(candidate => candidate.TryDelete(It.IsAny()), Times.Never); + storage.VerifyNoOtherCalls(); + } } public class WorkOrderCompletionControllerUploadLimitTests diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index b9bc629..62f81df 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -52,6 +52,27 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("{id:int}/media/{mediaId:int}/content")] + public async Task GetMediaContent(int id, int mediaId, CancellationToken cancellationToken) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var content = await _workOrderMediaService.GetMediaContentAsync( + id, mediaId, User, actorId, cancellationToken); + return File(content.Content, content.ContentType, content.FileName); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + [HttpPost("{id:int}/media")] [RequestSizeLimit(MaxUploadBytes)] public async Task AddMedia( From 143d61354719180782b04f18b4c6721cfc745b4d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:25:26 -0300 Subject: [PATCH 8/8] test(work-orders): expect media content route --- .../WorkOrderRouteContractTests.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 920f0f4..6411337 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests /// Baseline public endpoint set (verb + action-relative route) that the original single /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route - /// duplication is collapsed here, so this is the distinct action-relative contract. 51 routes - /// come from 49 actions (Editworkorder and GetWorkorderById each bind two routes). + /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes + /// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { @@ -67,6 +67,7 @@ public class WorkOrderRouteContractTests "GET {id:int}/detail", "GET {id:int}/uplifts", "GET {id:int}/media", + "GET {id:int}/media/{mediaId:int}/content", "PATCH {id:int}/board", "PATCH {id:int}/comments/{commentId:int}", "PATCH {id:int}/media/{mediaId:int}", @@ -168,6 +169,24 @@ public class WorkOrderRouteContractTests unexpected.Should().BeEmpty("the split must not introduce new public WorkOrder endpoints"); actual.Should().HaveCount(ExpectedWorkOrderEndpoints.Count, "the distinct action-relative endpoint count must match the baseline"); + actual.Should().NotContain(endpoint => + endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void WorkOrder_Family_Does_Not_Expose_Documents_Route() + { + ExpectedWorkOrderEndpoints.Should().Contain("GET {id:int}/media/{mediaId:int}/content"); + ExpectedWorkOrderEndpoints.Should().NotContain(endpoint => + endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase)); + + var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad + && WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo)) + .Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}") + .ToHashSet(StringComparer.Ordinal); + + actual.Should().NotContain(endpoint => + endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase)); } [Fact]