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