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() {