feat(work-orders): authorize WO media content reads

This commit is contained in:
Arthur Bassi 2026-09-08 11:19:41 -03:00
parent a25fd0bd5d
commit b4f2c8b763
10 changed files with 278 additions and 5 deletions

View file

@ -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<IWorkOrderMediaDataService>(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<IFileStoragePort>(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<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(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();
}
}

View file

@ -38,6 +38,16 @@ namespace SeaHaven.DataServices.Implementation
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public Task<WorkOrderAttachments?> GetAttachmentForReadAsync(
int mediaId,
int workOrderId,
CancellationToken cancellationToken)
=> _context.workOrderAttachments
.AsNoTracking()
.FirstOrDefaultAsync(
a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true,
cancellationToken);
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
=> _context.workOrderAttachments.FirstOrDefaultAsync(
a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true,

View file

@ -15,6 +15,11 @@ namespace SeaHaven.DataServices.Interfaces
int? accountId,
CancellationToken cancellationToken);
Task<WorkOrderAttachments?> GetAttachmentForReadAsync(
int mediaId,
int workOrderId,
CancellationToken cancellationToken);
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);

View file

@ -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; }

View file

@ -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<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
@ -48,6 +51,42 @@ namespace SeaHaven.Services.Implementation
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
}
public async Task<WorkOrderMediaContentDto> 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"
};
}
}

View file

@ -12,6 +12,13 @@ namespace SeaHaven.Services.Interfaces
string? actorId,
CancellationToken cancellationToken = default);
Task<WorkOrderMediaContentDto> GetMediaContentAsync(
int workOrderId,
int mediaId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
/// <summary>
/// Authorizes the caller and validates the work order is mutable before any blob storage write.
/// </summary>

View file

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Http;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
internal sealed class TestFileStoragePort : IFileStoragePort
{
private readonly Func<string, Stream?> _openRead;
public TestFileStoragePort(Func<string, Stream?>? openRead = null)
{
_openRead = openRead ?? (_ => null);
}
public int TryDeleteCalls { get; private set; }
public Task<string> 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);
}

View file

@ -40,7 +40,8 @@ public class WorkOrderCompletedSelectiveLockTests
var service = new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit);
audit,
new TestFileStoragePort());
return (context, service);
}

View file

@ -175,7 +175,8 @@ public class WorkOrderMediaConcurrencyRelationalTests
return new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit);
audit,
new TestFileStoragePort());
}
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)

View file

@ -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<ApplicationDbContext>()
.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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<OperationCanceledException>(() =>
service.GetMediaContentAsync(
1,
10,
AuthenticatedUser(),
"actor-1",
source.Token));
}
}
public class WorkOrderMediaFileRulesTests