From d073a503d1b05801ec18d7def9b0f7d360143dad Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 31 Jul 2026 11:58:22 -0300 Subject: [PATCH 01/16] feat(work-orders): board completedDate + media categorize contract Expose completedDate on PATCH /workorders/{id}/board so CompDoc can leave legacy EditWorkorder. Allow optional media category on upload, PATCH category afterward, and enforce JPG/PNG/MP4/MOV allowlist (SH-116). --- .../WorkOrderRouteContractTests.cs | 5 +- .../Controllers/WorkOrderMediaController.cs | 26 +++++- .../Helpers/WorkOrderBoardProjection.cs | 2 + .../Interfaces/WorkOrderBoardModels.cs | 1 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 1 + SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 1 - .../Helpers/WorkOrderBoardFieldNames.cs | 3 + .../Helpers/WorkOrderMediaFileRules.cs | 51 ++++++++++++ .../Implementation/WorkOrderBoardService.cs | 1 + .../WorkOrderBoardUpdateService.cs | 1 + .../Implementation/WorkOrderMediaService.cs | 83 +++++++++++++++++-- .../Interfaces/IWorkOrderMediaService.cs | 11 ++- .../WorkOrderBoardUpdateServiceTests.cs | 55 ++++++++++++ .../WorkOrderPhase6Tests.cs | 52 ++++++++++++ 14 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index dd1d0c5..5870f16 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests /// Baseline public endpoint set (verb + action-relative route) that the original single /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route - /// duplication is collapsed here, so this is the distinct action-relative contract. 46 routes - /// come from 44 actions (Editworkorder and GetWorkorderById each bind two routes). + /// duplication is collapsed here, so this is the distinct action-relative contract. 47 routes + /// come from 45 actions (Editworkorder and GetWorkorderById each bind two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { @@ -68,6 +68,7 @@ public class WorkOrderRouteContractTests "GET {id:int}/media", "PATCH {id:int}/board", "PATCH {id:int}/comments/{commentId:int}", + "PATCH {id:int}/media/{mediaId:int}", "POST AddChecklistItem", "POST AddComment", "POST AddCommentJson", diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 2664d65..73e5a69 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -40,7 +41,7 @@ namespace Api.SeaHavenIndustries.Controllers [RequestSizeLimit(30_000_000)] public async Task AddMedia( int id, - [FromForm] WorkOrderMediaCategory category, + [FromForm] WorkOrderMediaCategory? category, [FromForm] IFormFile file) { if (file == null || file.Length == 0) @@ -48,6 +49,7 @@ namespace Api.SeaHavenIndustries.Controllers 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); @@ -63,6 +65,28 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPatch("{id:int}/media/{mediaId:int}")] + public async Task UpdateMediaCategory( + int id, + int mediaId, + [FromForm] WorkOrderMediaCategory category) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var media = await _workOrderMediaService.UpdateMediaCategoryAsync(id, mediaId, category, actorId); + return Ok(media); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + [HttpDelete("{id:int}/media/{mediaId:int}")] public async Task DeleteMedia(int id, int mediaId) { diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 80d18df..190bb8f 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -66,6 +66,7 @@ namespace SeaHaven.DataServices.Helpers w.ServiceNotes, w.ExtraServices, w.DocStatus, + w.CompletedDate, w.FlagColor, w.PrimaryDispatchId, w.RowVersion, @@ -116,6 +117,7 @@ namespace SeaHaven.DataServices.Helpers w.ServiceNotes, w.ExtraServices, w.DocStatus, + w.CompletedDate, w.FlagColor, w.PrimaryDispatchId, w.RowVersion, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 51ca0d7..71ad944 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -46,6 +46,7 @@ namespace SeaHaven.DataServices.Interfaces string? ServiceNotes, string? ExtraServices, DocStatus? DocStatus, + DateTime? CompletedDate, string? FlagColor, int? PrimaryDispatchId, byte[]? RowVersion, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index adba573..21e4fff 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -56,6 +56,7 @@ namespace SeaHaven.Services.DTOs public string? ServiceNotes { get; set; } public List? ExtraServices { get; set; } public DocStatus? DocStatus { get; set; } + public DateTime? CompletedDate { get; set; } /// Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar). public string? FlagColor { get; set; } public int? PrimaryDispatchId { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index 105e112..8f2c692 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -19,7 +19,6 @@ namespace SeaHaven.Services.DTOs public string? SubTrade { get; set; } public DateOnly? OriginalWeek { get; set; } public DateOnly? OriginalDate { get; set; } - public DateTime? CompletedDate { get; set; } public string? VendorNotes { get; set; } } diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index a76a7fe..ce7b7ae 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -15,6 +15,7 @@ namespace SeaHaven.Services.Helpers public const string ApptDate = "apptDate"; public const string ApptTime = "apptTime"; public const string DocStatus = "docStatus"; + public const string CompletedDate = "completedDate"; public const string Pm = "pm"; public const string ServiceNotes = "serviceNotes"; public const string ExtraServices = "extraServices"; @@ -43,6 +44,7 @@ namespace SeaHaven.Services.Helpers ApptDate, ApptTime, DocStatus, + CompletedDate, Pm, ServiceNotes, ExtraServices, @@ -83,6 +85,7 @@ namespace SeaHaven.Services.Helpers ApptDate => "ApptDate", ApptTime => "ApptTime", DocStatus => "DocStatus", + CompletedDate => "CompletedDate", Pm => "Trade", ServiceNotes => "ServiceNotes", ExtraServices => "ExtraServices", diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs new file mode 100644 index 0000000..e500edc --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Http; + +namespace SeaHaven.Services.Helpers +{ + /// SH-116 media type allowlist for board work-order uploads. + public static class WorkOrderMediaFileRules + { + private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/jpeg", + "image/png", + "video/mp4", + "video/quicktime" + }; + + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", + ".jpeg", + ".png", + ".mp4", + ".mov" + }; + + public static bool IsAllowed(IFormFile file) + { + if (file == null) + return false; + + var contentTypeOk = !string.IsNullOrWhiteSpace(file.ContentType) + && AllowedContentTypes.Contains(file.ContentType.Trim()); + + var extension = Path.GetExtension(file.FileName ?? ""); + var extensionOk = !string.IsNullOrWhiteSpace(extension) + && AllowedExtensions.Contains(extension); + + // Accept when either MIME or extension matches the advertised contract (JPG/PNG/MP4/MOV). + return contentTypeOk || extensionOk; + } + + public static void EnsureAllowed(IFormFile file) + { + if (!IsAllowed(file)) + { + throw new Exceptions.WorkOrderBoardValidationException( + "UnsupportedMediaType", + "Supported media types are JPG, PNG, MP4, and MOV."); + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index f6fdb1f..aa4b4dd 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -113,6 +113,7 @@ namespace SeaHaven.Services.Implementation ServiceNotes = row.ServiceNotes, ExtraServices = ParseExtraServices(row.ExtraServices), DocStatus = row.DocStatus, + CompletedDate = row.CompletedDate, FlagColor = row.FlagColor, PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index bdab70a..3e8c108 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -153,6 +153,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.CompletedDate => new List { ApplyDateField(value, auditField, v => workOrder.CompletedDate = v, () => workOrder.CompletedDate) }, WorkOrderBoardFieldNames.Pm => new List { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) }, WorkOrderBoardFieldNames.ServiceNotes => new List { ApplyServiceNotes(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ExtraServices => new List { ApplyExtraServices(workOrder, value, auditField) }, diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 81c2614..098d5d7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -34,10 +34,12 @@ namespace SeaHaven.Services.Implementation public async Task AddMediaAsync( int workOrderId, - WorkOrderMediaCategory category, + WorkOrderMediaCategory? category, string fileUrl, string? actorId) { + var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; + if (!await _detailData.ExistsAsync(workOrderId)) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); @@ -48,34 +50,34 @@ namespace SeaHaven.Services.Implementation if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); - if (category == WorkOrderMediaCategory.Completion) + if (resolvedCategory == WorkOrderMediaCategory.Completion) { throw new WorkOrderBoardValidationException( "UseCompletionDocEndpoint", "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); } - if (category == WorkOrderMediaCategory.Before) + if (resolvedCategory == WorkOrderMediaCategory.Before) { workOrder.BeforPhotoAttachment = fileUrl; await _mediaData.SaveAsync(CancellationToken.None); return new WorkOrderMediaFileDto { Id = -1, - Category = category, + Category = resolvedCategory, Url = fileUrl, IsLegacy = true }; } - if (category == WorkOrderMediaCategory.After) + if (resolvedCategory == WorkOrderMediaCategory.After) { workOrder.AfterPhotoAttachment = fileUrl; await _mediaData.SaveAsync(CancellationToken.None); return new WorkOrderMediaFileDto { Id = -2, - Category = category, + Category = resolvedCategory, Url = fileUrl, IsLegacy = true }; @@ -85,7 +87,7 @@ namespace SeaHaven.Services.Implementation { WorkorderId = workOrderId, Attachments = fileUrl, - Category = category, + Category = category.HasValue ? resolvedCategory : null, CreatedDate = DateTime.UtcNow, createdby = actorId }; @@ -96,13 +98,78 @@ namespace SeaHaven.Services.Implementation return new WorkOrderMediaFileDto { Id = attachment.Id, - Category = category, + Category = attachment.Category ?? WorkOrderMediaCategory.Extra, Url = fileUrl, UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), IsLegacy = false }; } + public async Task UpdateMediaCategoryAsync( + int workOrderId, + int mediaId, + WorkOrderMediaCategory category, + string? actorId) + { + if (mediaId <= 0) + throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint."); + + if (category == WorkOrderMediaCategory.Completion) + { + throw new WorkOrderBoardValidationException( + "UseCompletionDocEndpoint", + "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 _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); + if (attachment == null) + throw new WorkOrderBoardValidationException("NotFound", "Media not found."); + + if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After) + { + var url = attachment.Attachments ?? ""; + if (category == WorkOrderMediaCategory.Before) + workOrder.BeforPhotoAttachment = url; + else + workOrder.AfterPhotoAttachment = url; + + attachment.IsDeleted = true; + attachment.DeletionTime = DateTime.UtcNow; + attachment.DeleterUserId = actorId; + await _mediaData.SaveAsync(CancellationToken.None); + + return new WorkOrderMediaFileDto + { + Id = category == WorkOrderMediaCategory.Before ? -1 : -2, + Category = category, + Url = url, + IsLegacy = true + }; + } + + attachment.Category = category; + await _mediaData.SaveAsync(CancellationToken.None); + + return new WorkOrderMediaFileDto + { + Id = attachment.Id, + Category = attachment.Category ?? WorkOrderMediaCategory.Extra, + Url = attachment.Attachments ?? "", + UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), + IsLegacy = false + }; + } + public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId) { if (mediaId <= 0) diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index 015cc83..a40b422 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -6,7 +6,16 @@ namespace SeaHaven.Services.Interfaces public interface IWorkOrderMediaService { Task?> GetMediaAsync(int workOrderId); - Task AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId); + Task AddMediaAsync( + int workOrderId, + WorkOrderMediaCategory? category, + string fileUrl, + string? actorId); + Task UpdateMediaCategoryAsync( + int workOrderId, + int mediaId, + WorkOrderMediaCategory category, + string? actorId); Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 883aeae..b775ee6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -798,4 +798,59 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("VendorNotFound", ex.Code); } + + [Fact] + public async Task PatchField_UpdatesCompletedDate() + { + 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.WorkOrderContacts.Add(new WorkOrderContacts { Id = 1, WorkorderId = 1, ContactId = 50 }); + context.workOrderCategories.Add(new WorkOrderCategories { Id = 1, WorkorderId = 1, CategoryId = 60 }); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.CompletedDate, + Value = "2026-07-20", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(new DateTime(2026, 7, 20), result.CompletedDate); + Assert.Equal(new DateTime(2026, 7, 20), context.workOrders.Single().CompletedDate); + Assert.Equal(1, await context.WorkOrderContacts.CountAsync()); + Assert.Equal(1, await context.workOrderCategories.CountAsync()); + Assert.Equal(50, context.WorkOrderContacts.Single().ContactId); + Assert.Equal(60, context.workOrderCategories.Single().CategoryId); + } + + [Fact] + public async Task PatchField_ClearsCompletedDate_WhenValueEmpty() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + CompletedDate = new DateTime(2026, 7, 1), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.CompletedDate, + Value = "", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Null(result.CompletedDate); + Assert.Null(context.workOrders.Single().CompletedDate); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index e30e651..8029516 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -650,4 +650,56 @@ public class WorkOrderMediaServiceTests Assert.Equal("UseCompletionDocEndpoint", ex.Code); } + + [Fact] + public async Task AddMedia_WithoutCategory_PersistsAsUncategorizedExtra() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled + }); + 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); + } + + [Fact] + public async Task UpdateMediaCategory_SetsBeforeFromExtraAttachment() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/photo.jpg", + Category = null + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context)); + var media = await service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, "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); + } } From 2ec85d119306aba8b341149fc3bc68e6edf065aa Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 4 Aug 2026 09:30:09 -0300 Subject: [PATCH 02/16] 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. --- .../Controllers/WorkOrderMediaController.cs | 66 ++++- .../Infrastructure/PortAdapters.cs | 38 ++- .../WorkOrderMediaDataService.cs | 12 + .../Interfaces/IWorkOrderMediaDataService.cs | 2 + .../Helpers/WorkOrderBoardFieldNames.cs | 42 +-- .../Helpers/WorkOrderMediaFileRules.cs | 94 +++++-- .../Implementation/WorkOrderMediaService.cs | 174 ++++++++++--- SeaHaven.Services/Interfaces/IServicePorts.cs | 6 + .../Interfaces/IWorkOrderMediaService.cs | 24 +- .../WorkOrderPhase6Tests.cs | 245 +++++++++++++++--- 10 files changed, 578 insertions(+), 125 deletions(-) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 73e5a69..2d85749 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -29,9 +29,9 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("{id:int}/media")] - public async Task GetMedia(int id) + public async Task 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 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 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 DeleteMedia(int id, int mediaId) + public async Task 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); + } } } diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index 308dd86..adcc357 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -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 diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index d21a804..a6e481e 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -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); } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index db1d07e..468d7a7 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task 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); } } diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index ce7b7ae..97f7744 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -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 + }; } } } diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index e500edc..bc03e6c 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -13,29 +13,53 @@ namespace SeaHaven.Services.Helpers "video/quicktime" }; - private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".jpg", - ".jpeg", - ".png", - ".mp4", - ".mov" - }; + private static readonly Dictionary> ExtensionsByContentType = + new(StringComparer.OrdinalIgnoreCase) + { + ["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" }, + ["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" }, + ["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }, + ["video/quicktime"] = new HashSet(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'; + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 098d5d7..7c7cf9d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -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?> GetMediaAsync(int workOrderId) + public async Task?> 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 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 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; } } diff --git a/SeaHaven.Services/Interfaces/IServicePorts.cs b/SeaHaven.Services/Interfaces/IServicePorts.cs index aae1383..10c1718 100644 --- a/SeaHaven.Services/Interfaces/IServicePorts.cs +++ b/SeaHaven.Services/Interfaces/IServicePorts.cs @@ -18,6 +18,12 @@ namespace SeaHaven.Services.Interfaces public interface IFileStoragePort { Task SaveFileAsync(IFormFile file); + + /// + /// Best-effort delete of a previously saved file URL. Returns false when the file + /// cannot be resolved or removed; never throws for missing paths. + /// + bool TryDelete(string fileUrl); } /// diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index a40b422..fe1bbe1 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -5,17 +5,33 @@ namespace SeaHaven.Services.Interfaces { public interface IWorkOrderMediaService { - Task?> GetMediaAsync(int workOrderId); + Task?> GetMediaAsync(int workOrderId, CancellationToken cancellationToken = default); + + /// + /// Authorizes the caller and validates the work order is mutable before any blob storage write. + /// + Task EnsureCanMutateMediaAsync(int workOrderId, string? actorId, CancellationToken cancellationToken = default); + Task AddMediaAsync( int workOrderId, WorkOrderMediaCategory? category, string fileUrl, - string? actorId); + string? actorId, + CancellationToken cancellationToken = default); + Task 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); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 8029516..087d084 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -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() .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() - .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(() => - 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() - .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(() => 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() - .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(() => + 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(() => + service.EnsureCanMutateMediaAsync(99, "actor-1")); + Assert.Equal("NotFound", ex.Code); } [Fact] public async Task UpdateMediaCategory_SetsBeforeFromExtraAttachment() { - var options = new DbContextOptionsBuilder() - .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(() => + 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(() => + 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(() => + 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("not an image"); + 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(() => + WorkOrderMediaFileRules.EnsureAllowed(FormFile(new byte[] { 0x00 }, "a.png", "image/png"))); + Assert.Equal("UnsupportedMediaType", ex.Code); } } From 899da0eb4fd7e6a87c4705d4691ac6fc314beb48 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 4 Aug 2026 10:21:47 -0300 Subject: [PATCH 03/16] fix(work-orders): enforce media auth and base scope on mutations Require an authenticated ClaimsPrincipal at service entry and filter tracked work orders with board base scope so deleted/template rows surface as NotFound without disclosure. --- .../Controllers/WorkOrderMediaController.cs | 8 +- .../WorkOrderMediaDataService.cs | 4 +- .../Implementation/WorkOrderMediaService.cs | 29 ++++-- .../Interfaces/IWorkOrderMediaService.cs | 10 +- .../WorkOrderPhase6Tests.cs | 91 +++++++++++++++++-- 5 files changed, 117 insertions(+), 25 deletions(-) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 2d85749..b8d9967 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -53,10 +53,10 @@ namespace Api.SeaHavenIndustries.Controllers { WorkOrderMediaFileRules.EnsureAllowed(file); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - await _workOrderMediaService.EnsureCanMutateMediaAsync(id, actorId, cancellationToken); + await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken); fileUrl = await _fileStorage.SaveFileAsync(file); - var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId, cancellationToken); + var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, User, actorId, cancellationToken); return Ok(media); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") @@ -94,7 +94,7 @@ namespace Api.SeaHavenIndustries.Controllers { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var media = await _workOrderMediaService.UpdateMediaCategoryAsync( - id, mediaId, category, workOrderVersion, actorId, cancellationToken); + id, mediaId, category, workOrderVersion, User, actorId, cancellationToken); return Ok(media); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") @@ -126,7 +126,7 @@ namespace Api.SeaHavenIndustries.Controllers try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - await _workOrderMediaService.DeleteMediaAsync(id, mediaId, workOrderVersion, actorId, cancellationToken); + await _workOrderMediaService.DeleteMediaAsync(id, mediaId, workOrderVersion, User, actorId, cancellationToken); return NoContent(); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index a6e481e..7a81f01 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -14,7 +14,9 @@ namespace SeaHaven.DataServices.Implementation } public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) - => _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + => _context.workOrders.FirstOrDefaultAsync( + w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null), + cancellationToken); public Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken) => _context.workOrderAttachments.FirstOrDefaultAsync( diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 7c7cf9d..9d9df01 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; @@ -41,10 +42,11 @@ namespace SeaHaven.Services.Implementation public async Task EnsureCanMutateMediaAsync( int workOrderId, + ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { - EnsureAuthenticatedActor(actorId); + EnsureAuthenticatedCaller(user, actorId); await GetMutableWorkOrderAsync(workOrderId, cancellationToken); } @@ -52,10 +54,11 @@ namespace SeaHaven.Services.Implementation int workOrderId, WorkOrderMediaCategory? category, string fileUrl, + ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { - EnsureAuthenticatedActor(actorId); + EnsureAuthenticatedCaller(user, actorId); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken); @@ -132,10 +135,11 @@ namespace SeaHaven.Services.Implementation int mediaId, WorkOrderMediaCategory category, string? workOrderVersion, + ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { - EnsureAuthenticatedActor(actorId); + EnsureAuthenticatedCaller(user, actorId); if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint."); @@ -209,10 +213,11 @@ namespace SeaHaven.Services.Implementation int workOrderId, int mediaId, string? workOrderVersion, + ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { - EnsureAuthenticatedActor(actorId); + EnsureAuthenticatedCaller(user, actorId); if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint."); @@ -241,9 +246,7 @@ namespace SeaHaven.Services.Implementation private async Task GetMutableWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) { - if (!await _detailData.ExistsAsync(workOrderId)) - throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - + // Base-scoped lookup: deleted/template work orders surface as NotFound (no disclosure). var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); @@ -266,10 +269,16 @@ namespace SeaHaven.Services.Implementation _mediaData.SetExpectedWorkOrderVersion(workOrder, expected); } - private static void EnsureAuthenticatedActor(string? actorId) + private static void EnsureAuthenticatedCaller(ClaimsPrincipal user, string? actorId) { - if (string.IsNullOrWhiteSpace(actorId)) - throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to mutate work order media."); + if (user is null + || !(user.Identity?.IsAuthenticated ?? false) + || string.IsNullOrWhiteSpace(actorId)) + { + throw new WorkOrderBoardValidationException( + "Forbidden", + "You are not allowed to mutate work order media."); + } } private static byte[]? ParseRowVersion(string? base64) diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index fe1bbe1..13bb4c9 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Data.SeaHavenIndustries.Enums; using SeaHaven.Services.DTOs; @@ -10,12 +11,17 @@ namespace SeaHaven.Services.Interfaces /// /// Authorizes the caller and validates the work order is mutable before any blob storage write. /// - Task EnsureCanMutateMediaAsync(int workOrderId, string? actorId, CancellationToken cancellationToken = default); + Task EnsureCanMutateMediaAsync( + int workOrderId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default); Task AddMediaAsync( int workOrderId, WorkOrderMediaCategory? category, string fileUrl, + ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default); @@ -24,6 +30,7 @@ namespace SeaHaven.Services.Interfaces int mediaId, WorkOrderMediaCategory category, string? workOrderVersion, + ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default); @@ -31,6 +38,7 @@ namespace SeaHaven.Services.Interfaces int workOrderId, int mediaId, string? workOrderVersion, + ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default); } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 087d084..2ace8eb 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using System.Text; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; @@ -589,6 +590,17 @@ public class WorkOrderMediaServiceTests private static string ToVersion(WorkOrder workOrder) => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + private static ClaimsPrincipal AuthenticatedUser(string actorId = "actor-1") + { + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, actorId) }, + authenticationType: "Test"); + return new ClaimsPrincipal(identity); + } + + private static ClaimsPrincipal UnauthenticatedUser() + => new ClaimsPrincipal(new ClaimsIdentity()); + [Fact] public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra() { @@ -636,7 +648,7 @@ public class WorkOrderMediaServiceTests await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => - service.DeleteMediaAsync(1, 10, ToVersion(context.workOrders.Single()), "actor-1")); + service.DeleteMediaAsync(1, 10, ToVersion(context.workOrders.Single()), AuthenticatedUser(), "actor-1")); Assert.Equal("ReadOnly", ex.Code); } @@ -654,7 +666,7 @@ public class WorkOrderMediaServiceTests await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => - service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1")); + service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", AuthenticatedUser(), "actor-1")); Assert.Equal("UseCompletionDocEndpoint", ex.Code); } @@ -671,7 +683,7 @@ public class WorkOrderMediaServiceTests }); await context.SaveChangesAsync(); - var media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", "actor-1"); + var media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", AuthenticatedUser(), "actor-1"); Assert.True(media.Id > 0); Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); @@ -692,7 +704,30 @@ public class WorkOrderMediaServiceTests await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => - service.AddMediaAsync(1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", null)); + service.AddMediaAsync(1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedUser(), null)); + + Assert.Equal("Forbidden", ex.Code); + } + + [Fact] + public async Task AddMedia_UnauthenticatedCaller_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(() => + service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Extra, + "https://example.com/photo.jpg", + UnauthenticatedUser(), + "actor-1")); Assert.Equal("Forbidden", ex.Code); } @@ -702,7 +737,45 @@ public class WorkOrderMediaServiceTests { var (_, service) = CreateSut(); var ex = await Assert.ThrowsAsync(() => - service.EnsureCanMutateMediaAsync(99, "actor-1")); + service.EnsureCanMutateMediaAsync(99, AuthenticatedUser(), "actor-1")); + Assert.Equal("NotFound", ex.Code); + } + + [Fact] + public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + IsDeleted = true, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1")); + + Assert.Equal("NotFound", ex.Code); + } + + [Fact] + public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + istemplate = true, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1")); + Assert.Equal("NotFound", ex.Code); } @@ -727,7 +800,7 @@ public class WorkOrderMediaServiceTests await context.SaveChangesAsync(); var media = await service.UpdateMediaCategoryAsync( - 1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), "actor-1"); + 1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1"); Assert.Equal(-1, media.Id); Assert.Equal(WorkOrderMediaCategory.Before, media.Category); @@ -758,7 +831,7 @@ public class WorkOrderMediaServiceTests var stale = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); var ex = await Assert.ThrowsAsync(() => - service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, stale, "actor-1")); + service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, stale, AuthenticatedUser(), "actor-1")); Assert.Equal("ConcurrencyConflict", ex.Code); } @@ -790,7 +863,7 @@ public class WorkOrderMediaServiceTests var version = ToVersion(context.workOrders.Single(w => w.Id == 1)); var ex = await Assert.ThrowsAsync(() => - service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, version, "actor-1")); + service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, version, AuthenticatedUser(), "actor-1")); Assert.Equal("NotFound", ex.Code); } @@ -817,7 +890,7 @@ public class WorkOrderMediaServiceTests await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => - service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), "actor-1")); + service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1")); Assert.Equal("NotFound", ex.Code); } From 680012d88b219663e5d2160ac5489b6af315310d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 4 Aug 2026 11:08:17 -0300 Subject: [PATCH 04/16] fix(work-orders): enforce media role scope and relational concurrency Derive staff vs technician scope from claims (Assigned for User), map DbUpdateConcurrencyException to a stable 409, and add SQLite competing-write tests for categorize-vs-categorize and categorize-vs-delete. --- .../Helpers/WorkOrderMediaAuthorization.cs | 82 ++++++ .../Implementation/WorkOrderMediaService.cs | 63 +++-- ...orkOrderMediaConcurrencyRelationalTests.cs | 252 ++++++++++++++++++ .../WorkOrderPhase6Tests.cs | 116 +++++++- 4 files changed, 485 insertions(+), 28 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs b/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs new file mode 100644 index 0000000..dc1adec --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs @@ -0,0 +1,82 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using SeaHaven.Services.Exceptions; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Claims-derived authorization for work-order media mutations. + /// True multi-tenant CustomerId/TenantId is not modeled on WorkOrder/JWT; + /// scope is role + Assigned () for Technician. + /// + public static class WorkOrderMediaAuthorization + { + private static readonly string[] StaffRoles = + { + "Admin", + "Manager", + "Dispatcher", + "Supervisor" + }; + + public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId) + { + EnsureAuthenticated(user, actorId); + + if (IsStaff(user) || user.IsInRole("User")) + return; + + throw Forbidden(); + } + + public static void EnsureCanDelete(ClaimsPrincipal user, string? actorId) + { + EnsureAuthenticated(user, actorId); + + // Technician (User) may upload/categorize assigned media but not delete. + if (IsStaff(user)) + return; + + throw Forbidden(); + } + + /// + /// Staff: any in-scope (non-deleted/non-template) work order. + /// Technician: only work orders assigned to the caller. Out-of-scope → NotFound (no disclosure). + /// + public static void EnsureWorkOrderInCallerScope( + ClaimsPrincipal user, + string actorId, + WorkOrder workOrder) + { + if (IsStaff(user)) + return; + + if (user.IsInRole("User") + && string.Equals(workOrder.AssignTo, actorId, StringComparison.Ordinal)) + { + return; + } + + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } + + private static void EnsureAuthenticated(ClaimsPrincipal user, string? actorId) + { + if (user is null + || !(user.Identity?.IsAuthenticated ?? false) + || string.IsNullOrWhiteSpace(actorId)) + { + throw Forbidden(); + } + } + + private static bool IsStaff(ClaimsPrincipal user) + => StaffRoles.Any(user.IsInRole); + + private static WorkOrderBoardValidationException Forbidden() + => new( + "Forbidden", + "You are not allowed to mutate work order media."); + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 9d9df01..f2a61dd 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; @@ -46,8 +47,8 @@ namespace SeaHaven.Services.Implementation string? actorId, CancellationToken cancellationToken = default) { - EnsureAuthenticatedCaller(user, actorId); - await GetMutableWorkOrderAsync(workOrderId, cancellationToken); + WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); + await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); } public async Task AddMediaAsync( @@ -58,10 +59,10 @@ namespace SeaHaven.Services.Implementation string? actorId, CancellationToken cancellationToken = default) { - EnsureAuthenticatedCaller(user, actorId); + WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; - var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken); + var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); if (resolvedCategory == WorkOrderMediaCategory.Completion) { @@ -76,7 +77,7 @@ namespace SeaHaven.Services.Implementation workOrder.BeforPhotoAttachment = fileUrl; await _auditService.StageFieldChangedAsync( workOrderId, "BeforPhotoAttachment", oldBefore, fileUrl, actorId); - await _mediaData.SaveAsync(cancellationToken); + await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = -1, @@ -92,7 +93,7 @@ namespace SeaHaven.Services.Implementation workOrder.AfterPhotoAttachment = fileUrl; await _auditService.StageFieldChangedAsync( workOrderId, "AfterPhotoAttachment", oldAfter, fileUrl, actorId); - await _mediaData.SaveAsync(cancellationToken); + await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = -2, @@ -118,7 +119,7 @@ namespace SeaHaven.Services.Implementation null, FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()), actorId); - await _mediaData.SaveAsync(cancellationToken); + await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { @@ -139,7 +140,7 @@ namespace SeaHaven.Services.Implementation string? actorId, CancellationToken cancellationToken = default) { - EnsureAuthenticatedCaller(user, actorId); + WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint."); @@ -151,7 +152,7 @@ namespace SeaHaven.Services.Implementation "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); } - var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken); + var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); ApplyExpectedVersion(workOrder, workOrderVersion); var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken); @@ -178,7 +179,7 @@ namespace SeaHaven.Services.Implementation FormatMediaAuditValue(mediaId, priorCategory), FormatMediaAuditValue(mediaId, category.ToString()), actorId); - await _mediaData.SaveAsync(cancellationToken); + await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { @@ -197,7 +198,7 @@ namespace SeaHaven.Services.Implementation FormatMediaAuditValue(mediaId, priorCategory), FormatMediaAuditValue(mediaId, category.ToString()), actorId); - await _mediaData.SaveAsync(cancellationToken); + await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { @@ -217,12 +218,12 @@ namespace SeaHaven.Services.Implementation string? actorId, CancellationToken cancellationToken = default) { - EnsureAuthenticatedCaller(user, actorId); + WorkOrderMediaAuthorization.EnsureCanDelete(user, actorId); if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint."); - var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken); + var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); ApplyExpectedVersion(workOrder, workOrderVersion); var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken); @@ -241,22 +242,42 @@ namespace SeaHaven.Services.Implementation FormatMediaAuditValue(mediaId, priorCategory), FormatMediaAuditValue(mediaId, "Deleted"), actorId); - await _mediaData.SaveAsync(cancellationToken); + await SaveMediaAsync(cancellationToken); } - private async Task GetMutableWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + private async Task GetMutableWorkOrderAsync( + int workOrderId, + ClaimsPrincipal user, + string actorId, + CancellationToken cancellationToken) { // Base-scoped lookup: deleted/template work orders surface as NotFound (no disclosure). var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder); + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); return workOrder; } + private async Task SaveMediaAsync(CancellationToken cancellationToken) + { + try + { + await _mediaData.SaveAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + throw new WorkOrderBoardValidationException( + "ConcurrencyConflict", + "Work order was modified. Refresh and retry."); + } + } + private void ApplyExpectedVersion(WorkOrder workOrder, string? workOrderVersion) { var expected = ParseRowVersion(workOrderVersion); @@ -269,18 +290,6 @@ namespace SeaHaven.Services.Implementation _mediaData.SetExpectedWorkOrderVersion(workOrder, expected); } - private static void EnsureAuthenticatedCaller(ClaimsPrincipal user, string? actorId) - { - if (user is null - || !(user.Identity?.IsAuthenticated ?? false) - || 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)) diff --git a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs new file mode 100644 index 0000000..006fcb4 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs @@ -0,0 +1,252 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +/// +/// Provider-backed media concurrency: two independently tracked contexts share one SQLite +/// connection so EF concurrency tokens and competing writes are exercised (not InMemory). +/// +public class WorkOrderMediaConcurrencyRelationalTests +{ + [Fact] + public async Task CategorizeVsCategorize_SecondWriter_GetsConcurrencyConflict_WithoutPartialAudit() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = CreateOptions(connection); + + await using (var seed = new SqliteMediaTestDbContext(options)) + { + await seed.Database.EnsureCreatedAsync(); + seed.Users.AddRange( + new ApplicationUser + { + Id = "actor-a", + UserName = "actor-a", + NormalizedUserName = "ACTOR-A", + Email = "a@test.local", + NormalizedEmail = "A@TEST.LOCAL" + }, + new ApplicationUser + { + Id = "actor-b", + UserName = "actor-b", + NormalizedUserName = "ACTOR-B", + Email = "b@test.local", + NormalizedEmail = "B@TEST.LOCAL" + }); + seed.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + seed.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/photo.jpg", + Category = WorkOrderMediaCategory.Extra + }); + await seed.SaveChangesAsync(); + } + + await using var contextA = new SqliteMediaTestDbContext(options); + await using var contextB = new SqliteMediaTestDbContext(options); + var serviceA = CreateService(contextA); + var serviceB = CreateService(contextB); + + var versionA = await LoadVersionAsync(contextA, workOrderId: 1); + var versionB = await LoadVersionAsync(contextB, workOrderId: 1); + Assert.Equal(versionA, versionB); + + var winner = await serviceA.UpdateMediaCategoryAsync( + 1, 10, WorkOrderMediaCategory.Before, versionA, Admin("actor-a"), "actor-a"); + + Assert.Equal(WorkOrderMediaCategory.Before, winner.Category); + + var loser = await Assert.ThrowsAsync(() => + serviceB.UpdateMediaCategoryAsync( + 1, 10, WorkOrderMediaCategory.After, versionB, Admin("actor-b"), "actor-b")); + + Assert.Equal("ConcurrencyConflict", loser.Code); + + await using var verify = new SqliteMediaTestDbContext(options); + var wo = await verify.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1); + var attachment = await verify.workOrderAttachments.AsNoTracking().SingleAsync(a => a.Id == 10); + var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync(); + + Assert.Equal("https://example.com/photo.jpg", wo.BeforPhotoAttachment); + Assert.True(string.IsNullOrEmpty(wo.AfterPhotoAttachment)); + Assert.True(attachment.IsDeleted); + Assert.Single(audits); + Assert.Contains(audits, a => a.NewValue == "10:Before"); + Assert.DoesNotContain(audits, a => a.NewValue == "10:After"); + } + + [Fact] + public async Task CategorizeVsDelete_SecondWriter_GetsConcurrencyConflict_WithoutPartialState() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = CreateOptions(connection); + + await using (var seed = new SqliteMediaTestDbContext(options)) + { + await seed.Database.EnsureCreatedAsync(); + seed.Users.AddRange( + new ApplicationUser + { + Id = "actor-a", + UserName = "actor-a", + NormalizedUserName = "ACTOR-A", + Email = "a@test.local", + NormalizedEmail = "A@TEST.LOCAL" + }, + new ApplicationUser + { + Id = "actor-b", + UserName = "actor-b", + NormalizedUserName = "ACTOR-B", + Email = "b@test.local", + NormalizedEmail = "B@TEST.LOCAL" + }); + seed.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + seed.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/photo.jpg", + Category = WorkOrderMediaCategory.Extra + }); + await seed.SaveChangesAsync(); + } + + await using var categorizeContext = new SqliteMediaTestDbContext(options); + await using var deleteContext = new SqliteMediaTestDbContext(options); + var categorizeService = CreateService(categorizeContext); + var deleteService = CreateService(deleteContext); + + var categorizeVersion = await LoadVersionAsync(categorizeContext, workOrderId: 1); + var deleteVersion = await LoadVersionAsync(deleteContext, workOrderId: 1); + + await categorizeService.UpdateMediaCategoryAsync( + 1, 10, WorkOrderMediaCategory.Before, categorizeVersion, Admin("actor-a"), "actor-a"); + + var loser = await Assert.ThrowsAsync(() => + deleteService.DeleteMediaAsync( + 1, 10, deleteVersion, Admin("actor-b"), "actor-b")); + + Assert.Equal("ConcurrencyConflict", loser.Code); + + await using var verify = new SqliteMediaTestDbContext(options); + var wo = await verify.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1); + var attachment = await verify.workOrderAttachments.AsNoTracking().SingleAsync(a => a.Id == 10); + var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync(); + + Assert.Equal("https://example.com/photo.jpg", wo.BeforPhotoAttachment); + Assert.True(attachment.IsDeleted); + Assert.Single(audits); + Assert.Contains(audits, a => a.NewValue == "10:Before"); + Assert.DoesNotContain(audits, a => a.NewValue != null && a.NewValue.EndsWith(":Deleted", StringComparison.Ordinal)); + } + + private static DbContextOptions CreateOptions(SqliteConnection connection) + => new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + private static WorkOrderMediaService CreateService(ApplicationDbContext context) + { + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + return new WorkOrderMediaService( + new WorkOrderMediaDataService(context), + new WorkOrderDetailDataService(context), + audit); + } + + private static async Task LoadVersionAsync(ApplicationDbContext context, int workOrderId) + { + var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrderId); + return Convert.ToBase64String(wo.RowVersion!); + } + + private static ClaimsPrincipal Admin(string actorId) + => new(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, actorId), + new Claim(ClaimTypes.Role, "Admin") + }, + "Test")); + + private sealed class SqliteMediaTestDbContext : ApplicationDbContext + { + public SqliteMediaTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes())) + { + if (index.GetFilter() != null) + index.SetFilter(null); + } + + // SQLite has no rowversion type; keep a byte[] concurrency token and bump on save. + var property = builder.Entity().Property(w => w.RowVersion).Metadata; + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.IsConcurrencyToken = true; + } + + public override int SaveChanges() + { + BumpWorkOrderRowVersions(); + return base.SaveChanges(); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + BumpWorkOrderRowVersions(); + return base.SaveChangesAsync(cancellationToken); + } + + private void BumpWorkOrderRowVersions() + { + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State != EntityState.Modified) + continue; + + var current = entry.Entity.RowVersion != null && entry.Entity.RowVersion.Length > 0 + ? (byte[])entry.Entity.RowVersion.Clone() + : new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }; + + for (var i = 0; i < current.Length; i++) + { + if (++current[i] != 0) + break; + } + + entry.Entity.RowVersion = current; + } + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 2ace8eb..07bc43a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -590,7 +590,19 @@ public class WorkOrderMediaServiceTests private static string ToVersion(WorkOrder workOrder) => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); - private static ClaimsPrincipal AuthenticatedUser(string actorId = "actor-1") + private static ClaimsPrincipal AuthenticatedUser(string actorId = "actor-1", string role = "Admin") + { + var identity = new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, actorId), + new Claim(ClaimTypes.Role, role) + }, + authenticationType: "Test"); + return new ClaimsPrincipal(identity); + } + + private static ClaimsPrincipal AuthenticatedWithoutRole(string actorId = "actor-1") { var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, actorId) }, @@ -732,6 +744,108 @@ public class WorkOrderMediaServiceTests Assert.Equal("Forbidden", ex.Code); } + [Fact] + public async Task AddMedia_CallerWithoutRole_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(() => + service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Extra, + "https://example.com/photo.jpg", + AuthenticatedWithoutRole(), + "actor-1")); + + Assert.Equal("Forbidden", ex.Code); + } + + [Fact] + public async Task AddMedia_TechnicianOnUnassignedWorkOrder_ThrowsNotFound() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + AssignTo = "other-tech", + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Extra, + "https://example.com/photo.jpg", + AuthenticatedUser("tech-1", "User"), + "tech-1")); + + Assert.Equal("NotFound", ex.Code); + } + + [Fact] + public async Task AddMedia_TechnicianOnAssignedWorkOrder_Succeeds() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + AssignTo = "tech-1", + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo.jpg", + AuthenticatedUser("tech-1", "User"), + "tech-1"); + + Assert.True(media.Id > 0); + Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); + } + + [Fact] + public async Task DeleteMedia_Technician_ThrowsForbidden() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + AssignTo = "tech-1", + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.DeleteMediaAsync( + 1, + 10, + ToVersion(context.workOrders.Single()), + AuthenticatedUser("tech-1", "User"), + "tech-1")); + + Assert.Equal("Forbidden", ex.Code); + } + [Fact] public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound() { From 6b18327d6bfec1cc36de6f0cbc83b97f5a4f25e3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 4 Aug 2026 14:14:37 -0300 Subject: [PATCH 05/16] fix(work-orders): authorize GET media and forward cancellation Enforce claims-derived read scope on media list and thread CancellationToken through detail data reads so HTTP cancel stops EF work. --- .../Controllers/WorkOrderMediaController.cs | 21 ++++- .../WorkOrderDetailDataService.cs | 16 ++-- .../Interfaces/IWorkOrderDetailDataService.cs | 10 +- .../Helpers/WorkOrderMediaAuthorization.cs | 23 ++++- .../Implementation/WorkOrderMediaService.cs | 12 ++- .../Interfaces/IWorkOrderMediaService.cs | 6 +- .../WorkOrderPhase6Tests.cs | 93 ++++++++++++++++++- 7 files changed, 158 insertions(+), 23 deletions(-) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index b8d9967..55c2dbf 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -31,10 +31,23 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("{id:int}/media")] public async Task GetMedia(int id, CancellationToken cancellationToken) { - var media = await _workOrderMediaService.GetMediaAsync(id, cancellationToken); - if (media == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(media); + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var media = await _workOrderMediaService.GetMediaAsync(id, User, actorId, cancellationToken); + if (media == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + 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 }); + } } [HttpPost("{id:int}/media")] diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs index e2526ab..b2942f0 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs @@ -14,9 +14,9 @@ namespace SeaHaven.DataServices.Implementation _context = context; } - public Task ExistsAsync(int workOrderId) + public Task ExistsAsync(int workOrderId, CancellationToken cancellationToken = default) => WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) - .AnyAsync(w => w.Id == workOrderId); + .AnyAsync(w => w.Id == workOrderId, cancellationToken); public async Task GetExtendedFieldsAsync(int workOrderId) { @@ -63,13 +63,15 @@ namespace SeaHaven.DataServices.Implementation return await query.ToListAsync(); } - public async Task> GetAttachmentsAsync(int workOrderId) + public async Task> GetAttachmentsAsync( + int workOrderId, + CancellationToken cancellationToken = default) { return await _context.workOrderAttachments .AsNoTracking() .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true) .OrderByDescending(a => a.CreatedDate) - .ToListAsync(); + .ToListAsync(cancellationToken); } public async Task> GetDispatchSignoffsAsync(int workOrderId) @@ -86,10 +88,12 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(); } - public async Task GetWorkOrderForMediaAsync(int workOrderId) + public async Task GetWorkOrderForMediaAsync( + int workOrderId, + CancellationToken cancellationToken = default) { return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) - .FirstOrDefaultAsync(w => w.Id == workOrderId); + .FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); } } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs index 3f61a70..0e5840f 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs @@ -6,13 +6,17 @@ namespace SeaHaven.DataServices.Interfaces { public interface IWorkOrderDetailDataService { - Task ExistsAsync(int workOrderId); + Task ExistsAsync(int workOrderId, CancellationToken cancellationToken = default); Task GetExtendedFieldsAsync(int workOrderId); Task> GetCommentsAsync(int workOrderId); Task> GetAuditLogsAsync(int workOrderId, int? limit = null); - Task> GetAttachmentsAsync(int workOrderId); + Task> GetAttachmentsAsync( + int workOrderId, + CancellationToken cancellationToken = default); Task> GetDispatchSignoffsAsync(int workOrderId); - Task GetWorkOrderForMediaAsync(int workOrderId); + Task GetWorkOrderForMediaAsync( + int workOrderId, + CancellationToken cancellationToken = default); } public interface ICompletionDocTemplateDataService diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs b/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs index dc1adec..5034a0e 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs @@ -5,7 +5,7 @@ using SeaHaven.Services.Exceptions; namespace SeaHaven.Services.Helpers { /// - /// Claims-derived authorization for work-order media mutations. + /// Claims-derived authorization for work-order media read/mutations. /// True multi-tenant CustomerId/TenantId is not modeled on WorkOrder/JWT; /// scope is role + Assigned () for Technician. /// @@ -19,6 +19,16 @@ namespace SeaHaven.Services.Helpers "Supervisor" }; + public static void EnsureCanRead(ClaimsPrincipal user, string? actorId) + { + EnsureAuthenticated(user, actorId, "You are not allowed to view work order media."); + + if (IsStaff(user) || user.IsInRole("User")) + return; + + throw Forbidden("You are not allowed to view work order media."); + } + public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId) { EnsureAuthenticated(user, actorId); @@ -61,22 +71,25 @@ namespace SeaHaven.Services.Helpers throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); } - private static void EnsureAuthenticated(ClaimsPrincipal user, string? actorId) + private static void EnsureAuthenticated( + ClaimsPrincipal user, + string? actorId, + string? forbiddenMessage = null) { if (user is null || !(user.Identity?.IsAuthenticated ?? false) || string.IsNullOrWhiteSpace(actorId)) { - throw Forbidden(); + throw Forbidden(forbiddenMessage); } } private static bool IsStaff(ClaimsPrincipal user) => StaffRoles.Any(user.IsInRole); - private static WorkOrderBoardValidationException Forbidden() + private static WorkOrderBoardValidationException Forbidden(string? message = null) => new( "Forbidden", - "You are not allowed to mutate work order media."); + message ?? "You are not allowed to mutate work order media."); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index f2a61dd..f224388 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -28,16 +28,22 @@ namespace SeaHaven.Services.Implementation public async Task?> GetMediaAsync( int workOrderId, + ClaimsPrincipal user, + string? actorId, CancellationToken cancellationToken = default) { - if (!await _detailData.ExistsAsync(workOrderId)) + WorkOrderMediaAuthorization.EnsureCanRead(user, actorId); + + if (!await _detailData.ExistsAsync(workOrderId, cancellationToken)) return null; - var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); + var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken); if (workOrder == null) return null; - var attachments = await _detailData.GetAttachmentsAsync(workOrderId); + WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId!, workOrder); + + var attachments = await _detailData.GetAttachmentsAsync(workOrderId, cancellationToken); return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index 13bb4c9..b4e97cb 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -6,7 +6,11 @@ namespace SeaHaven.Services.Interfaces { public interface IWorkOrderMediaService { - Task?> GetMediaAsync(int workOrderId, CancellationToken cancellationToken = default); + Task?> GetMediaAsync( + int workOrderId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default); /// /// Authorizes the caller and validates the work order is mutable before any blob storage write. diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 07bc43a..a2e467f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -631,7 +631,7 @@ public class WorkOrderMediaServiceTests }); await context.SaveChangesAsync(); - var media = await service.GetMediaAsync(1); + var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); Assert.NotNull(media); Assert.Equal(3, media!.Count); @@ -640,6 +640,97 @@ public class WorkOrderMediaServiceTests Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra); } + [Fact] + public async Task GetMedia_TechnicianOnAssignedWorkOrder_Succeeds() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + AssignTo = "tech-1", + BeforPhotoAttachment = "https://example.com/before.jpg" + }); + await context.SaveChangesAsync(); + + var media = await service.GetMediaAsync( + 1, + AuthenticatedUser("tech-1", "User"), + "tech-1"); + + Assert.NotNull(media); + Assert.Contains(media!, m => m.Category == WorkOrderMediaCategory.Before); + } + + [Fact] + public async Task GetMedia_TechnicianOnUnassignedWorkOrder_ThrowsNotFound() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + AssignTo = "other-tech", + BeforPhotoAttachment = "https://example.com/before.jpg" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.GetMediaAsync(1, AuthenticatedUser("tech-1", "User"), "tech-1")); + + Assert.Equal("NotFound", ex.Code); + } + + [Fact] + public async Task GetMedia_CallerWithoutRole_ThrowsForbidden() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + BeforPhotoAttachment = "https://example.com/before.jpg" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.GetMediaAsync(1, AuthenticatedWithoutRole(), "actor-1")); + + Assert.Equal("Forbidden", ex.Code); + } + + [Fact] + public async Task GetMedia_UnauthenticatedCaller_ThrowsForbidden() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + BeforPhotoAttachment = "https://example.com/before.jpg" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.GetMediaAsync(1, UnauthenticatedUser(), "actor-1")); + + Assert.Equal("Forbidden", ex.Code); + } + + [Fact] + public async Task GetMedia_CanceledToken_ThrowsOperationCanceled() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + BeforPhotoAttachment = "https://example.com/before.jpg" + }); + await context.SaveChangesAsync(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + service.GetMediaAsync(1, AuthenticatedUser(), "actor-1", cts.Token)); + } + [Fact] public async Task DeleteMedia_ReadOnlyWorkOrder_Throws() { From 8ff4ab174213305379fec154a9436ae0635178a6 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 4 Aug 2026 16:07:26 -0300 Subject: [PATCH 06/16] fix(work-orders): document single-org media scope (ADR 0001) Clarify SH-116 tenant scope as board-aligned ApplyBaseScope + claims, and add out-of-org-scope GET/mutation tests for deleted/template/missing WOs. --- .../Helpers/WorkOrderMediaAuthorization.cs | 16 +++-- .../Implementation/WorkOrderMediaService.cs | 4 +- .../WorkOrderPhase6Tests.cs | 66 ++++++++++++++++- docs/adr/0001-work-order-single-org-scope.md | 71 +++++++++++++++++++ 4 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 docs/adr/0001-work-order-single-org-scope.md diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs b/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs index 5034a0e..2af3080 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs @@ -6,8 +6,11 @@ namespace SeaHaven.Services.Helpers { /// /// Claims-derived authorization for work-order media read/mutations. - /// True multi-tenant CustomerId/TenantId is not modeled on WorkOrder/JWT; - /// scope is role + Assigned () for Technician. + /// Organization scope matches the board: callers must already have resolved + /// the work order via ApplyBaseScope (non-deleted, non-template). + /// Staff may access any in-org work order; technicians only when + /// matches the actor. See + /// docs/adr/0001-work-order-single-org-scope.md. /// public static class WorkOrderMediaAuthorization { @@ -51,14 +54,19 @@ namespace SeaHaven.Services.Helpers } /// - /// Staff: any in-scope (non-deleted/non-template) work order. - /// Technician: only work orders assigned to the caller. Out-of-scope → NotFound (no disclosure). + /// Caller-scope check after a base-scoped (in-org) work-order load. + /// Staff: any in-org work order already resolved via ApplyBaseScope. + /// Technician: only when assigned to the caller. + /// Out of caller scope → NotFound (no disclosure). /// public static void EnsureWorkOrderInCallerScope( ClaimsPrincipal user, string actorId, WorkOrder workOrder) { + // Organization boundary is enforced by the data-layer ApplyBaseScope + // load that produced . Staff are authorized + // for any in-org work order (same as the board). See ADR 0001. if (IsStaff(user)) return; diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index f224388..a75df3e 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -34,6 +34,8 @@ namespace SeaHaven.Services.Implementation { WorkOrderMediaAuthorization.EnsureCanRead(user, actorId); + // Organization scope: ApplyBaseScope via Exists / GetWorkOrderForMedia. + // Outside org (deleted/template/missing) → null (no disclosure). ADR 0001. if (!await _detailData.ExistsAsync(workOrderId, cancellationToken)) return null; @@ -257,7 +259,7 @@ namespace SeaHaven.Services.Implementation string actorId, CancellationToken cancellationToken) { - // Base-scoped lookup: deleted/template work orders surface as NotFound (no disclosure). + // Organization scope via ApplyBaseScope: deleted/template → NotFound (ADR 0001). var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index a2e467f..0d2f6fc 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -731,6 +731,57 @@ public class WorkOrderMediaServiceTests service.GetMediaAsync(1, AuthenticatedUser(), "actor-1", cts.Token)); } + /// + /// Out-of-organization-scope (single-org analogue of SH-116 cross-tenant): + /// deleted work orders are outside ApplyBaseScope → null / no media disclosure. + /// + [Fact] + public async Task GetMedia_StaffOnDeletedWorkOrder_ReturnsNull_OutOfOrgScope() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + IsDeleted = true, + BeforPhotoAttachment = "https://example.com/before.jpg" + }); + await context.SaveChangesAsync(); + + var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); + + Assert.Null(media); + } + + /// + /// Out-of-organization-scope: template work orders are outside ApplyBaseScope. + /// + [Fact] + public async Task GetMedia_StaffOnTemplateWorkOrder_ReturnsNull_OutOfOrgScope() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + istemplate = true, + BeforPhotoAttachment = "https://example.com/before.jpg" + }); + await context.SaveChangesAsync(); + + var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); + + Assert.Null(media); + } + + [Fact] + public async Task GetMedia_MissingWorkOrder_ReturnsNull_OutOfOrgScope() + { + var (_, service) = CreateSut(); + + var media = await service.GetMediaAsync(99, AuthenticatedUser(), "actor-1"); + + Assert.Null(media); + } + [Fact] public async Task DeleteMedia_ReadOnlyWorkOrder_Throws() { @@ -937,8 +988,11 @@ public class WorkOrderMediaServiceTests Assert.Equal("Forbidden", ex.Code); } + /// + /// Out-of-organization-scope mutation: missing id → NotFound (no disclosure). + /// [Fact] - public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound() + public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound_OutOfOrgScope() { var (_, service) = CreateSut(); var ex = await Assert.ThrowsAsync(() => @@ -946,8 +1000,11 @@ public class WorkOrderMediaServiceTests Assert.Equal("NotFound", ex.Code); } + /// + /// Out-of-organization-scope mutation: deleted WO outside ApplyBaseScope → NotFound. + /// [Fact] - public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound() + public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound_OutOfOrgScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder @@ -965,8 +1022,11 @@ public class WorkOrderMediaServiceTests Assert.Equal("NotFound", ex.Code); } + /// + /// Out-of-organization-scope mutation: template WO outside ApplyBaseScope → NotFound. + /// [Fact] - public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound() + public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound_OutOfOrgScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder diff --git a/docs/adr/0001-work-order-single-org-scope.md b/docs/adr/0001-work-order-single-org-scope.md new file mode 100644 index 0000000..4abacc7 --- /dev/null +++ b/docs/adr/0001-work-order-single-org-scope.md @@ -0,0 +1,71 @@ +# ADR 0001: Work-order media uses single-org scope (board-aligned) + +## Status + +Accepted — 2026-08-04 + +## Context + +SH-116 requires that cross-tenant, unauthorized, and out-of-scope media access +be rejected without metadata disclosure. The repository hard rule +(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) requires **server-derived tenant +scope**: filtering by tenant/customer/owner comes from the authenticated +principal and the data layer, never from client-supplied body/query as the +source of truth. + +The work-order domain does not model `TenantId` / `CustomerId` on `WorkOrder` +or on JWT claims. The board, search, and detail paths already treat the +deployment as a single organization: staff see any work order that passes +`ApplyBaseScope` (non-deleted, non-template). Introducing a multi-tenant key +would require product modeling plus a schema migration, which is out of scope +for the SH-116 media contract. + +## Decision + +Work-order media authorization matches the board: + +1. **Organization / “tenant” boundary** = `ApplyBaseScope` in the data layer + (`istemplate != true` and not deleted). Lookups outside that set resolve as + missing → `NotFound` / null (no disclosure). +2. **Authorization at service entry** from claims: staff roles + (`Admin`, `Manager`, `Dispatcher`, `Supervisor`) may read/mutate any + in-org work order; role `User` (technician) only when + `WorkOrder.AssignTo == actorId`; delete remains staff-only. +3. Scope is never taken from request body or query as the trust source; + `actorId` and roles come from the authenticated principal. + +This satisfies server-derived scope for the current single-org deployment. +True multi-tenant isolation remains deferred until product models a tenant key +and emits a matching claim. + +## Consequences + +- Out-of-organization-scope tests cover deleted, template, and missing work + order ids (GET and mutations) as the single-org analogue of SH-116 + “cross-tenant” rejection. +- Staff org-wide access by numeric id remains intentional and aligned with the + board; it is not a substitute for future multi-tenant keys. +- Reviewers of media PRs should cite this ADR when evaluating tenant-scope + findings against SH-116. + +## Excepted / clarified rule + +Hard rule: **server-derived tenant scope** +(`ARCHITECTURE_AND_CODE_QUALITY.md` §2). + +Clarification: in the work-order domain, the server-derived scope key is the +organization boundary enforced by `ApplyBaseScope` plus claims-derived +role/assignee — not a `TenantId`/`CustomerId` column. Absence of a multi-tenant +key is an accepted product/architecture state until superseded. + +## Review / expiry + +Re-review by **2027-02-04**, or earlier if product introduces +`TenantId`/`CustomerId` on work orders or JWT claims. + +## References + +- SH-116 — Completion document: fields + media categorization +- PR that relies on this ADR: Sea-Haven-Industries/shoc-backend#47 +- `WorkOrderBoardQueryFilters.ApplyBaseScope` +- `WorkOrderMediaAuthorization` From e572786b1ce0211d72a433c75c2d06397e50a379 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 5 Aug 2026 09:57:14 -0300 Subject: [PATCH 07/16] ~docs(work-orders): demote ADR 0001 to Proposed pending CODEOWNERS [SH-116] --- .../Helpers/WorkOrderMediaAuthorization.cs | 21 ++-- .../WorkOrderPhase6Tests.cs | 24 ++--- docs/adr/0001-work-order-single-org-scope.md | 102 +++++++++++++----- 3 files changed, 100 insertions(+), 47 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs b/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs index 2af3080..0dd25f7 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs @@ -6,11 +6,12 @@ namespace SeaHaven.Services.Helpers { /// /// Claims-derived authorization for work-order media read/mutations. - /// Organization scope matches the board: callers must already have resolved - /// the work order via ApplyBaseScope (non-deleted, non-template). - /// Staff may access any in-org work order; technicians only when - /// matches the actor. See - /// docs/adr/0001-work-order-single-org-scope.md. + /// Callers must already have resolved the work order via + /// ApplyBaseScope (non-deleted, non-template). Staff may access any + /// such work order; technicians only when + /// matches the actor. This is not tenant/customer isolation — that + /// hard rule remains open pending + /// docs/adr/0001-work-order-single-org-scope.md (Proposed). /// public static class WorkOrderMediaAuthorization { @@ -54,8 +55,8 @@ namespace SeaHaven.Services.Helpers } /// - /// Caller-scope check after a base-scoped (in-org) work-order load. - /// Staff: any in-org work order already resolved via ApplyBaseScope. + /// Caller-scope check after an ApplyBaseScope work-order load. + /// Staff: any base-scoped work order (interim; no tenant key — ADR 0001 Proposed). /// Technician: only when assigned to the caller. /// Out of caller scope → NotFound (no disclosure). /// @@ -64,9 +65,9 @@ namespace SeaHaven.Services.Helpers string actorId, WorkOrder workOrder) { - // Organization boundary is enforced by the data-layer ApplyBaseScope - // load that produced . Staff are authorized - // for any in-org work order (same as the board). See ADR 0001. + // ApplyBaseScope already filtered the load. Staff may reach any such + // work order (board-aligned interim). Not a tenant boundary — see + // ADR 0001 (Proposed). if (IsStaff(user)) return; diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 0d2f6fc..d1e46bc 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -732,11 +732,11 @@ public class WorkOrderMediaServiceTests } /// - /// Out-of-organization-scope (single-org analogue of SH-116 cross-tenant): - /// deleted work orders are outside ApplyBaseScope → null / no media disclosure. + /// ApplyBaseScope filter: deleted work orders → null / no media disclosure. + /// (Not a cross-tenant test — no tenant key exists yet.) /// [Fact] - public async Task GetMedia_StaffOnDeletedWorkOrder_ReturnsNull_OutOfOrgScope() + public async Task GetMedia_StaffOnDeletedWorkOrder_ReturnsNull_OutsideBaseScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder @@ -753,10 +753,10 @@ public class WorkOrderMediaServiceTests } /// - /// Out-of-organization-scope: template work orders are outside ApplyBaseScope. + /// ApplyBaseScope filter: template work orders are excluded. /// [Fact] - public async Task GetMedia_StaffOnTemplateWorkOrder_ReturnsNull_OutOfOrgScope() + public async Task GetMedia_StaffOnTemplateWorkOrder_ReturnsNull_OutsideBaseScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder @@ -773,7 +773,7 @@ public class WorkOrderMediaServiceTests } [Fact] - public async Task GetMedia_MissingWorkOrder_ReturnsNull_OutOfOrgScope() + public async Task GetMedia_MissingWorkOrder_ReturnsNull_OutsideBaseScope() { var (_, service) = CreateSut(); @@ -989,10 +989,10 @@ public class WorkOrderMediaServiceTests } /// - /// Out-of-organization-scope mutation: missing id → NotFound (no disclosure). + /// ApplyBaseScope mutation: missing id → NotFound (no disclosure). /// [Fact] - public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound_OutOfOrgScope() + public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound_OutsideBaseScope() { var (_, service) = CreateSut(); var ex = await Assert.ThrowsAsync(() => @@ -1001,10 +1001,10 @@ public class WorkOrderMediaServiceTests } /// - /// Out-of-organization-scope mutation: deleted WO outside ApplyBaseScope → NotFound. + /// ApplyBaseScope mutation: deleted WO → NotFound. /// [Fact] - public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound_OutOfOrgScope() + public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound_OutsideBaseScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder @@ -1023,10 +1023,10 @@ public class WorkOrderMediaServiceTests } /// - /// Out-of-organization-scope mutation: template WO outside ApplyBaseScope → NotFound. + /// ApplyBaseScope mutation: template WO → NotFound. /// [Fact] - public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound_OutOfOrgScope() + public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound_OutsideBaseScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder diff --git a/docs/adr/0001-work-order-single-org-scope.md b/docs/adr/0001-work-order-single-org-scope.md index 4abacc7..eadaaf9 100644 --- a/docs/adr/0001-work-order-single-org-scope.md +++ b/docs/adr/0001-work-order-single-org-scope.md @@ -2,7 +2,16 @@ ## Status -Accepted — 2026-08-04 +**Proposed — awaiting approval** (2026-08-05). + +This ADR is **not** self-accepted by the PR author. Approval is required from: + +- CODEOWNERS team `@Sea-Haven-Industries/internal-dev` (see `.github/CODEOWNERS`) +- SH-116 product owner + +Until approved (or until real tenant enforcement lands), this document records a +**pending** exception request against the hard rule below — it does not waive +the rule on its own. ## Context @@ -13,18 +22,26 @@ scope**: filtering by tenant/customer/owner comes from the authenticated principal and the data layer, never from client-supplied body/query as the source of truth. -The work-order domain does not model `TenantId` / `CustomerId` on `WorkOrder` -or on JWT claims. The board, search, and detail paths already treat the -deployment as a single organization: staff see any work order that passes -`ApplyBaseScope` (non-deleted, non-template). Introducing a multi-tenant key -would require product modeling plus a schema migration, which is out of scope -for the SH-116 media contract. +Verified against the current codebase: -## Decision +- There is no `TenantId` / `CustomerId` column on `WorkOrder`, + `WorkOrderAttachments`, or `ApplicationUser`. +- `WorkOrder.Customer` is free-text (`nvarchar`), not a FK. +- JWT issuance (`AuthenticationService.GetToken`) emits only `Name`, + `NameIdentifier`, `Jti`, and `Role` — no tenant/customer claim. +- Board, search, and detail already treat the deployment as a single + organization via `ApplyBaseScope` (non-deleted, non-template). -Work-order media authorization matches the board: +Introducing a multi-tenant key requires product modeling plus a schema +migration, which is out of scope for the SH-116 media contract and blocked by +`AGENTS.md` (no migrations / product behavior without explicit instruction). -1. **Organization / “tenant” boundary** = `ApplyBaseScope` in the data layer +## Decision (proposed) + +Until real tenant enforcement exists, work-order media authorization matches +the board: + +1. **Organization boundary** = `ApplyBaseScope` in the data layer (`istemplate != true` and not deleted). Lookups outside that set resolve as missing → `NotFound` / null (no disclosure). 2. **Authorization at service entry** from claims: staff roles @@ -34,38 +51,73 @@ Work-order media authorization matches the board: 3. Scope is never taken from request body or query as the trust source; `actorId` and roles come from the authenticated principal. -This satisfies server-derived scope for the current single-org deployment. -True multi-tenant isolation remains deferred until product models a tenant key -and emits a matching claim. +This is **not** a substitute for SH-116 cross-tenant isolation. It is the +interim behavior while the exception is under review or real tenant keys land. + +## Accepted risk (while Proposed / if Accepted) + +Any authenticated **staff** principal who knows a numeric work-order id can +read or mutate media for that work order, provided it passes `ApplyBaseScope`. +There is no server-derived tenant/customer boundary separating staff access +across customers. Deleted / template / missing ids are **not** a cross-tenant +test; they only prove the `ApplyBaseScope` filter. ## Consequences -- Out-of-organization-scope tests cover deleted, template, and missing work - order ids (GET and mutations) as the single-org analogue of SH-116 - “cross-tenant” rejection. +- Tests for deleted, template, and missing work-order ids cover + `ApplyBaseScope` rejection only — they must not be labeled as SH-116 + cross-tenant coverage. - Staff org-wide access by numeric id remains intentional and aligned with the - board; it is not a substitute for future multi-tenant keys. -- Reviewers of media PRs should cite this ADR when evaluating tenant-scope - findings against SH-116. + board for this interim state. +- Merge of PR #47 that relies on this ADR requires either: + 1. formal approval of this ADR by the approvers listed in Status, or + 2. landing of real tenant enforcement (see below). -## Excepted / clarified rule +## Path to real enforcement + +Candidate design for the deferred multi-tenant work (tracked in the linked +Jira ticket): + +1. **Tenant key** — reuse the existing `Accounts` entity (CRM customer) as the + customer boundary; add `WorkOrder.AccountId` (FK) and + `ApplicationUser.AccountId` (or an equivalent membership table). +2. **Claim** — emit a server-derived `account_id` (or equivalent) claim in + `AuthenticationService.GetToken` from the authenticated user’s account + membership; never accept account id from body/query as trust source. +3. **Data filter** — extend `ApplyBaseScope` (or a sibling filter) so board, + detail, search, and media loads restrict by the claim-derived account + scope; staff may still be broader if product defines org-wide roles, but + that must be an explicit claims rule, not “any numeric id”. +4. **Backfill** — map free-text `WorkOrder.Customer` strings to `Accounts` + rows where possible; unresolved rows need a product decision (block, + orphan bucket, or manual remapping). +5. **Tests** — add true cross-tenant rejection tests (staff/tech of account A + cannot read or mutate media of a work order owned by account B) with stable + `NotFound` / `Forbidden` and no metadata disclosure. + +## Excepted rule Hard rule: **server-derived tenant scope** (`ARCHITECTURE_AND_CODE_QUALITY.md` §2). -Clarification: in the work-order domain, the server-derived scope key is the -organization boundary enforced by `ApplyBaseScope` plus claims-derived -role/assignee — not a `TenantId`/`CustomerId` column. Absence of a multi-tenant -key is an accepted product/architecture state until superseded. +Requested clarification while this ADR is Proposed/Accepted: in the work-order +domain, the interim server-derived scope key is the organization boundary +enforced by `ApplyBaseScope` plus claims-derived role/assignee — not a +`TenantId`/`CustomerId` column. Absence of a multi-tenant key is a temporary +product/architecture gap until superseded by the path above. ## Review / expiry Re-review by **2027-02-04**, or earlier if product introduces -`TenantId`/`CustomerId` on work orders or JWT claims. +`TenantId`/`CustomerId`/`AccountId` on work orders or JWT claims, or when the +linked multi-tenant ticket closes. ## References - SH-116 — Completion document: fields + media categorization +- SH-221 — Server-derived tenant/customer scope for Work Order domain (deferred enforcement) - PR that relies on this ADR: Sea-Haven-Industries/shoc-backend#47 - `WorkOrderBoardQueryFilters.ApplyBaseScope` - `WorkOrderMediaAuthorization` +- `ARCHITECTURE_AND_CODE_QUALITY.md` §2, §10 +- `REVIEW_AND_PR_FRAMEWORK.md` §7, §8 From fdc315d8fe4d5990d63746b3e8af1d8461b6b532 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 6 Aug 2026 09:47:34 -0300 Subject: [PATCH 08/16] !feat(work-orders): enforce media account scope and AddMedia freshness [SH-221] --- .../Auth/ApplicationDbContext.cs | 22 + ...38_SH221_WorkOrderAccountScope.Designer.cs | 3814 +++++++++++++++++ ...60806123838_SH221_WorkOrderAccountScope.cs | 110 + .../ApplicationDbContextModelSnapshot.cs | 27 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 4 + .../Helpers/WorkOrderBoardQueryFilters.cs | 12 + .../WorkOrderDetailDataService.cs | 16 +- .../WorkOrderMediaDataService.cs | 22 +- .../Interfaces/IWorkOrderDetailDataService.cs | 8 +- .../Interfaces/IWorkOrderMediaDataService.cs | 12 +- .../Helpers/SeaHavenClaimTypes.cs | 9 + .../Helpers/WorkOrderMediaAuthorization.cs | 28 +- .../Implementation/AuthenticationService.cs | 7 + .../Implementation/WorkOrderMediaService.cs | 34 +- .../WorkOrderPhase6Tests.cs | 183 +- docs/adr/0001-work-order-single-org-scope.md | 132 +- 16 files changed, 4308 insertions(+), 132 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260806123838_SH221_WorkOrderAccountScope.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260806123838_SH221_WorkOrderAccountScope.cs create mode 100644 SeaHaven.Services/Helpers/SeaHavenClaimTypes.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index c8b2369..342aa88 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -136,6 +136,24 @@ namespace Data.SeaHavenIndustries .HasForeignKey(w => w.PrimaryDispatchId) .OnDelete(DeleteBehavior.Restrict); + builder.Entity() + .HasOne(w => w.Account) + .WithMany() + .HasForeignKey(w => w.AccountId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(w => w.AccountId); + + builder.Entity() + .HasOne(u => u.Account) + .WithMany() + .HasForeignKey(u => u.AccountId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(u => u.AccountId); + builder.Entity() .HasIndex(c => c.NormalizedName) .IsUnique(); @@ -284,6 +302,10 @@ namespace Data.SeaHavenIndustries public string? Initials { get; set; } public string? Color { get; set; } public int? Type { get; set; } // 1 for users 0 for admin + /// Optional CRM account membership for server-derived media scope (SH-221). + public int? AccountId { get; set; } + [ForeignKey(nameof(AccountId))] + public virtual Accounts? Account { get; set; } public ICollection