mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
feat(work-orders): authorize WO media content reads
This commit is contained in:
parent
a25fd0bd5d
commit
b4f2c8b763
10 changed files with 278 additions and 5 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
28
SeaHavenIndustries.Tests/TestFileStoragePort.cs
Normal file
28
SeaHavenIndustries.Tests/TestFileStoragePort.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -40,7 +40,8 @@ public class WorkOrderCompletedSelectiveLockTests
|
|||
var service = new WorkOrderMediaService(
|
||||
new WorkOrderMediaDataService(context),
|
||||
new WorkOrderDetailDataService(context),
|
||||
audit);
|
||||
audit,
|
||||
new TestFileStoragePort());
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue