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.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs new file mode 100644 index 0000000..b377b40 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -0,0 +1,177 @@ +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.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +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(); + } + + [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 +{ + [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); + } +} + +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/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] 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, diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 0867b1a..62f81df 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; @@ -50,8 +52,29 @@ 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(30_000_000)] + [RequestSizeLimit(MaxUploadBytes)] public async Task AddMedia( int id, [FromForm] WorkOrderMediaCategory? category, @@ -64,7 +87,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); diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index adcc357..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; @@ -70,6 +61,51 @@ namespace Api.SeaHavenIndustries.Infrastructure } } + public Stream? OpenRead(string fileUrl) + { + if (string.IsNullOrWhiteSpace(fileUrl)) + return null; + + try + { + if (!TryResolveDocumentPath(fileUrl, out var fullPath)) + return null; + + return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null; + } + catch + { + return null; + } + } + + 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"); 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/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index 68381d9..a1ab2f7 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 { @@ -11,7 +13,10 @@ namespace SeaHaven.Services.Helpers "image/jpg", "image/png", "video/mp4", - "video/quicktime" + "video/quicktime", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }; private static readonly Dictionary> ExtensionsByContentType = @@ -20,7 +25,11 @@ 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" } }; private static string CanonicalContentType(string contentType) @@ -30,7 +39,9 @@ namespace SeaHaven.Services.Helpers return contentType; } - public static bool IsAllowed(IFormFile file) + public static bool IsAllowed( + IFormFile file, + WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { if (file == null || file.Length <= 0) return false; @@ -40,6 +51,12 @@ namespace SeaHaven.Services.Helpers return false; var contentType = CanonicalContentType(declaredType); + 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) @@ -52,7 +69,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; @@ -64,6 +81,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 @@ -72,13 +92,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."); } } @@ -105,9 +127,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/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index bcb8872..0b0e38c 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, @@ -177,6 +216,13 @@ namespace SeaHaven.Services.Implementation if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After) { + if (IsDocumentAttachment(attachment.Attachments)) + { + throw new WorkOrderBoardValidationException( + "UnsupportedMediaType", + "Documents can only be categorized as Extra or Aveta."); + } + var url = attachment.Attachments ?? ""; if (category == WorkOrderMediaCategory.Before) workOrder.BeforPhotoAttachment = url; @@ -361,5 +407,43 @@ 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" + }; + + private static bool IsDocumentAttachment(string? attachment) + => GetContentType(GetSafeFileName(attachment ?? string.Empty)) is + "application/pdf" + or "application/msword" + or "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; } } 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); } /// 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 b770313..cecb181 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; @@ -9,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; @@ -797,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()) @@ -808,7 +811,8 @@ public class WorkOrderMediaServiceTests var service = new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + fileStorage ?? new TestFileStoragePort()); return (context, service); } @@ -1424,6 +1428,44 @@ public class WorkOrderMediaServiceTests a.FieldName == "MediaCategory" && a.NewValue == "10:Before"); } + [Theory] + [InlineData("https://example.com/document.pdf")] + [InlineData("https://example.com/document.doc")] + [InlineData("https://example.com/document.docx")] + public async Task UpdateMediaCategory_DocumentToPhotoCategory_ThrowsUnsupportedMediaType( + string attachmentUrl) + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = attachmentUrl, + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UpdateMediaCategoryAsync( + 1, + 10, + WorkOrderMediaCategory.Before, + ToVersion(wo), + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("UnsupportedMediaType", ex.Code); + Assert.Null(context.workOrders.Single().BeforPhotoAttachment); + Assert.True(context.workOrderAttachments.Single().IsDeleted != true); + } + [Fact] public async Task UpdateMediaCategory_StaleVersion_ThrowsConcurrencyConflict() { @@ -1730,6 +1772,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 @@ -1741,6 +1874,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() { @@ -1797,4 +1943,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); + } }