mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge pull request #109 from Sea-Haven-Industries/feat/SH-171-wo-documents
feat(work-orders): add authorized Extra Docs content GET
This commit is contained in:
commit
815e17c56f
17 changed files with 757 additions and 27 deletions
56
Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs
Normal file
56
Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
177
Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs
Normal file
177
Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue