mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 20:42:09 +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")]
|
[HttpGet("{id:int}/media")]
|
||||||
public async Task<IActionResult> GetMedia(int id, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetMedia(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var media = await _workOrderMediaService.GetMediaAsync(id, cancellationToken);
|
try
|
||||||
if (media == null)
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
return Ok(media);
|
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")]
|
[HttpPost("{id:int}/media")]
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> ExistsAsync(int workOrderId)
|
public Task<bool> ExistsAsync(int workOrderId, CancellationToken cancellationToken = default)
|
||||||
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||||
.AnyAsync(w => w.Id == workOrderId);
|
.AnyAsync(w => w.Id == workOrderId, cancellationToken);
|
||||||
|
|
||||||
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
|
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
|
||||||
{
|
{
|
||||||
|
|
@ -63,13 +63,15 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return await query.ToListAsync();
|
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
|
return await _context.workOrderAttachments
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
|
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
|
||||||
.OrderByDescending(a => a.CreatedDate)
|
.OrderByDescending(a => a.CreatedDate)
|
||||||
.ToListAsync();
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
|
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
|
||||||
|
|
@ -86,10 +88,12 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.ToListAsync();
|
.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())
|
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
|
public interface IWorkOrderDetailDataService
|
||||||
{
|
{
|
||||||
Task<bool> ExistsAsync(int workOrderId);
|
Task<bool> ExistsAsync(int workOrderId, CancellationToken cancellationToken = default);
|
||||||
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
|
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
|
||||||
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
|
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
|
||||||
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
|
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<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
|
||||||
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
|
Task<WorkOrder?> GetWorkOrderForMediaAsync(
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ICompletionDocTemplateDataService
|
public interface ICompletionDocTemplateDataService
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ using SeaHaven.Services.Exceptions;
|
||||||
namespace SeaHaven.Services.Helpers
|
namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <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;
|
/// True multi-tenant CustomerId/TenantId is not modeled on WorkOrder/JWT;
|
||||||
/// scope is role + Assigned (<see cref="WorkOrder.AssignTo"/>) for Technician.
|
/// scope is role + Assigned (<see cref="WorkOrder.AssignTo"/>) for Technician.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -19,6 +19,16 @@ namespace SeaHaven.Services.Helpers
|
||||||
"Supervisor"
|
"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)
|
public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(user, actorId);
|
EnsureAuthenticated(user, actorId);
|
||||||
|
|
@ -61,22 +71,25 @@ namespace SeaHaven.Services.Helpers
|
||||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
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
|
if (user is null
|
||||||
|| !(user.Identity?.IsAuthenticated ?? false)
|
|| !(user.Identity?.IsAuthenticated ?? false)
|
||||||
|| string.IsNullOrWhiteSpace(actorId))
|
|| string.IsNullOrWhiteSpace(actorId))
|
||||||
{
|
{
|
||||||
throw Forbidden();
|
throw Forbidden(forbiddenMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsStaff(ClaimsPrincipal user)
|
private static bool IsStaff(ClaimsPrincipal user)
|
||||||
=> StaffRoles.Any(user.IsInRole);
|
=> StaffRoles.Any(user.IsInRole);
|
||||||
|
|
||||||
private static WorkOrderBoardValidationException Forbidden()
|
private static WorkOrderBoardValidationException Forbidden(string? message = null)
|
||||||
=> new(
|
=> new(
|
||||||
"Forbidden",
|
"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(
|
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
string? actorId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!await _detailData.ExistsAsync(workOrderId))
|
WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
|
||||||
|
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken);
|
||||||
if (workOrder == null)
|
if (workOrder == null)
|
||||||
return 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);
|
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ namespace SeaHaven.Services.Interfaces
|
||||||
{
|
{
|
||||||
public interface IWorkOrderMediaService
|
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>
|
/// <summary>
|
||||||
/// Authorizes the caller and validates the work order is mutable before any blob storage write.
|
/// 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();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var media = await service.GetMediaAsync(1);
|
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
|
||||||
|
|
||||||
Assert.NotNull(media);
|
Assert.NotNull(media);
|
||||||
Assert.Equal(3, media!.Count);
|
Assert.Equal(3, media!.Count);
|
||||||
|
|
@ -640,6 +640,97 @@ public class WorkOrderMediaServiceTests
|
||||||
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra);
|
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]
|
[Fact]
|
||||||
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
|
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue