mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
fix(work-orders): validate uploads before persist and store privately
Board completion/media uploads now check domain state first, use server-controlled filenames with an allowlist, store under App_Data, and serve via authenticated download endpoints. Also harden WeekRolled log arguments against CodeQL log-injection findings.
This commit is contained in:
parent
755a95bdc3
commit
719a5b05fd
12 changed files with 587 additions and 50 deletions
|
|
@ -15,6 +15,7 @@ using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Api.SeaHavenIndustries.Helper;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Tests;
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
@ -64,7 +65,8 @@ public class WorkOrderControllerSearchTests
|
||||||
Mock.Of<IWorkOrderDetailService>(),
|
Mock.Of<IWorkOrderDetailService>(),
|
||||||
Mock.Of<IWorkOrderCommentService>(),
|
Mock.Of<IWorkOrderCommentService>(),
|
||||||
Mock.Of<IWorkOrderCompletionService>(),
|
Mock.Of<IWorkOrderCompletionService>(),
|
||||||
Mock.Of<IWorkOrderMediaService>());
|
Mock.Of<IWorkOrderMediaService>(),
|
||||||
|
Mock.Of<IWorkOrderPrivateFileStorage>());
|
||||||
|
|
||||||
controller.ControllerContext = new ControllerContext
|
controller.ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
|
|
@ -138,7 +140,8 @@ public class WorkOrderControllerSearchTests
|
||||||
Mock.Of<IWorkOrderDetailService>(),
|
Mock.Of<IWorkOrderDetailService>(),
|
||||||
Mock.Of<IWorkOrderCommentService>(),
|
Mock.Of<IWorkOrderCommentService>(),
|
||||||
Mock.Of<IWorkOrderCompletionService>(),
|
Mock.Of<IWorkOrderCompletionService>(),
|
||||||
Mock.Of<IWorkOrderMediaService>());
|
Mock.Of<IWorkOrderMediaService>(),
|
||||||
|
Mock.Of<IWorkOrderPrivateFileStorage>());
|
||||||
|
|
||||||
controllerWithBoard.ControllerContext = new ControllerContext
|
controllerWithBoard.ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
using Api.SeaHavenIndustries.Helper;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderPrivateFileStorageTests
|
||||||
|
{
|
||||||
|
private static (WorkOrderPrivateFileStorage Storage, string Root) CreateStorage()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), "wo-files-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
var env = new Mock<IWebHostEnvironment>();
|
||||||
|
env.Setup(e => e.ContentRootPath).Returns(root);
|
||||||
|
return (new WorkOrderPrivateFileStorage(env.Object), root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IFormFile FakeFile(string fileName, byte[]? bytes = null)
|
||||||
|
{
|
||||||
|
bytes ??= new byte[] { 1, 2, 3, 4 };
|
||||||
|
var stream = new MemoryStream(bytes);
|
||||||
|
return new FormFile(stream, 0, bytes.Length, "file", fileName)
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = "application/octet-stream"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_UsesServerControlledName_OutsideWwwroot()
|
||||||
|
{
|
||||||
|
var (storage, root) = CreateStorage();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var key = await storage.SaveAsync(42, FakeFile("../../evil.pdf"));
|
||||||
|
key.Should().StartWith("workorder-files/42/");
|
||||||
|
key.Should().EndWith(".pdf");
|
||||||
|
key.Should().NotContain("..");
|
||||||
|
key.Should().NotContain("evil");
|
||||||
|
|
||||||
|
storage.TryResolvePhysicalPath(key, out var physical).Should().BeTrue();
|
||||||
|
physical.Should().StartWith(Path.Combine(root, "App_Data", "workorder-files"));
|
||||||
|
File.Exists(physical).Should().BeTrue();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root))
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_RejectsDisallowedExtension()
|
||||||
|
{
|
||||||
|
var (storage, root) = CreateStorage();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var act = async () => await storage.SaveAsync(1, FakeFile("payload.exe"));
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
Directory.Exists(Path.Combine(root, "App_Data", "workorder-files", "1")).Should().BeFalse();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root))
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryResolvePhysicalPath_RejectsPathEscape()
|
||||||
|
{
|
||||||
|
var (storage, root) = CreateStorage();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
storage.TryResolvePhysicalPath("workorder-files/../secret.txt", out _).Should().BeFalse();
|
||||||
|
storage.TryResolvePhysicalPath("https://host/Assets/Documents/x.pdf", out _).Should().BeFalse();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root))
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,9 @@ using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using Api.SeaHavenIndustries.Helper;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -53,6 +55,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
private readonly IWorkOrderCommentService _workOrderCommentService;
|
private readonly IWorkOrderCommentService _workOrderCommentService;
|
||||||
private readonly IWorkOrderCompletionService _workOrderCompletionService;
|
private readonly IWorkOrderCompletionService _workOrderCompletionService;
|
||||||
private readonly IWorkOrderMediaService _workOrderMediaService;
|
private readonly IWorkOrderMediaService _workOrderMediaService;
|
||||||
|
private readonly IWorkOrderPrivateFileStorage _privateFileStorage;
|
||||||
|
|
||||||
public WorkOrderController(
|
public WorkOrderController(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
|
|
@ -78,7 +81,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
IWorkOrderDetailService workOrderDetailService,
|
IWorkOrderDetailService workOrderDetailService,
|
||||||
IWorkOrderCommentService workOrderCommentService,
|
IWorkOrderCommentService workOrderCommentService,
|
||||||
IWorkOrderCompletionService workOrderCompletionService,
|
IWorkOrderCompletionService workOrderCompletionService,
|
||||||
IWorkOrderMediaService workOrderMediaService)
|
IWorkOrderMediaService workOrderMediaService,
|
||||||
|
IWorkOrderPrivateFileStorage privateFileStorage)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_workOrderService = workOrderService;
|
_workOrderService = workOrderService;
|
||||||
|
|
@ -104,6 +108,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
_workOrderCommentService = workOrderCommentService;
|
_workOrderCommentService = workOrderCommentService;
|
||||||
_workOrderCompletionService = workOrderCompletionService;
|
_workOrderCompletionService = workOrderCompletionService;
|
||||||
_workOrderMediaService = workOrderMediaService;
|
_workOrderMediaService = workOrderMediaService;
|
||||||
|
_privateFileStorage = privateFileStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("board")]
|
[HttpGet("board")]
|
||||||
|
|
@ -418,34 +423,67 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
if (file == null || file.Length == 0)
|
if (file == null || file.Length == 0)
|
||||||
return BadRequest(new Response { Status = "Error", Message = "file is required." });
|
return BadRequest(new Response { Status = "Error", Message = "file is required." });
|
||||||
|
|
||||||
|
if (!WorkOrderUploadFileRules.TryGetAllowedExtension(file.FileName, out _, out var fileError))
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = fileError });
|
||||||
|
|
||||||
|
string? storedKey = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var uploadDto = new WorkOrderCompletionDocUploadDto
|
||||||
|
{
|
||||||
|
SignOffName = signOffName,
|
||||||
|
SignOffSignature = signOffSignature,
|
||||||
|
WorkOrderVersion = workOrderVersion
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate domain state before persisting any bytes.
|
||||||
|
await _workOrderCompletionService.EnsureCanUploadCompletionDocAsync(id, uploadDto);
|
||||||
|
|
||||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var fileUrl = await SaveFileAsync(file);
|
storedKey = await _privateFileStorage.SaveAsync(id, file);
|
||||||
|
var downloadUrl = _privateFileStorage.ToDownloadApiPath(id, "completion");
|
||||||
var result = await _workOrderCompletionService.UploadCompletionDocAsync(
|
var result = await _workOrderCompletionService.UploadCompletionDocAsync(
|
||||||
id,
|
id,
|
||||||
new WorkOrderCompletionDocUploadDto
|
uploadDto,
|
||||||
{
|
storedKey,
|
||||||
SignOffName = signOffName,
|
|
||||||
SignOffSignature = signOffSignature,
|
|
||||||
WorkOrderVersion = workOrderVersion
|
|
||||||
},
|
|
||||||
fileUrl,
|
|
||||||
actorId);
|
actorId);
|
||||||
|
result.SignOffAttachment = downloadUrl;
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
|
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
|
||||||
{
|
{
|
||||||
|
_privateFileStorage.TryDelete(storedKey);
|
||||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||||
}
|
}
|
||||||
catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
|
catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
|
||||||
{
|
{
|
||||||
|
_privateFileStorage.TryDelete(storedKey);
|
||||||
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||||
}
|
}
|
||||||
catch (WorkOrderBoardValidationException ex)
|
catch (WorkOrderBoardValidationException ex)
|
||||||
{
|
{
|
||||||
|
_privateFileStorage.TryDelete(storedKey);
|
||||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
_privateFileStorage.TryDelete(storedKey);
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/completion-doc/file")]
|
||||||
|
public async Task<IActionResult> DownloadCompletionDoc(int id)
|
||||||
|
{
|
||||||
|
var stored = await _workOrderCompletionService.GetCompletionDocStoredPathAsync(id);
|
||||||
|
if (stored == null)
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||||
|
|
||||||
|
if (!_privateFileStorage.TryResolvePhysicalPath(stored, out var physicalPath))
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Completion document not found." });
|
||||||
|
|
||||||
|
var contentType = GetContentType(physicalPath);
|
||||||
|
return PhysicalFile(physicalPath, contentType, Path.GetFileName(physicalPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/media")]
|
[HttpGet("{id:int}/media")]
|
||||||
|
|
@ -454,6 +492,16 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
var media = await _workOrderMediaService.GetMediaAsync(id);
|
var media = await _workOrderMediaService.GetMediaAsync(id);
|
||||||
if (media == null)
|
if (media == null)
|
||||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||||
|
|
||||||
|
foreach (var item in media)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.Url)
|
||||||
|
&& item.Url.StartsWith(WorkOrderPrivateFileStorage.RelativeRoot + "/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
item.Url = _privateFileStorage.ToDownloadApiPath(id, "media", item.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(media);
|
return Ok(media);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -467,21 +515,49 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
if (file == null || file.Length == 0)
|
if (file == null || file.Length == 0)
|
||||||
return BadRequest(new Response { Status = "Error", Message = "file is required." });
|
return BadRequest(new Response { Status = "Error", Message = "file is required." });
|
||||||
|
|
||||||
|
if (!WorkOrderUploadFileRules.TryGetAllowedExtension(file.FileName, out _, out var fileError))
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = fileError });
|
||||||
|
|
||||||
|
string? storedKey = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await _workOrderMediaService.EnsureCanAddMediaAsync(id, category);
|
||||||
|
|
||||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var fileUrl = await SaveFileAsync(file);
|
storedKey = await _privateFileStorage.SaveAsync(id, file);
|
||||||
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
|
var media = await _workOrderMediaService.AddMediaAsync(id, category, storedKey, actorId);
|
||||||
|
media.Url = _privateFileStorage.ToDownloadApiPath(id, "media", media.Id);
|
||||||
return Ok(media);
|
return Ok(media);
|
||||||
}
|
}
|
||||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||||
{
|
{
|
||||||
|
_privateFileStorage.TryDelete(storedKey);
|
||||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||||
}
|
}
|
||||||
catch (WorkOrderBoardValidationException ex)
|
catch (WorkOrderBoardValidationException ex)
|
||||||
{
|
{
|
||||||
|
_privateFileStorage.TryDelete(storedKey);
|
||||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
_privateFileStorage.TryDelete(storedKey);
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/media/{mediaId:int}/file")]
|
||||||
|
public async Task<IActionResult> DownloadMedia(int id, int mediaId)
|
||||||
|
{
|
||||||
|
var stored = await _workOrderMediaService.GetMediaStoredPathAsync(id, mediaId);
|
||||||
|
if (stored == null)
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Media not found." });
|
||||||
|
|
||||||
|
if (!_privateFileStorage.TryResolvePhysicalPath(stored, out var physicalPath))
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Media file not found." });
|
||||||
|
|
||||||
|
var contentType = GetContentType(physicalPath);
|
||||||
|
return PhysicalFile(physicalPath, contentType, Path.GetFileName(physicalPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}/media/{mediaId:int}")]
|
[HttpDelete("{id:int}/media/{mediaId:int}")]
|
||||||
|
|
@ -1731,6 +1807,24 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return await _workOrderDataService.GenerateNextInternalWONumberAsync();
|
return await _workOrderDataService.GenerateNextInternalWONumberAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetContentType(string path)
|
||||||
|
{
|
||||||
|
return Path.GetExtension(path).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
".png" => "image/png",
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".doc" => "application/msword",
|
||||||
|
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
".xls" => "application/vnd.ms-excel",
|
||||||
|
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".txt" => "text/plain",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string> SaveFileAsync(IFormFile file)
|
private async Task<string> SaveFileAsync(IFormFile file)
|
||||||
{
|
{
|
||||||
var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"');
|
var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"');
|
||||||
|
|
|
||||||
112
Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs
Normal file
112
Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Helper
|
||||||
|
{
|
||||||
|
public interface IWorkOrderPrivateFileStorage
|
||||||
|
{
|
||||||
|
Task<string> SaveAsync(int workOrderId, IFormFile file, CancellationToken cancellationToken = default);
|
||||||
|
bool TryResolvePhysicalPath(string? storedValue, out string physicalPath);
|
||||||
|
void TryDelete(string? storedValue);
|
||||||
|
string ToDownloadApiPath(int workOrderId, string kind, int? mediaId = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores board completion/media files under ContentRoot/App_Data (not wwwroot).
|
||||||
|
/// DB values are relative keys: workorder-files/{workOrderId}/{guid}{ext}
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkOrderPrivateFileStorage : IWorkOrderPrivateFileStorage
|
||||||
|
{
|
||||||
|
public const string RelativeRoot = "workorder-files";
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
|
public WorkOrderPrivateFileStorage(IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string RootPath => Path.GetFullPath(Path.Combine(_env.ContentRootPath, "App_Data", RelativeRoot));
|
||||||
|
|
||||||
|
public async Task<string> SaveAsync(int workOrderId, IFormFile file, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var clientName = file.FileName;
|
||||||
|
if (string.IsNullOrWhiteSpace(clientName) && !string.IsNullOrWhiteSpace(file.ContentDisposition))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
clientName = System.Net.Http.Headers.ContentDispositionHeaderValue
|
||||||
|
.Parse(file.ContentDisposition).FileName?.Trim('"');
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
clientName = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WorkOrderUploadFileRules.TryGetAllowedExtension(clientName, out var extension, out var error))
|
||||||
|
throw new InvalidOperationException(error ?? "Invalid file.");
|
||||||
|
|
||||||
|
var serverName = WorkOrderUploadFileRules.BuildServerFileName(extension);
|
||||||
|
var relativeKey = $"{RelativeRoot}/{workOrderId}/{serverName}".Replace('\\', '/');
|
||||||
|
var directory = Path.Combine(RootPath, workOrderId.ToString());
|
||||||
|
var fullPath = Path.GetFullPath(Path.Combine(directory, serverName));
|
||||||
|
|
||||||
|
if (!fullPath.StartsWith(RootPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException("Resolved path escapes storage root.");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
await using (var stream = System.IO.File.Create(fullPath))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryResolvePhysicalPath(string? storedValue, out string physicalPath)
|
||||||
|
{
|
||||||
|
physicalPath = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(storedValue))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Accept relative keys; reject absolute URLs / wwwroot legacy paths here.
|
||||||
|
var normalized = storedValue.Replace('\\', '/').TrimStart('/');
|
||||||
|
if (!normalized.StartsWith(RelativeRoot + "/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var relative = normalized[(RelativeRoot.Length + 1)..];
|
||||||
|
var candidate = Path.GetFullPath(Path.Combine(RootPath, relative));
|
||||||
|
if (!candidate.StartsWith(RootPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(candidate))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
physicalPath = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TryDelete(string? storedValue)
|
||||||
|
{
|
||||||
|
if (!TryResolvePhysicalPath(storedValue, out var path))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(path);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ToDownloadApiPath(int workOrderId, string kind, int? mediaId = null)
|
||||||
|
=> kind switch
|
||||||
|
{
|
||||||
|
"completion" => $"/api/workorders/{workOrderId}/completion-doc/file",
|
||||||
|
"media" when mediaId.HasValue => $"/api/workorders/{workOrderId}/media/{mediaId.Value}/file",
|
||||||
|
_ => $"/api/workorders/{workOrderId}/media/file"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,7 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
|
||||||
|
|
||||||
builder.Services.AddScoped<SendMessage>();
|
builder.Services.AddScoped<SendMessage>();
|
||||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
|
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
|
||||||
|
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.IWorkOrderPrivateFileStorage, Api.SeaHavenIndustries.Helper.WorkOrderPrivateFileStorage>();
|
||||||
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
|
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
|
||||||
{
|
{
|
||||||
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
|
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
|
||||||
|
|
|
||||||
53
SeaHaven.Services/Helpers/WorkOrderUploadFileRules.cs
Normal file
53
SeaHaven.Services/Helpers/WorkOrderUploadFileRules.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderUploadFileRules
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".pdf",
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".webp",
|
||||||
|
".gif",
|
||||||
|
".doc",
|
||||||
|
".docx",
|
||||||
|
".xls",
|
||||||
|
".xlsx",
|
||||||
|
".txt"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool TryGetAllowedExtension(string? clientFileName, out string extension, out string? error)
|
||||||
|
{
|
||||||
|
extension = string.Empty;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(clientFileName))
|
||||||
|
{
|
||||||
|
error = "file name is required.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject path segments from the client; only the final name matters for extension.
|
||||||
|
var name = Path.GetFileName(clientFileName.Replace('\\', '/'));
|
||||||
|
if (string.IsNullOrWhiteSpace(name) || name is "." or "..")
|
||||||
|
{
|
||||||
|
error = "Invalid file name.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(ext) || !AllowedExtensions.Contains(ext))
|
||||||
|
{
|
||||||
|
error = $"File type '{ext}' is not allowed.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension = ext.ToLowerInvariant();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildServerFileName(string extension)
|
||||||
|
=> $"{Guid.NewGuid():N}{extension}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,27 +40,28 @@ namespace SeaHaven.Services.Implementation
|
||||||
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
|
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task EnsureCanUploadCompletionDocAsync(int workOrderId, WorkOrderCompletionDocUploadDto request)
|
||||||
|
{
|
||||||
|
var workOrder = await LoadMutableWorkOrderAsync(workOrderId);
|
||||||
|
ValidateUploadEligibility(workOrder, request.WorkOrderVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetCompletionDocStoredPathAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
var workOrder = await _context.workOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||||
|
return workOrder?.SignOffAttachment;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
WorkOrderCompletionDocUploadDto request,
|
WorkOrderCompletionDocUploadDto request,
|
||||||
string fileUrl,
|
string fileUrl,
|
||||||
string? actorId)
|
string? actorId)
|
||||||
{
|
{
|
||||||
var workOrder = await _context.workOrders
|
var workOrder = await LoadMutableWorkOrderAsync(workOrderId);
|
||||||
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
var workOrderVersion = ValidateUploadEligibility(workOrder, request.WorkOrderVersion);
|
||||||
|
|
||||||
if (workOrder == null)
|
|
||||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
|
||||||
|
|
||||||
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
|
||||||
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
|
||||||
|
|
||||||
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
|
|
||||||
if (workOrderVersion == null)
|
|
||||||
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
|
||||||
|
|
||||||
if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion))
|
|
||||||
throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry.");
|
|
||||||
|
|
||||||
_context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion;
|
_context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion;
|
||||||
|
|
||||||
|
|
@ -160,6 +161,32 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<WorkOrder> LoadMutableWorkOrderAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
var workOrder = await _context.workOrders
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||||
|
|
||||||
|
if (workOrder == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
return workOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ValidateUploadEligibility(WorkOrder workOrder, string? workOrderVersionBase64)
|
||||||
|
{
|
||||||
|
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||||
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||||
|
|
||||||
|
var workOrderVersion = ParseRowVersion(workOrderVersionBase64);
|
||||||
|
if (workOrderVersion == null)
|
||||||
|
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
||||||
|
|
||||||
|
if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion))
|
||||||
|
throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry.");
|
||||||
|
|
||||||
|
return workOrderVersion;
|
||||||
|
}
|
||||||
|
|
||||||
private static byte[]? ParseRowVersion(string? base64)
|
private static byte[]? ParseRowVersion(string? base64)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(base64))
|
if (string.IsNullOrWhiteSpace(base64))
|
||||||
|
|
|
||||||
|
|
@ -33,28 +33,44 @@ namespace SeaHaven.Services.Implementation
|
||||||
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
|
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task EnsureCanAddMediaAsync(int workOrderId, WorkOrderMediaCategory category)
|
||||||
|
{
|
||||||
|
await LoadMutableWorkOrderForMediaAsync(workOrderId, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetMediaStoredPathAsync(int workOrderId, int mediaId)
|
||||||
|
{
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (mediaId == -1)
|
||||||
|
{
|
||||||
|
var wo = await _context.workOrders.AsNoTracking().FirstOrDefaultAsync(w => w.Id == workOrderId);
|
||||||
|
return wo?.BeforPhotoAttachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaId == -2)
|
||||||
|
{
|
||||||
|
var wo = await _context.workOrders.AsNoTracking().FirstOrDefaultAsync(w => w.Id == workOrderId);
|
||||||
|
return wo?.AfterPhotoAttachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaId <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var attachment = await _context.workOrderAttachments
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true);
|
||||||
|
return attachment?.Attachments;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
|
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
WorkOrderMediaCategory category,
|
WorkOrderMediaCategory category,
|
||||||
string fileUrl,
|
string fileUrl,
|
||||||
string? actorId)
|
string? actorId)
|
||||||
{
|
{
|
||||||
if (!await _detailData.ExistsAsync(workOrderId))
|
var workOrder = await LoadMutableWorkOrderForMediaAsync(workOrderId, category);
|
||||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
|
||||||
|
|
||||||
var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId);
|
|
||||||
if (workOrder == null)
|
|
||||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
|
||||||
|
|
||||||
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
|
||||||
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
|
||||||
|
|
||||||
if (category == WorkOrderMediaCategory.Completion)
|
|
||||||
{
|
|
||||||
throw new WorkOrderBoardValidationException(
|
|
||||||
"UseCompletionDocEndpoint",
|
|
||||||
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category == WorkOrderMediaCategory.Before)
|
if (category == WorkOrderMediaCategory.Before)
|
||||||
{
|
{
|
||||||
|
|
@ -130,5 +146,27 @@ namespace SeaHaven.Services.Implementation
|
||||||
attachment.DeleterUserId = actorId;
|
attachment.DeleterUserId = actorId;
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<WorkOrder> LoadMutableWorkOrderForMediaAsync(int workOrderId, WorkOrderMediaCategory category)
|
||||||
|
{
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId))
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId);
|
||||||
|
if (workOrder == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||||
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||||
|
|
||||||
|
if (category == WorkOrderMediaCategory.Completion)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"UseCompletionDocEndpoint",
|
||||||
|
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return workOrder;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
var failed = 0;
|
var failed = 0;
|
||||||
|
|
||||||
|
// Log only system-formatted dates (yyyy-MM-dd) — avoid free-form user-derived strings in logs.
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}",
|
"WeekRolled job started. SourceWeekStart={SourceWeekStart:yyyy-MM-dd}, SourceWeekEnd={SourceWeekEnd:yyyy-MM-dd}",
|
||||||
correlationId, sourceWeekStart, sourceWeekEnd);
|
sourceWeekStart, sourceWeekEnd);
|
||||||
|
|
||||||
// Always fetch the first page of remaining candidates. Processed rows write ledger
|
// Always fetch the first page of remaining candidates. Processed rows write ledger
|
||||||
// entries that exclude them from the next query; offset paging would skip unprocessed rows.
|
// entries that exclude them from the next query; offset paging would skip unprocessed rows.
|
||||||
|
|
@ -78,8 +79,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
failed++;
|
failed++;
|
||||||
_logger.LogError(ex,
|
_logger.LogError(ex,
|
||||||
"WeekRolled failed for WorkOrderId={WorkOrderId}, CorrelationId={CorrelationId}",
|
"WeekRolled failed for WorkOrderId={WorkOrderId}, SourceWeekStart={SourceWeekStart:yyyy-MM-dd}",
|
||||||
workOrderId, correlationId);
|
workOrderId, sourceWeekStart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,8 +102,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}",
|
"WeekRolled job completed. SourceWeekStart={SourceWeekStart:yyyy-MM-dd}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}",
|
||||||
correlationId, processed, skipped, failed, result.DurationMs);
|
sourceWeekStart, processed, skipped, failed, result.DurationMs);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ namespace SeaHaven.Services.Interfaces
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
|
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
|
||||||
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
|
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
|
||||||
|
Task EnsureCanUploadCompletionDocAsync(int workOrderId, WorkOrderCompletionDocUploadDto request);
|
||||||
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
WorkOrderCompletionDocUploadDto request,
|
WorkOrderCompletionDocUploadDto request,
|
||||||
string fileUrl,
|
string fileUrl,
|
||||||
string? actorId);
|
string? actorId);
|
||||||
|
Task<string?> GetCompletionDocStoredPathAsync(int workOrderId);
|
||||||
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
|
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
|
||||||
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
|
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
|
||||||
Task DeleteTemplateAsync(int id);
|
Task DeleteTemplateAsync(int id);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ namespace SeaHaven.Services.Interfaces
|
||||||
public interface IWorkOrderMediaService
|
public interface IWorkOrderMediaService
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
|
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
|
||||||
|
Task EnsureCanAddMediaAsync(int workOrderId, WorkOrderMediaCategory category);
|
||||||
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId);
|
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId);
|
||||||
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
|
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
|
||||||
|
Task<string?> GetMediaStoredPathAsync(int workOrderId, int mediaId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
116
SeaHavenIndustries.Tests/WorkOrderUploadFileRulesTests.cs
Normal file
116
SeaHavenIndustries.Tests/WorkOrderUploadFileRulesTests.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderUploadFileRulesTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("report.pdf", ".pdf")]
|
||||||
|
[InlineData(@"C:\temp\photo.PNG", ".png")]
|
||||||
|
[InlineData("../evil.jpg", ".jpg")]
|
||||||
|
public void TryGetAllowedExtension_AcceptsSafeTypes(string name, string expectedExt)
|
||||||
|
{
|
||||||
|
var ok = WorkOrderUploadFileRules.TryGetAllowedExtension(name, out var ext, out var error);
|
||||||
|
Assert.True(ok, error);
|
||||||
|
Assert.Equal(expectedExt, ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("payload.exe")]
|
||||||
|
[InlineData("noext")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("..")]
|
||||||
|
public void TryGetAllowedExtension_RejectsUnsafeTypes(string name)
|
||||||
|
{
|
||||||
|
var ok = WorkOrderUploadFileRules.TryGetAllowedExtension(name, out _, out var error);
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildServerFileName_IsGuidPlusExtension_NoClientName()
|
||||||
|
{
|
||||||
|
var name = WorkOrderUploadFileRules.BuildServerFileName(".pdf");
|
||||||
|
Assert.StartsWith(name[..32], name);
|
||||||
|
Assert.EndsWith(".pdf", name);
|
||||||
|
Assert.DoesNotContain("..", name);
|
||||||
|
Assert.DoesNotContain("/", name);
|
||||||
|
Assert.DoesNotContain("\\", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderUploadValidationOrderTests
|
||||||
|
{
|
||||||
|
private static (ApplicationDbContext Context, WorkOrderCompletionService Completion, WorkOrderMediaService Media) CreateSut()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
var templateData = new CompletionDocTemplateDataService(context);
|
||||||
|
var detailData = new WorkOrderDetailDataService(context);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var completion = new WorkOrderCompletionService(context, templateData, detailData, audit);
|
||||||
|
var media = new WorkOrderMediaService(context, detailData);
|
||||||
|
return (context, completion, media);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureCanUploadCompletionDoc_NotFound_ThrowsBeforeAnyPersist()
|
||||||
|
{
|
||||||
|
var (_, completion, _) = CreateSut();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
completion.EnsureCanUploadCompletionDocAsync(
|
||||||
|
999,
|
||||||
|
new WorkOrderCompletionDocUploadDto
|
||||||
|
{
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Equal("NotFound", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureCanUploadCompletionDoc_ReadOnly_Throws()
|
||||||
|
{
|
||||||
|
var (context, completion, _) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Completed,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
completion.EnsureCanUploadCompletionDocAsync(
|
||||||
|
1,
|
||||||
|
new WorkOrderCompletionDocUploadDto
|
||||||
|
{
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Equal("ReadOnly", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureCanAddMedia_NotFound_Throws()
|
||||||
|
{
|
||||||
|
var (_, _, media) = CreateSut();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
media.EnsureCanAddMediaAsync(999, WorkOrderMediaCategory.Before));
|
||||||
|
|
||||||
|
Assert.Equal("NotFound", ex.Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue