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