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