Merge remote-tracking branch 'origin/dev' into feat/SH-186-status-auto-derivation

This commit is contained in:
Arthur Bassi 2026-09-09 17:34:00 -03:00
commit 1e37fb486c
17 changed files with 757 additions and 27 deletions

View file

@ -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<IWebHostEnvironment>();
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>(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);
}
}

View file

@ -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<IWorkOrderMediaService>? service = null,
Mock<IFileStoragePort>? storage = null)
{
var controller = new WorkOrderMediaController(
(service ?? new Mock<IWorkOrderMediaService>()).Object,
(storage ?? new Mock<IFileStoragePort>()).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<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(50_000_001);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("UnsupportedMediaType", error.Code);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task GetMediaContent_DeletedMedia_ReturnsNotFound()
{
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
service.Setup(candidate => candidate.GetMediaContentAsync(
1, 10, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new WorkOrderBoardValidationException("NotFound", "Media not found."));
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(service, storage);
var result = await controller.GetMediaContent(1, 10, CancellationToken.None);
var response = Assert.IsType<NotFoundObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("NotFound", error.Code);
storage.Verify(candidate => candidate.TryDelete(It.IsAny<string>()), Times.Never);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task DeleteMedia_Success_DoesNotCallTryDelete()
{
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
service.Setup(candidate => candidate.DeleteMediaAsync(
1, 10, null, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(service, storage);
var result = await controller.DeleteMedia(1, 10);
Assert.IsType<NoContentResult>(result);
storage.Verify(candidate => candidate.TryDelete(It.IsAny<string>()), 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<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,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).
/// </summary>
private static readonly HashSet<string> 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]

View file

@ -95,7 +95,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost("{id:int}/completion-doc")]
[RequestSizeLimit(30_000_000)]
[RequestSizeLimit(50_000_000)]
public async Task<IActionResult> UploadCompletionDoc(
int id,
[FromForm] IFormFile file,

View file

@ -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<IActionResult> 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<IActionResult> 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);

View file

@ -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");

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

@ -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<string, HashSet<string>> ExtensionsByContentType =
@ -20,7 +25,11 @@ namespace SeaHaven.Services.Helpers
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
["video/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" }
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
["application/pdf"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".pdf" },
["application/msword"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".doc" },
["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] =
new HashSet<string>(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<byte> 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)

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

View file

@ -24,6 +24,12 @@ namespace SeaHaven.Services.Interfaces
/// cannot be resolved or removed; never throws for missing paths.
/// </summary>
bool TryDelete(string fileUrl);
/// <summary>
/// Opens a previously saved file URL for reading. Returns null when the URL or file
/// cannot be safely resolved.
/// </summary>
Stream? OpenRead(string fileUrl);
}
/// <summary>

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

@ -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<ApplicationDbContext>()
.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<WorkOrderBoardValidationException>(() =>
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<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
@ -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("<document />");
}
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
WorkOrderMediaFileRules.EnsureAllowed(
FormFile(
DocxBytes(includeWordEntry: false),
"report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
WorkOrderMediaCategory.Extra));
Assert.Equal("UnsupportedMediaType", ex.Code);
}
}