mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 21:13:12 +00:00
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:
parent
d073a503d1
commit
2ec85d1193
10 changed files with 578 additions and 125 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue