mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
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.
This commit is contained in:
parent
899da0eb4f
commit
680012d88b
4 changed files with 485 additions and 28 deletions
82
SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs
Normal file
82
SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Claims-derived authorization for work-order media mutations.
|
||||
/// True multi-tenant CustomerId/TenantId is not modeled on WorkOrder/JWT;
|
||||
/// scope is role + Assigned (<see cref="WorkOrder.AssignTo"/>) for Technician.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staff: any in-scope (non-deleted/non-template) work order.
|
||||
/// Technician: only work orders assigned to the caller. Out-of-scope → NotFound (no disclosure).
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<WorkOrderMediaFileDto> 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<WorkOrder> GetMutableWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
private async Task<WorkOrder> 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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provider-backed media concurrency: two independently tracked contexts share one SQLite
|
||||
/// connection so EF concurrency tokens and competing writes are exercised (not InMemory).
|
||||
/// </summary>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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<ApplicationDbContext> CreateOptions(SqliteConnection connection)
|
||||
=> new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.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<string> 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<ApplicationDbContext> 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<WorkOrder>().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<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
BumpWorkOrderRowVersions();
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void BumpWorkOrderRowVersions()
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries<WorkOrder>())
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue