fix(work-orders): harden media upload contract for review blockers

Enforce MIME/extension/magic-byte validation, auth and workOrderVersion concurrency, audit on category changes, and validate-before-store with blob compensate.
This commit is contained in:
Arthur Bassi 2026-08-04 09:30:09 -03:00
parent d073a503d1
commit 2ec85d1193
10 changed files with 578 additions and 125 deletions

View file

@ -29,9 +29,9 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("{id:int}/media")]
public async Task<IActionResult> GetMedia(int id)
public async Task<IActionResult> GetMedia(int id, CancellationToken cancellationToken)
{
var media = await _workOrderMediaService.GetMediaAsync(id);
var media = await _workOrderMediaService.GetMediaAsync(id, cancellationToken);
if (media == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(media);
@ -42,45 +42,74 @@ namespace Api.SeaHavenIndustries.Controllers
public async Task<IActionResult> AddMedia(
int id,
[FromForm] WorkOrderMediaCategory? category,
[FromForm] IFormFile file)
[FromForm] IFormFile file,
CancellationToken cancellationToken)
{
if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "file is required." });
string? fileUrl = null;
try
{
WorkOrderMediaFileRules.EnsureAllowed(file);
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await _fileStorage.SaveFileAsync(file);
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, actorId, cancellationToken);
fileUrl = await _fileStorage.SaveFileAsync(file);
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId, cancellationToken);
return Ok(media);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
TryCompensateUpload(fileUrl);
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
TryCompensateUpload(fileUrl);
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
TryCompensateUpload(fileUrl);
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch
{
TryCompensateUpload(fileUrl);
throw;
}
}
[HttpPatch("{id:int}/media/{mediaId:int}")]
public async Task<IActionResult> UpdateMediaCategory(
int id,
int mediaId,
[FromForm] WorkOrderMediaCategory category)
[FromForm] WorkOrderMediaCategory category,
[FromForm] string? workOrderVersion = null,
CancellationToken cancellationToken = default)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var media = await _workOrderMediaService.UpdateMediaCategoryAsync(id, mediaId, category, actorId);
var media = await _workOrderMediaService.UpdateMediaCategoryAsync(
id, mediaId, category, workOrderVersion, actorId, cancellationToken);
return Ok(media);
}
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 });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "ConcurrencyConflict")
{
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
@ -88,22 +117,41 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpDelete("{id:int}/media/{mediaId:int}")]
public async Task<IActionResult> DeleteMedia(int id, int mediaId)
public async Task<IActionResult> DeleteMedia(
int id,
int mediaId,
[FromQuery] string? workOrderVersion = null,
CancellationToken cancellationToken = default)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId);
await _workOrderMediaService.DeleteMediaAsync(id, mediaId, workOrderVersion, actorId, cancellationToken);
return NoContent();
}
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 });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "ConcurrencyConflict")
{
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
private void TryCompensateUpload(string? fileUrl)
{
if (!string.IsNullOrWhiteSpace(fileUrl))
_fileStorage.TryDelete(fileUrl);
}
}
}

View file

@ -23,8 +23,7 @@ namespace Api.SeaHavenIndustries.Infrastructure
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
var uploadPath = Path.Combine("Assets", "Documents");
var webRoot = _webHostEnvironment.WebRootPath
?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot");
var webRoot = ResolveWebRoot();
var fullPath = Path.Combine(webRoot, uploadPath, uniqueFileName);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
@ -39,6 +38,41 @@ namespace Api.SeaHavenIndustries.Infrastructure
var domain = $"{request.Scheme}://{request.Host}";
return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}";
}
public bool TryDelete(string fileUrl)
{
if (string.IsNullOrWhiteSpace(fileUrl))
return false;
try
{
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
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;
System.IO.File.Delete(fullPath);
return true;
}
catch
{
return false;
}
}
private string ResolveWebRoot()
=> _webHostEnvironment.WebRootPath
?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot");
}
public class DispatchEmailAdapter : IDispatchEmailPort

View file

@ -24,6 +24,18 @@ namespace SeaHaven.DataServices.Implementation
public void TrackAttachment(WorkOrderAttachments attachment)
=> _context.workOrderAttachments.Add(attachment);
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
public void MarkWorkOrderModified(WorkOrder workOrder)
{
// Force a WO update so the RowVersion concurrency token is enforced when only
// attachment rows change (category-only Extra mutations).
var entry = _context.Entry(workOrder);
if (entry.State == EntityState.Unchanged)
entry.Property(w => w.Attachments).IsModified = true;
}
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}

View file

@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
void MarkWorkOrderModified(WorkOrder workOrder);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -71,27 +71,27 @@ namespace SeaHaven.Services.Helpers
{
var canonical = Canonicalize(field) ?? field;
return canonical switch
{
WoNumber => "InternalWONumber",
WorkOrderType => "WorkOrderType",
SiteCode => "SiteCode",
LifecycleStatus => "LifecycleStatus",
AssignTo => "AssignTo",
DueDate => "DueDate",
ScheduledDate => "ScheduledDate",
TargetWeek => "TargetWeek",
ScheduleWeekOnly => "ScheduleWeekOnly",
VendorId => "VendorId",
ApptDate => "ApptDate",
ApptTime => "ApptTime",
DocStatus => "DocStatus",
CompletedDate => "CompletedDate",
Pm => "Trade",
ServiceNotes => "ServiceNotes",
ExtraServices => "ExtraServices",
FlagColor => "FlagColor",
_ => canonical
};
{
WoNumber => "InternalWONumber",
WorkOrderType => "WorkOrderType",
SiteCode => "SiteCode",
LifecycleStatus => "LifecycleStatus",
AssignTo => "AssignTo",
DueDate => "DueDate",
ScheduledDate => "ScheduledDate",
TargetWeek => "TargetWeek",
ScheduleWeekOnly => "ScheduleWeekOnly",
VendorId => "VendorId",
ApptDate => "ApptDate",
ApptTime => "ApptTime",
DocStatus => "DocStatus",
CompletedDate => "CompletedDate",
Pm => "Trade",
ServiceNotes => "ServiceNotes",
ExtraServices => "ExtraServices",
FlagColor => "FlagColor",
_ => canonical
};
}
}
}

View file

@ -13,29 +13,53 @@ namespace SeaHaven.Services.Helpers
"video/quicktime"
};
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg",
".jpeg",
".png",
".mp4",
".mov"
};
private static readonly Dictionary<string, HashSet<string>> ExtensionsByContentType =
new(StringComparer.OrdinalIgnoreCase)
{
["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" }
};
public static bool IsAllowed(IFormFile file)
{
if (file == null)
if (file == null || file.Length <= 0)
return false;
var contentTypeOk = !string.IsNullOrWhiteSpace(file.ContentType)
&& AllowedContentTypes.Contains(file.ContentType.Trim());
var contentType = (file.ContentType ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType))
return false;
var extension = Path.GetExtension(file.FileName ?? "");
var extensionOk = !string.IsNullOrWhiteSpace(extension)
&& AllowedExtensions.Contains(extension);
var extension = Path.GetExtension(file.FileName ?? string.Empty);
if (string.IsNullOrWhiteSpace(extension)
|| !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions)
|| !allowedExtensions.Contains(extension))
{
return false;
}
// Accept when either MIME or extension matches the advertised contract (JPG/PNG/MP4/MOV).
return contentTypeOk || extensionOk;
try
{
using var stream = file.OpenReadStream();
var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64);
if (headerLength == 0)
return false;
var header = new byte[headerLength];
var read = stream.Read(header, 0, header.Length);
if (read <= 0)
return false;
if (read < header.Length)
Array.Resize(ref header, read);
return MatchesSignature(contentType, header);
}
catch
{
return false;
}
}
public static void EnsureAllowed(IFormFile file)
@ -47,5 +71,43 @@ namespace SeaHaven.Services.Helpers
"Supported media types are JPG, PNG, MP4, and MOV.");
}
}
internal static bool MatchesSignature(string contentType, byte[] bytes)
{
if (bytes.Length == 0)
return false;
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 8
&& bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
}
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
}
if (contentType.Equals("video/mp4", StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
{
return HasFtypBox(bytes);
}
return false;
}
private static bool HasFtypBox(byte[] bytes)
{
if (bytes.Length < 12)
return false;
// ISO BMFF: [size:4][ftyp:4][major_brand:4]...
return bytes[4] == (byte)'f'
&& bytes[5] == (byte)'t'
&& bytes[6] == (byte)'y'
&& bytes[7] == (byte)'p';
}
}
}

View file

@ -12,14 +12,21 @@ namespace SeaHaven.Services.Implementation
{
private readonly IWorkOrderMediaDataService _mediaData;
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService;
public WorkOrderMediaService(IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData)
public WorkOrderMediaService(
IWorkOrderMediaDataService mediaData,
IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService)
{
_mediaData = mediaData;
_detailData = detailData;
_auditService = auditService;
}
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId)
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
int workOrderId,
CancellationToken cancellationToken = default)
{
if (!await _detailData.ExistsAsync(workOrderId))
return null;
@ -32,23 +39,26 @@ namespace SeaHaven.Services.Implementation
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
}
public async Task EnsureCanMutateMediaAsync(
int workOrderId,
string? actorId,
CancellationToken cancellationToken = default)
{
EnsureAuthenticatedActor(actorId);
await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
}
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
int workOrderId,
WorkOrderMediaCategory? category,
string fileUrl,
string? actorId)
string? actorId,
CancellationToken cancellationToken = default)
{
EnsureAuthenticatedActor(actorId);
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
if (!await _detailData.ExistsAsync(workOrderId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
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 workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
if (resolvedCategory == WorkOrderMediaCategory.Completion)
{
@ -59,8 +69,11 @@ namespace SeaHaven.Services.Implementation
if (resolvedCategory == WorkOrderMediaCategory.Before)
{
var oldBefore = workOrder.BeforPhotoAttachment;
workOrder.BeforPhotoAttachment = fileUrl;
await _mediaData.SaveAsync(CancellationToken.None);
await _auditService.StageFieldChangedAsync(
workOrderId, "BeforPhotoAttachment", oldBefore, fileUrl, actorId);
await _mediaData.SaveAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
Id = -1,
@ -72,8 +85,11 @@ namespace SeaHaven.Services.Implementation
if (resolvedCategory == WorkOrderMediaCategory.After)
{
var oldAfter = workOrder.AfterPhotoAttachment;
workOrder.AfterPhotoAttachment = fileUrl;
await _mediaData.SaveAsync(CancellationToken.None);
await _auditService.StageFieldChangedAsync(
workOrderId, "AfterPhotoAttachment", oldAfter, fileUrl, actorId);
await _mediaData.SaveAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
Id = -2,
@ -93,7 +109,13 @@ namespace SeaHaven.Services.Implementation
};
_mediaData.TrackAttachment(attachment);
await _mediaData.SaveAsync(CancellationToken.None);
await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
null,
FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()),
actorId);
await _mediaData.SaveAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
@ -109,8 +131,12 @@ namespace SeaHaven.Services.Implementation
int workOrderId,
int mediaId,
WorkOrderMediaCategory category,
string? actorId)
string? workOrderVersion,
string? actorId,
CancellationToken cancellationToken = default)
{
EnsureAuthenticatedActor(actorId);
if (mediaId <= 0)
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint.");
@ -121,20 +147,15 @@ namespace SeaHaven.Services.Implementation
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
}
if (!await _detailData.ExistsAsync(workOrderId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
ApplyExpectedVersion(workOrder, workOrderVersion);
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
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 attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, CancellationToken.None);
var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken);
if (attachment == null)
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
var priorCategory = (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString();
if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After)
{
var url = attachment.Attachments ?? "";
@ -146,7 +167,14 @@ namespace SeaHaven.Services.Implementation
attachment.IsDeleted = true;
attachment.DeletionTime = DateTime.UtcNow;
attachment.DeleterUserId = actorId;
await _mediaData.SaveAsync(CancellationToken.None);
await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
FormatMediaAuditValue(mediaId, priorCategory),
FormatMediaAuditValue(mediaId, category.ToString()),
actorId);
await _mediaData.SaveAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
@ -158,7 +186,14 @@ namespace SeaHaven.Services.Implementation
}
attachment.Category = category;
await _mediaData.SaveAsync(CancellationToken.None);
_mediaData.MarkWorkOrderModified(workOrder);
await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
FormatMediaAuditValue(mediaId, priorCategory),
FormatMediaAuditValue(mediaId, category.ToString()),
actorId);
await _mediaData.SaveAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
@ -170,30 +205,89 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId)
public async Task DeleteMediaAsync(
int workOrderId,
int mediaId,
string? workOrderVersion,
string? actorId,
CancellationToken cancellationToken = default)
{
EnsureAuthenticatedActor(actorId);
if (mediaId <= 0)
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint.");
var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
ApplyExpectedVersion(workOrder, workOrderVersion);
var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken);
if (attachment == null)
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
var priorCategory = (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString();
attachment.IsDeleted = true;
attachment.DeletionTime = DateTime.UtcNow;
attachment.DeleterUserId = actorId;
_mediaData.MarkWorkOrderModified(workOrder);
await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
FormatMediaAuditValue(mediaId, priorCategory),
FormatMediaAuditValue(mediaId, "Deleted"),
actorId);
await _mediaData.SaveAsync(cancellationToken);
}
private async Task<WorkOrder> GetMutableWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
{
if (!await _detailData.ExistsAsync(workOrderId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken);
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 attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, CancellationToken.None);
if (attachment == null)
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
attachment.IsDeleted = true;
attachment.DeletionTime = DateTime.UtcNow;
attachment.DeleterUserId = actorId;
await _mediaData.SaveAsync(CancellationToken.None);
return workOrder;
}
private void ApplyExpectedVersion(WorkOrder workOrder, string? workOrderVersion)
{
var expected = ParseRowVersion(workOrderVersion);
if (expected == null)
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(expected))
throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry.");
_mediaData.SetExpectedWorkOrderVersion(workOrder, expected);
}
private static void EnsureAuthenticatedActor(string? actorId)
{
if (string.IsNullOrWhiteSpace(actorId))
throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to mutate work order media.");
}
private static byte[]? ParseRowVersion(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return null;
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
}
}
private static string FormatMediaAuditValue(int? mediaId, string category)
=> mediaId.HasValue ? $"{mediaId.Value}:{category}" : category;
}
}

View file

@ -18,6 +18,12 @@ namespace SeaHaven.Services.Interfaces
public interface IFileStoragePort
{
Task<string> SaveFileAsync(IFormFile file);
/// <summary>
/// Best-effort delete of a previously saved file URL. Returns false when the file
/// cannot be resolved or removed; never throws for missing paths.
/// </summary>
bool TryDelete(string fileUrl);
}
/// <summary>

View file

@ -5,17 +5,33 @@ namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderMediaService
{
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId, CancellationToken cancellationToken = default);
/// <summary>
/// Authorizes the caller and validates the work order is mutable before any blob storage write.
/// </summary>
Task EnsureCanMutateMediaAsync(int workOrderId, string? actorId, CancellationToken cancellationToken = default);
Task<WorkOrderMediaFileDto> AddMediaAsync(
int workOrderId,
WorkOrderMediaCategory? category,
string fileUrl,
string? actorId);
string? actorId,
CancellationToken cancellationToken = default);
Task<WorkOrderMediaFileDto> UpdateMediaCategoryAsync(
int workOrderId,
int mediaId,
WorkOrderMediaCategory category,
string? actorId);
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
string? workOrderVersion,
string? actorId,
CancellationToken cancellationToken = default);
Task DeleteMediaAsync(
int workOrderId,
int mediaId,
string? workOrderVersion,
string? actorId,
CancellationToken cancellationToken = default);
}
}

View file

@ -1,5 +1,7 @@
using System.Text;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
@ -569,13 +571,28 @@ public class WorkOrderCommentServiceTests
public class WorkOrderMediaServiceTests
{
[Fact]
public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra()
private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit);
return (context, service);
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
[Fact]
public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
@ -590,9 +607,6 @@ public class WorkOrderMediaServiceTests
});
await context.SaveChangesAsync();
var detailData = new WorkOrderDetailDataService(context);
var mediaData = new WorkOrderMediaDataService(context);
var service = new WorkOrderMediaService(mediaData, detailData);
var media = await service.GetMediaAsync(1);
Assert.NotNull(media);
@ -605,14 +619,12 @@ public class WorkOrderMediaServiceTests
[Fact]
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Completed
LifecycleStatus = LifecycleStatus.Completed,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
@ -623,9 +635,8 @@ public class WorkOrderMediaServiceTests
});
await context.SaveChangesAsync();
var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context));
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.DeleteMediaAsync(1, 10, "actor-1"));
service.DeleteMediaAsync(1, 10, ToVersion(context.workOrders.Single()), "actor-1"));
Assert.Equal("ReadOnly", ex.Code);
}
@ -633,18 +644,15 @@ public class WorkOrderMediaServiceTests
[Fact]
public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context));
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1"));
@ -654,37 +662,61 @@ public class WorkOrderMediaServiceTests
[Fact]
public async Task AddMedia_WithoutCategory_PersistsAsUncategorizedExtra()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context));
var media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", "actor-1");
Assert.True(media.Id > 0);
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
Assert.Null(context.workOrderAttachments.Single().Category);
Assert.Contains(context.WorkOrderAuditLogs, a => a.FieldName == "MediaCategory");
}
[Fact]
public async Task AddMedia_MissingActor_ThrowsForbidden()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", null));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.EnsureCanMutateMediaAsync(99, "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task UpdateMediaCategory_SetsBeforeFromExtraAttachment()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled
});
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,
@ -694,12 +726,159 @@ public class WorkOrderMediaServiceTests
});
await context.SaveChangesAsync();
var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context));
var media = await service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, "actor-1");
var media = await service.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), "actor-1");
Assert.Equal(-1, media.Id);
Assert.Equal(WorkOrderMediaCategory.Before, media.Category);
Assert.Equal("https://example.com/photo.jpg", context.workOrders.Single().BeforPhotoAttachment);
Assert.True(context.workOrderAttachments.Single().IsDeleted);
Assert.Contains(context.WorkOrderAuditLogs, a =>
a.FieldName == "MediaCategory" && a.NewValue == "10:Before");
}
[Fact]
public async Task UpdateMediaCategory_StaleVersion_ThrowsConcurrencyConflict()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var stale = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, stale, "actor-1"));
Assert.Equal("ConcurrencyConflict", ex.Code);
}
[Fact]
public async Task UpdateMediaCategory_CrossWorkOrderMedia_ThrowsNotFound()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.workOrders.Add(new WorkOrder
{
Id = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 2,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var version = ToVersion(context.workOrders.Single(w => w.Id == 1));
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, version, "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task UpdateMediaCategory_DeletedMedia_ThrowsNotFound()
{
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 = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra,
IsDeleted = true
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
}
public class WorkOrderMediaFileRulesTests
{
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
=> new(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
[Fact]
public void IsAllowed_ValidJpeg_ReturnsTrue()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg")));
}
[Fact]
public void IsAllowed_SpoofedExtension_ReturnsFalse()
{
var html = Encoding.UTF8.GetBytes("<html>not an image</html>");
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(html, "photo.jpg", "image/jpeg")));
}
[Fact]
public void IsAllowed_SpoofedMime_ReturnsFalse()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.exe", "image/jpeg")));
}
[Fact]
public void IsAllowed_MimeOrExtensionAlone_ReturnsFalse()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.bin", "application/octet-stream")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(new byte[] { 1, 2, 3 }, "photo.jpg", "image/jpeg")));
}
[Fact]
public void IsAllowed_ValidMp4Ftyp_ReturnsTrue()
{
// size(4) + ftyp(4) + isom(4)
var mp4 = new byte[]
{
0x00, 0x00, 0x00, 0x18,
(byte)'f', (byte)'t', (byte)'y', (byte)'p',
(byte)'i', (byte)'s', (byte)'o', (byte)'m'
};
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(mp4, "clip.mp4", "video/mp4")));
}
[Fact]
public void EnsureAllowed_MalformedBytes_ThrowsUnsupportedMediaType()
{
var ex = Assert.Throws<WorkOrderBoardValidationException>(() =>
WorkOrderMediaFileRules.EnsureAllowed(FormFile(new byte[] { 0x00 }, "a.png", "image/png")));
Assert.Equal("UnsupportedMediaType", ex.Code);
}
}