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:
Arthur Bassi 2026-07-21 09:25:33 -03:00
parent 755a95bdc3
commit 719a5b05fd
12 changed files with 587 additions and 50 deletions

View file

@ -15,6 +15,7 @@ using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Api.SeaHavenIndustries.Helper;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
@ -64,7 +65,8 @@ public class WorkOrderControllerSearchTests
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
Mock.Of<IWorkOrderMediaService>(),
Mock.Of<IWorkOrderPrivateFileStorage>());
controller.ControllerContext = new ControllerContext
{
@ -138,7 +140,8 @@ public class WorkOrderControllerSearchTests
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
Mock.Of<IWorkOrderMediaService>(),
Mock.Of<IWorkOrderPrivateFileStorage>());
controllerWithBoard.ControllerContext = new ControllerContext
{

View file

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

View file

@ -19,7 +19,9 @@ using static System.Runtime.InteropServices.JavaScript.JSType;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.DataServices.Interfaces;
using Api.SeaHavenIndustries.Helper;
namespace Api.SeaHavenIndustries.Controllers
{
@ -53,6 +55,7 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly IWorkOrderCommentService _workOrderCommentService;
private readonly IWorkOrderCompletionService _workOrderCompletionService;
private readonly IWorkOrderMediaService _workOrderMediaService;
private readonly IWorkOrderPrivateFileStorage _privateFileStorage;
public WorkOrderController(
UserManager<ApplicationUser> userManager,
@ -78,7 +81,8 @@ namespace Api.SeaHavenIndustries.Controllers
IWorkOrderDetailService workOrderDetailService,
IWorkOrderCommentService workOrderCommentService,
IWorkOrderCompletionService workOrderCompletionService,
IWorkOrderMediaService workOrderMediaService)
IWorkOrderMediaService workOrderMediaService,
IWorkOrderPrivateFileStorage privateFileStorage)
{
_userManager = userManager;
_workOrderService = workOrderService;
@ -104,6 +108,7 @@ namespace Api.SeaHavenIndustries.Controllers
_workOrderCommentService = workOrderCommentService;
_workOrderCompletionService = workOrderCompletionService;
_workOrderMediaService = workOrderMediaService;
_privateFileStorage = privateFileStorage;
}
[HttpGet("board")]
@ -418,34 +423,67 @@ namespace Api.SeaHavenIndustries.Controllers
if (file == null || file.Length == 0)
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
{
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 fileUrl = await SaveFileAsync(file);
storedKey = await _privateFileStorage.SaveAsync(id, file);
var downloadUrl = _privateFileStorage.ToDownloadApiPath(id, "completion");
var result = await _workOrderCompletionService.UploadCompletionDocAsync(
id,
new WorkOrderCompletionDocUploadDto
{
SignOffName = signOffName,
SignOffSignature = signOffSignature,
WorkOrderVersion = workOrderVersion
},
fileUrl,
uploadDto,
storedKey,
actorId);
result.SignOffAttachment = downloadUrl;
return Ok(result);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
{
_privateFileStorage.TryDelete(storedKey);
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
{
_privateFileStorage.TryDelete(storedKey);
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
_privateFileStorage.TryDelete(storedKey);
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")]
@ -454,6 +492,16 @@ namespace Api.SeaHavenIndustries.Controllers
var media = await _workOrderMediaService.GetMediaAsync(id);
if (media == null)
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);
}
@ -467,21 +515,49 @@ namespace Api.SeaHavenIndustries.Controllers
if (file == null || file.Length == 0)
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
{
await _workOrderMediaService.EnsureCanAddMediaAsync(id, category);
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await SaveFileAsync(file);
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
storedKey = await _privateFileStorage.SaveAsync(id, file);
var media = await _workOrderMediaService.AddMediaAsync(id, category, storedKey, actorId);
media.Url = _privateFileStorage.ToDownloadApiPath(id, "media", media.Id);
return Ok(media);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
_privateFileStorage.TryDelete(storedKey);
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
_privateFileStorage.TryDelete(storedKey);
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}")]
@ -1731,6 +1807,24 @@ namespace Api.SeaHavenIndustries.Controllers
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)
{
var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"');

View 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"
};
}
}

View file

@ -44,6 +44,7 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
builder.Services.AddScoped<SendMessage>();
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 =>
{
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();

View 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}";
}
}

View file

@ -40,27 +40,28 @@ namespace SeaHaven.Services.Implementation
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(
int workOrderId,
WorkOrderCompletionDocUploadDto request,
string fileUrl,
string? actorId)
{
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.");
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.");
var workOrder = await LoadMutableWorkOrderAsync(workOrderId);
var workOrderVersion = ValidateUploadEligibility(workOrder, request.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)
{
if (string.IsNullOrWhiteSpace(base64))

View file

@ -33,28 +33,44 @@ namespace SeaHaven.Services.Implementation
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(
int workOrderId,
WorkOrderMediaCategory category,
string fileUrl,
string? actorId)
{
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.");
}
var workOrder = await LoadMutableWorkOrderForMediaAsync(workOrderId, category);
if (category == WorkOrderMediaCategory.Before)
{
@ -130,5 +146,27 @@ namespace SeaHaven.Services.Implementation
attachment.DeleterUserId = actorId;
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;
}
}
}

View file

@ -37,9 +37,10 @@ namespace SeaHaven.Services.Implementation
var skipped = 0;
var failed = 0;
// Log only system-formatted dates (yyyy-MM-dd) — avoid free-form user-derived strings in logs.
_logger.LogInformation(
"WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}",
correlationId, sourceWeekStart, sourceWeekEnd);
"WeekRolled job started. SourceWeekStart={SourceWeekStart:yyyy-MM-dd}, SourceWeekEnd={SourceWeekEnd:yyyy-MM-dd}",
sourceWeekStart, sourceWeekEnd);
// 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.
@ -78,8 +79,8 @@ namespace SeaHaven.Services.Implementation
{
failed++;
_logger.LogError(ex,
"WeekRolled failed for WorkOrderId={WorkOrderId}, CorrelationId={CorrelationId}",
workOrderId, correlationId);
"WeekRolled failed for WorkOrderId={WorkOrderId}, SourceWeekStart={SourceWeekStart:yyyy-MM-dd}",
workOrderId, sourceWeekStart);
}
}
@ -101,8 +102,8 @@ namespace SeaHaven.Services.Implementation
};
_logger.LogInformation(
"WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}",
correlationId, processed, skipped, failed, result.DurationMs);
"WeekRolled job completed. SourceWeekStart={SourceWeekStart:yyyy-MM-dd}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}",
sourceWeekStart, processed, skipped, failed, result.DurationMs);
return result;
}

View file

@ -7,11 +7,13 @@ namespace SeaHaven.Services.Interfaces
{
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
Task EnsureCanUploadCompletionDocAsync(int workOrderId, WorkOrderCompletionDocUploadDto request);
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId,
WorkOrderCompletionDocUploadDto request,
string fileUrl,
string? actorId);
Task<string?> GetCompletionDocStoredPathAsync(int workOrderId);
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
Task DeleteTemplateAsync(int id);

View file

@ -6,7 +6,9 @@ namespace SeaHaven.Services.Interfaces
public interface IWorkOrderMediaService
{
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
Task EnsureCanAddMediaAsync(int workOrderId, WorkOrderMediaCategory category);
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId);
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
Task<string?> GetMediaStoredPathAsync(int workOrderId, int mediaId);
}
}

View 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);
}
}