mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
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.
This commit is contained in:
parent
680012d88b
commit
6b18327d6b
7 changed files with 158 additions and 23 deletions
|
|
@ -31,10 +31,23 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpGet("{id:int}/media")]
|
||||
public async Task<IActionResult> 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")]
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context = context;
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(int workOrderId)
|
||||
public Task<bool> 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<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
|
||||
{
|
||||
|
|
@ -63,13 +63,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId)
|
||||
public async Task<IReadOnlyList<WorkOrderAttachments>> 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<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
|
||||
|
|
@ -86,10 +88,12 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId)
|
||||
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId);
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,17 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
public interface IWorkOrderDetailDataService
|
||||
{
|
||||
Task<bool> ExistsAsync(int workOrderId);
|
||||
Task<bool> ExistsAsync(int workOrderId, CancellationToken cancellationToken = default);
|
||||
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
|
||||
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
|
||||
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
|
||||
Task<WorkOrder?> GetWorkOrderForMediaAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ICompletionDocTemplateDataService
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ using SeaHaven.Services.Exceptions;
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 (<see cref="WorkOrder.AssignTo"/>) for Technician.
|
||||
/// </summary>
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,16 +28,22 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ namespace SeaHaven.Services.Interfaces
|
|||
{
|
||||
public interface IWorkOrderMediaService
|
||||
{
|
||||
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes the caller and validates the work order is mutable before any blob storage write.
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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<OperationCanceledException>(() =>
|
||||
service.GetMediaAsync(1, AuthenticatedUser(), "actor-1", cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue