shoc-backend/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
Alexandre Brandizzi 46eed22707 fix(work-orders): accept mobile media whose declared MIME is foreign (SH-381)
Mobile browsers attach an unreliable Content-Type to a picked file: empty or
application/octet-stream when the OS cannot classify it, and sometimes a
foreign-but-plausible type for a supported container (video/3gpp for an .mp4,
video/x-quicktime for a .mov). The media allowlist already resolved empty and
octet-stream types from the extension, but a concrete foreign type was rejected
outright, so a real .MP4/.MOV picked on a phone passed the client dialog and was
refused by the API.

Any declared type that is not itself on the allowlist now falls back to the
extension. The extension pairing and magic-byte signature still decide, so the
accepted set of files is unchanged; an allowlisted declared type stays
authoritative and must still match its own extension.
2026-09-18 16:33:24 -03:00

2215 lines
80 KiB
C#

using System.Security.Claims;
using System.Text;
using System.IO.Compression;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public class WorkOrderDocStatusPatchTests
{
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
return (context, service);
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
[Theory]
[InlineData("Yes", DocStatus.Yes)]
[InlineData("No", DocStatus.No)]
[InlineData("NotNeeded", DocStatus.NotNeeded)]
public async Task PatchField_DocStatus_SetsValue(string value, DocStatus expected)
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DocStatus,
Value = value,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(expected, result.DocStatus);
var audit = await context.WorkOrderAuditLogs.SingleAsync();
Assert.Equal("DocStatus", audit.FieldName);
}
[Fact]
public async Task PatchField_DocStatus_ReadOnlyWorkOrder_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Canceled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DocStatus,
Value = "Yes",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("CanceledReadOnly", ex.Code);
}
}
public class WorkOrderDetailServiceTests
{
private static (ApplicationDbContext Context, WorkOrderDetailService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var detailData = new WorkOrderDetailDataService(context);
var templateData = new CompletionDocTemplateDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderDetailService(boardService, detailData, templateData, userData, resolver);
return (context, service);
}
[Fact]
public async Task GetDetail_ReturnsDerivedFieldsAndFeAuditMapping()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "00000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.AddDays(3),
ScheduledDate = DateTime.UtcNow.AddDays(-2),
Trade = "HVAC PM",
WorkOrderType = WorkOrderType.PM,
DocStatus = DocStatus.No,
Description = "Leak",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
context.CompletionDocTemplates.Add(new CompletionDocTemplate
{
Name = "HVAC PM Completion",
ServiceKey = "HVAC PM",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/hvac.pdf",
IsActive = true
});
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = 1,
EventType = "System",
Action = "WeekRolled",
FieldName = "CarriedOver",
OldValue = "0",
NewValue = "1",
CreatedAt = DateTime.UtcNow
});
context.Users.Add(new ApplicationUser
{
Id = "user-1",
UserName = "alice",
FirstName = "Alice",
LastName = "Dispatcher"
});
context.Comments.Add(new Comments
{
WorkerOrderId = 1,
UserId = "user-1",
Commenttext = "Called vendor",
CreatedDate = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc)
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.True(detail!.Info.IsPastDue);
Assert.Equal("HVAC PM", detail.Info.Trade);
Assert.Equal("Leak", detail.Info.Description);
Assert.NotNull(detail.Completion.Template);
Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name);
Assert.Single(detail.Comments);
Assert.Equal("user-1", detail.Comments[0].AuthorId);
Assert.Equal("Alice Dispatcher", detail.Comments[0].AuthorName);
Assert.Equal("Called vendor", detail.Comments[0].Text);
Assert.Single(detail.Audit);
Assert.Equal("system", detail.Audit[0].Type);
Assert.Equal("WeekRolled", detail.Audit[0].Action);
}
[Fact]
public async Task GetDetail_SurfacesPendingUpliftCountFromBoardRow()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(1),
PrimaryDispatchId = 10,
SiteCode = "BK5",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Equal(1, detail!.Info.PendingUpliftCount);
Assert.NotNull(detail.Info.UpliftSummary);
Assert.True(detail.Info.UpliftSummary!.HasUplift);
Assert.Equal(1, detail.Info.UpliftSummary.PendingCount);
Assert.Equal("pending", detail.Info.UpliftSummary.PrimaryStatus);
Assert.Equal(1500m, detail.Info.UpliftSummary.Amount);
}
[Fact]
public async Task GetDetail_SurfacesSeverityFromBoardRow()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Emergency,
Severity = "2",
ScheduledDate = DateTime.UtcNow.AddDays(1),
SiteCode = "BK5",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Equal(2, detail!.Info.Severity);
}
[Fact]
public async Task GetDetail_InactivePrimaryDispatch_OmitsVendorAndMapsStatus()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
Status = "Refused",
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(1),
PrimaryDispatchId = 10,
SiteCode = "BK5",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Null(detail!.Info.VendorId);
Assert.Null(detail.Info.VendorName);
Assert.Equal("Refused", detail.Info.PrimaryDispatchStatus);
}
[Fact]
public async Task GetDetail_LiveDispatchWithEmptyCompanyName_KeepsVendorIdAndMapsStatus()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
Status = "Sent",
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(1),
PrimaryDispatchId = 10,
SiteCode = "BK5",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Equal(1, detail!.Info.VendorId);
Assert.Equal("Sent", detail.Info.PrimaryDispatchStatus);
}
[Fact]
public async Task GetDetail_ReturnsAdditionalContactsFromBoardRow()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000218",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(1),
AdditionalContacts =
"[{\"name\":\"Backup Lead\",\"phone\":\"+1 555-0101\",\"notes\":\"After hours\"},{\"name\":\"Security Desk\",\"phone\":\"+1 555-0102\"}]",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.NotNull(detail!.Info.AdditionalContacts);
Assert.Equal(2, detail.Info.AdditionalContacts!.Count);
Assert.Equal("Backup Lead", detail.Info.AdditionalContacts[0].Name);
Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone);
Assert.Equal("After hours", detail.Info.AdditionalContacts[0].Notes);
Assert.Equal("Security Desk", detail.Info.AdditionalContacts[1].Name);
Assert.Equal("+1 555-0102", detail.Info.AdditionalContacts[1].Phone);
}
[Fact]
public async Task GetDetail_ReflectsPatchedAdditionalContacts()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "10000000219",
LifecycleStatus = LifecycleStatus.Incomplete,
AdditionalContacts = "[{\"name\":\"Old Backup\",\"phone\":\"+1 555-0000\"}]",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var mutationData = new WorkOrderBoardMutationDataService(context);
var boardData = new WorkOrderBoardDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
await update.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AdditionalContacts,
Value = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\",\"notes\":\"Call first\"}]",
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
}, "actor-1");
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Single(detail!.Info.AdditionalContacts!);
Assert.Equal("Jane Backup", detail.Info.AdditionalContacts![0].Name);
Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone);
Assert.Equal("Call first", detail.Info.AdditionalContacts[0].Notes);
}
[Fact]
public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames()
{
var (context, service) = CreateSut();
const string oldUserId = "a1000001-0001-4000-8000-000000000002";
const string newUserId = "b5e356d5-d926-4f92-8673-ee13cddeff0f";
context.Users.AddRange(
new ApplicationUser
{
Id = oldUserId,
UserName = "alice",
FirstName = "Alice",
LastName = "Dispatcher"
},
new ApplicationUser
{
Id = newUserId,
UserName = "bob",
FirstName = "Bob",
LastName = "Tech"
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "00000000001",
LifecycleStatus = LifecycleStatus.Pending,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = 1,
UserId = "actor-1",
EventType = "Manual",
Action = "AssignmentChanged",
FieldName = "AssignTo",
OldValue = oldUserId,
NewValue = newUserId,
CreatedAt = DateTime.UtcNow
});
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = 1,
UserId = "actor-1",
EventType = "Manual",
Action = "AssignmentChanged",
FieldName = "AssignTo",
OldValue = newUserId,
NewValue = "",
CreatedAt = DateTime.UtcNow.AddMinutes(1)
});
await context.SaveChangesAsync();
var audit = await service.GetAuditAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(audit);
Assert.Equal(2, audit!.Count);
var unassigned = audit.Single(a => a.NewValue == "Unassigned");
Assert.Equal("Bob Tech", unassigned.OldValue);
var reassigned = audit.Single(a => a.NewValue == "Bob Tech");
Assert.Equal("Alice Dispatcher", reassigned.OldValue);
}
}
public class WorkOrderCompletionServiceTests
{
private static (ApplicationDbContext Context, WorkOrderCompletionService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var templateData = new CompletionDocTemplateDataService(context);
var completionData = new WorkOrderCompletionDataService(context);
var detailData = new WorkOrderDetailDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(
completionData,
templateData,
detailData,
audit,
WorkOrderAccountTestHelpers.Resolver(context));
return (context, service);
}
[Fact]
public async Task UploadCompletionDoc_SetsDocStatusYesAndSignOff()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
Trade = "HVAC PM",
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto
{
SignOffName = "Jane Dispatcher",
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!)
},
"https://example.com/signed.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1");
Assert.Equal(DocStatus.Yes, result.DocStatus);
Assert.Equal("https://example.com/signed.pdf", result.SignOffAttachment);
Assert.Equal("Jane Dispatcher", result.SignOffName);
var updated = await context.workOrders.FindAsync(1);
Assert.Equal(DocStatus.Yes, updated!.DocStatus);
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
}
[Fact]
public async Task UploadCompletionDoc_SecondUpload_DoesNotDuplicateFieldLocks()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var version = Convert.ToBase64String(wo.RowVersion!);
await service.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto { WorkOrderVersion = version },
"https://example.com/first.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1");
// Simulate a new request scope: clear local tracker then reload
context.ChangeTracker.Clear();
var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1);
// InMemory does not bump rowversion; keep same bytes for the second call
reloaded.RowVersion ??= new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
await service.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(reloaded.RowVersion!)
},
"https://example.com/second.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1");
var locks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Equal(1, locks.Count(l => l.FieldName == "SignOffAttachment"));
Assert.Equal(1, locks.Count(l => l.FieldName == "DocStatus"));
Assert.Equal("https://example.com/second.pdf", reloaded.SignOffAttachment);
}
[Fact]
public async Task UploadCompletionDoc_MissingVersion_Throws()
{
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.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto(),
"https://example.com/signed.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1"));
Assert.Equal("WorkOrderVersionRequired", ex.Code);
}
[Fact]
public async Task UploadCompletionDoc_ReadOnly_Throws()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Completed
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
},
"https://example.com/signed.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1"));
Assert.Equal("ReadOnly", ex.Code);
}
}
public class WorkOrderCommentServiceTests
{
[Fact]
public async Task AddComment_ReturnsFeSchema()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
context.Users.Add(new ApplicationUser
{
Id = "user-abc",
UserName = "bob",
FirstName = "Bob",
LastName = "Tech"
});
await context.SaveChangesAsync();
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(
detailData, commentData, userData, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.AddCommentAsync(
1,
new WorkOrderCommentCreateDto { Text = "Note" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-abc"),
"user-abc");
Assert.Equal("user-abc", result.AuthorId);
Assert.Equal("Bob Tech", result.AuthorName);
Assert.Equal("Note", result.Text);
Assert.False(string.IsNullOrWhiteSpace(result.Time));
}
[Fact]
public async Task GetComments_FallsBackToCommenterWhenUserIdMissing()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
context.Comments.Add(new Comments
{
WorkerOrderId = 1,
Commenter = "Legacy Sync Author",
Commenttext = "Synced note",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(
detailData, commentData, userData, WorkOrderAccountTestHelpers.Resolver(context));
var comments = await service.GetCommentsAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(comments);
Assert.Single(comments!);
Assert.Null(comments[0].AuthorId);
Assert.Equal("Legacy Sync Author", comments[0].AuthorName);
Assert.Equal("Synced note", comments[0].Text);
}
private static async Task<(ApplicationDbContext Context, WorkOrderCommentService Service, Comments Comment)> SeedEditableCommentAsync(
LifecycleStatus status = LifecycleStatus.Scheduled,
string authorId = "user-author",
string commentType = "General",
string recordType = "WorkOrder",
int workOrderId = 1)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = workOrderId, LifecycleStatus = status });
context.Users.Add(new ApplicationUser
{
Id = authorId,
UserName = "author",
FirstName = "Alice",
LastName = "Dispatcher"
});
var comment = new Comments
{
WorkerOrderId = workOrderId,
UserId = authorId,
Commenttext = "Original",
CommentType = commentType,
RecordType = recordType,
CreatedDate = DateTime.UtcNow
};
context.Comments.Add(comment);
await context.SaveChangesAsync();
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
return (context, service, comment);
}
[Fact]
public async Task UpdateComment_Author_UpdatesText()
{
var (_, service, comment) = await SeedEditableCommentAsync();
var result = await service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = " Updated note " },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author");
Assert.Equal("Updated note", result.Text);
Assert.Equal("user-author", result.AuthorId);
Assert.Equal("Alice Dispatcher", result.AuthorName);
}
[Fact]
public async Task UpdateComment_AdminNonAuthor_ThrowsForbidden()
{
var (_, service, comment) = await SeedEditableCommentAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Admin edit" },
WorkOrderAccountTestHelpers.OrgWideAdmin("admin-user"),
"admin-user"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task UpdateComment_NonAuthor_ThrowsForbidden()
{
var (_, service, comment) = await SeedEditableCommentAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("other-user"),
"other-user"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task UpdateComment_LegacyVendorComment_ThrowsNotEditable()
{
var (_, service, comment) = await SeedEditableCommentAsync(
commentType: "vendor",
recordType: "WorkOrder");
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("NotEditable", ex.Code);
}
[Fact]
public async Task UpdateComment_ReadOnlyWorkOrder_Throws()
{
var (_, service, comment) = await SeedEditableCommentAsync(LifecycleStatus.Canceled);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("ReadOnly", ex.Code);
}
[Fact]
public async Task UpdateComment_WrongWorkOrder_ThrowsNotFound()
{
var (context, service, comment) = await SeedEditableCommentAsync(workOrderId: 1);
context.workOrders.Add(new WorkOrder { Id = 2, LifecycleStatus = LifecycleStatus.Scheduled });
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
2,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("NotFound", ex.Code);
}
}
public class WorkOrderMediaServiceTests
{
private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut(
IFileStoragePort? fileStorage = null)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
fileStorage ?? new TestFileStoragePort());
return (context, service);
}
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",
string role = "Admin",
int? accountId = null,
bool omitScopeClaims = false)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, actorId),
new Claim(ClaimTypes.Role, role)
};
if (!omitScopeClaims)
{
if (accountId.HasValue)
claims.Add(new Claim(SeaHavenClaimTypes.AccountId, accountId.Value.ToString()));
else if (role == "Admin")
claims.Add(new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
}
var identity = new ClaimsIdentity(claims, authenticationType: "Test");
return new ClaimsPrincipal(identity);
}
private static ClaimsPrincipal AuthenticatedWithMalformedAccountClaim(string actorId = "actor-1")
{
var identity = new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, actorId),
new Claim(ClaimTypes.Role, "Admin"),
new Claim(SeaHavenClaimTypes.AccountId, "not-an-int")
},
authenticationType: "Test");
return new ClaimsPrincipal(identity);
}
private static ClaimsPrincipal AuthenticatedWithoutRole(string actorId = "actor-1")
{
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, actorId) },
authenticationType: "Test");
return new ClaimsPrincipal(identity);
}
private static ClaimsPrincipal UnauthenticatedUser()
=> new ClaimsPrincipal(new ClaimsIdentity());
[Fact]
public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
BeforPhotoAttachment = "https://example.com/before.jpg",
AfterPhotoAttachment = "https://example.com/after.jpg"
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = "https://example.com/extra.pdf",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.NotNull(media);
Assert.Equal(3, media!.Count);
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Before);
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.After);
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra);
}
[Fact]
public async Task GetMedia_IncludesAvetaCategory()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder { Id = 1 });
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = "https://example.com/aveta.pdf",
Category = WorkOrderMediaCategory.Aveta
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.NotNull(media);
Assert.Contains(media!, m => m.Category == WorkOrderMediaCategory.Aveta);
}
[Fact]
public async Task AddMedia_Aveta_PersistsCategory()
{
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 media = await service.AddMediaAsync(
1,
WorkOrderMediaCategory.Aveta,
"https://example.com/aveta.pdf",
AuthenticatedUser(),
"actor-1");
Assert.True(media.Id > 0);
Assert.Equal(WorkOrderMediaCategory.Aveta, media.Category);
Assert.Equal(WorkOrderMediaCategory.Aveta, context.workOrderAttachments.Single().Category);
}
[Fact]
public async Task AddMedia_Extra_DoesNotPersistAsAveta()
{
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 media = await service.AddMediaAsync(
1,
WorkOrderMediaCategory.Extra,
"https://example.com/extra.pdf",
AuthenticatedUser(),
"actor-1");
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
Assert.Equal(WorkOrderMediaCategory.Extra, context.workOrderAttachments.Single().Category);
Assert.NotEqual(WorkOrderMediaCategory.Aveta, context.workOrderAttachments.Single().Category);
}
[Fact]
public async Task UpdateMediaCategory_ExtraToAveta()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/extra.pdf",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var media = await service.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Aveta, ToVersion(wo), AuthenticatedUser(), "actor-1");
Assert.Equal(WorkOrderMediaCategory.Aveta, media.Category);
Assert.Equal(WorkOrderMediaCategory.Aveta, context.workOrderAttachments.Single().Category);
Assert.True(context.workOrderAttachments.Single().IsDeleted != true);
}
[Fact]
public async Task GetMedia_TechnicianOnAssignedWorkOrder_Succeeds()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 1,
AssignTo = "tech-1",
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(
1,
AuthenticatedUser("tech-1", "User", accountId: 1),
"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,
AccountId = 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", accountId: 1), "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));
}
/// <summary>
/// ApplyBaseScope filter: deleted work orders → null / no media disclosure.
/// (Not a cross-tenant test — no tenant key exists yet.)
/// </summary>
[Fact]
public async Task GetMedia_StaffOnDeletedWorkOrder_ReturnsNull_OutsideBaseScope()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
IsDeleted = true,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.Null(media);
}
/// <summary>
/// ApplyBaseScope filter: template work orders are excluded.
/// </summary>
[Fact]
public async Task GetMedia_StaffOnTemplateWorkOrder_ReturnsNull_OutsideBaseScope()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
istemplate = true,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.Null(media);
}
[Fact]
public async Task GetMedia_MissingWorkOrder_ReturnsNull_OutsideBaseScope()
{
var (_, service) = CreateSut();
var media = await service.GetMediaAsync(99, AuthenticatedUser(), "actor-1");
Assert.Null(media);
}
[Fact]
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Canceled,
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(), "actor-1"));
Assert.Equal("ReadOnly", ex.Code);
}
[Fact]
public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint()
{
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.Completion, "https://example.com/doc.pdf", AuthenticatedUser(), "actor-1"));
Assert.Equal("UseCompletionDocEndpoint", ex.Code);
}
[Fact]
public async Task AddMedia_WithoutCategory_PersistsAsUncategorizedExtra()
{
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 media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", AuthenticatedUser(), "actor-1");
Assert.True(media.Id > 0);
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
Assert.Null(context.workOrderAttachments.Single().Category);
Assert.Contains(context.WorkOrderAuditLogs, a => a.FieldName == "MediaCategory");
}
[Fact]
public async Task AddMedia_MissingActor_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", AuthenticatedUser(), null));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task AddMedia_UnauthenticatedCaller_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",
UnauthenticatedUser(),
"actor-1"));
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,
AccountId = 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", accountId: 1),
"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,
AccountId = 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", accountId: 1),
"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,
AccountId = 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", accountId: 1),
"tech-1"));
Assert.Equal("Forbidden", ex.Code);
}
/// <summary>
/// ApplyBaseScope mutation: missing id → NotFound (no disclosure).
/// </summary>
[Fact]
public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound_OutsideBaseScope()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.EnsureCanMutateMediaAsync(99, AuthenticatedUser(), "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
/// <summary>
/// ApplyBaseScope mutation: deleted WO → NotFound.
/// </summary>
[Fact]
public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound_OutsideBaseScope()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
IsDeleted = true,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
/// <summary>
/// ApplyBaseScope mutation: template WO → NotFound.
/// </summary>
[Fact]
public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound_OutsideBaseScope()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = true,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task UpdateMediaCategory_SetsBeforeFromExtraAttachment()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = null
});
await context.SaveChangesAsync();
var media = await service.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1");
Assert.Equal(-1, media.Id);
Assert.Equal(WorkOrderMediaCategory.Before, media.Category);
Assert.Equal("https://example.com/photo.jpg", context.workOrders.Single().BeforPhotoAttachment);
Assert.True(context.workOrderAttachments.Single().IsDeleted);
Assert.Contains(context.WorkOrderAuditLogs, a =>
a.FieldName == "MediaCategory" && a.NewValue == "10:Before");
}
[Theory]
[InlineData("https://example.com/document.pdf")]
[InlineData("https://example.com/document.doc")]
[InlineData("https://example.com/document.docx")]
public async Task UpdateMediaCategory_DocumentToPhotoCategory_ThrowsUnsupportedMediaType(
string attachmentUrl)
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = attachmentUrl,
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateMediaCategoryAsync(
1,
10,
WorkOrderMediaCategory.Before,
ToVersion(wo),
AuthenticatedUser(),
"actor-1"));
Assert.Equal("UnsupportedMediaType", ex.Code);
Assert.Null(context.workOrders.Single().BeforPhotoAttachment);
Assert.True(context.workOrderAttachments.Single().IsDeleted != true);
}
[Fact]
public async Task UpdateMediaCategory_StaleVersion_ThrowsConcurrencyConflict()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var stale = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, stale, AuthenticatedUser(), "actor-1"));
Assert.Equal("ConcurrencyConflict", ex.Code);
}
[Fact]
public async Task UpdateMediaCategory_CrossWorkOrderMedia_ThrowsNotFound()
{
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 }
});
context.workOrders.Add(new WorkOrder
{
Id = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 2,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var version = ToVersion(context.workOrders.Single(w => w.Id == 1));
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, version, AuthenticatedUser(), "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task UpdateMediaCategory_DeletedMedia_ThrowsNotFound()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra,
IsDeleted = true
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task GetMedia_StaffWithAccountClaim_CrossAccount_ReturnsNull()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 20,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1, AuthenticatedUser(accountId: 10), "actor-1");
Assert.Null(media);
}
[Fact]
public async Task GetMedia_StaffWithAccountClaim_SameAccount_Succeeds()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 10,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1, AuthenticatedUser(accountId: 10), "actor-1");
Assert.NotNull(media);
Assert.Contains(media!, m => m.Category == WorkOrderMediaCategory.Before);
}
[Fact]
public async Task GetMedia_StaffWithOrgScopeClaim_Succeeds()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 99,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.NotNull(media);
}
[Fact]
public async Task GetMedia_StaffWithoutScopeClaim_ThrowsForbidden()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 99,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetMediaAsync(1, AuthenticatedUser(omitScopeClaims: true), "actor-1"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task GetMedia_MalformedAccountClaim_ThrowsForbidden()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 10,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetMediaAsync(1, AuthenticatedWithMalformedAccountClaim(), "actor-1"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task AddMedia_StaffWithoutScopeClaim_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",
AuthenticatedUser(role: "Dispatcher", omitScopeClaims: true),
"actor-1"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task AddMedia_StaffWithAccountClaim_CrossAccount_ThrowsNotFound()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 20,
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(accountId: 10),
"actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task AddMedia_TechnicianAssigned_CrossAccount_ThrowsNotFound()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 20,
AssignTo = "tech-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",
AuthenticatedUser("tech-1", "User", accountId: 10),
"tech-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task AddMedia_AfterPreCheck_BecomesReadOnly_ThrowsReadOnly_OnFreshLoad()
{
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();
await service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1");
var wo = await context.workOrders.SingleAsync(w => w.Id == 1);
wo.LifecycleStatus = LifecycleStatus.Canceled;
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(
1,
WorkOrderMediaCategory.Extra,
"https://example.com/photo.jpg",
AuthenticatedUser(),
"actor-1"));
Assert.Equal("ReadOnly", ex.Code);
}
[Fact]
public async Task AddMedia_AfterPreCheck_AssignmentChanged_ThrowsNotFound_OnFreshLoad()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 1,
AssignTo = "tech-1",
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
await service.EnsureCanMutateMediaAsync(1, AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1");
var wo = await context.workOrders.SingleAsync(w => w.Id == 1);
wo.AssignTo = "other-tech";
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(
1,
WorkOrderMediaCategory.Extra,
"https://example.com/photo.jpg",
AuthenticatedUser("tech-1", "User", accountId: 1),
"tech-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task ExtraPdf_ListContentDeleteContent_FollowsSoftDeleteLifecycle()
{
var storage = new TestFileStoragePort(_ => new MemoryStream(Encoding.ASCII.GetBytes("%PDF-1.7")));
var (context, service) = CreateSut(storage);
var workOrder = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
var fileUrl =
"https://example.test/Assets/Documents/11111111-1111-1111-1111-111111111111_report.pdf";
var added = await service.AddMediaAsync(
1,
WorkOrderMediaCategory.Extra,
fileUrl,
AuthenticatedUser(),
"actor-1");
var listed = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.Contains(listed!, candidate => candidate.Id == added.Id);
var content = await service.GetMediaContentAsync(
1,
added.Id,
AuthenticatedUser(),
"actor-1");
using (content.Content)
{
Assert.Equal("application/pdf", content.ContentType);
Assert.Equal("report.pdf", content.FileName);
}
await service.DeleteMediaAsync(
1,
added.Id,
ToVersion(workOrder),
AuthenticatedUser(),
"actor-1");
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetMediaContentAsync(1, added.Id, AuthenticatedUser(), "actor-1"));
Assert.Equal("NotFound", ex.Code);
Assert.Equal(0, storage.TryDeleteCalls);
}
[Fact]
public async Task GetMediaContent_CrossAccount_ThrowsNotFound()
{
var storage = new TestFileStoragePort(_ => new MemoryStream([1]));
var (context, service) = CreateSut(storage);
context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 20 });
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.test/Assets/Documents/report.pdf"
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetMediaContentAsync(
1,
10,
AuthenticatedUser(accountId: 10),
"actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task GetMediaContent_CanceledToken_ForwardsCancellation()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder { Id = 1 });
await context.SaveChangesAsync();
using var source = new CancellationTokenSource();
source.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.GetMediaContentAsync(
1,
10,
AuthenticatedUser(),
"actor-1",
source.Token));
}
}
public class WorkOrderCompletionDocFileRulesTests
{
private static readonly byte[] Pdf = Encoding.ASCII.GetBytes("%PDF-1.7\n1 0 obj\n");
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
=> new(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
[Fact]
public void IsAllowed_ValidPdf_ReturnsTrue()
{
Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "application/pdf")));
}
[Fact]
public void IsAllowed_PdfWithUndeterminedContentType_ReturnsTrue()
{
// The browser leaves File.type empty when the OS cannot determine it, and the
// completion-doc dialog already lets that through on a .pdf name. Rejecting it
// would break uploads that work today.
Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "")));
Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "application/octet-stream")));
}
[Fact]
public void IsAllowed_PdfExtensionWithNonPdfContent_ReturnsFalse()
{
var html = Encoding.UTF8.GetBytes("<html>not a pdf</html>");
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(html, "completion.pdf", "application/pdf")));
}
[Fact]
public void IsAllowed_PdfContentUnderDisallowedExtension_ReturnsFalse()
{
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.exe", "application/pdf")));
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion", "application/pdf")));
}
[Fact]
public void IsAllowed_DisallowedContentType_ReturnsFalse()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg")));
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "text/html")));
}
[Fact]
public void IsAllowed_EmptyFile_ReturnsFalse()
{
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Array.Empty<byte>(), "completion.pdf", "application/pdf")));
}
[Fact]
public void EnsureAllowed_RejectedFile_ThrowsUnsupportedMediaType()
{
var html = Encoding.UTF8.GetBytes("<html>not a pdf</html>");
var ex = Assert.Throws<WorkOrderBoardValidationException>(
() => WorkOrderCompletionDocFileRules.EnsureAllowed(FormFile(html, "completion.pdf", "application/pdf")));
Assert.Equal("UnsupportedMediaType", ex.Code);
}
[Fact]
public void MediaRules_StillRejectPdfOutsideDocumentCategories()
{
// The completion-doc allowlist must not widen the SH-116 media allowlist.
// SH-171 since added documents to media for Extra and Aveta, which is a
// deliberate widening of its own; what this guards is that it stopped
// there. Completion is the category SH-337 touches, and a photo slot
// must still refuse a PDF.
Assert.False(WorkOrderMediaFileRules.IsAllowed(
FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.Completion));
Assert.False(WorkOrderMediaFileRules.IsAllowed(
FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.Before));
Assert.False(WorkOrderMediaFileRules.IsAllowed(
FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.After));
}
}
public class WorkOrderMediaFileRulesTests
{
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
=> new(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
private static byte[] DocxBytes(bool includeWordEntry)
{
using var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(includeWordEntry ? "word/document.xml" : "not-word/content.xml");
using var writer = new StreamWriter(entry.Open());
writer.Write("<document />");
}
return stream.ToArray();
}
[Fact]
public void IsAllowed_ValidJpeg_ReturnsTrue()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg")));
}
[Fact]
public void IsAllowed_ImageJpgAlias_ReturnsTrue()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpg")));
}
[Fact]
public void IsAllowed_SpoofedExtension_ReturnsFalse()
{
var html = Encoding.UTF8.GetBytes("<html>not an image</html>");
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(html, "photo.jpg", "image/jpeg")));
}
[Fact]
public void IsAllowed_SpoofedMime_ReturnsFalse()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.exe", "image/jpeg")));
}
[Fact]
public void IsAllowed_MimeOrExtensionAlone_ReturnsFalse()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.bin", "application/octet-stream")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(new byte[] { 1, 2, 3 }, "photo.jpg", "image/jpeg")));
}
// Signatures below are the leading bytes of files produced by real encoders:
// ffmpeg (mp4 isom, mov qt, jpeg JFIF, png) and an iPhone/macOS .MOV (ftyp qt).
private static readonly byte[] RealJpeg =
{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01 };
private static readonly byte[] RealPng =
{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52 };
private static readonly byte[] RealMp4 =
{ 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00 };
private static readonly byte[] RealMov =
{ 0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00 };
private static readonly byte[] RealPdf = Encoding.ASCII.GetBytes("%PDF-1.7\n%\u00e2\u00e3\n1 0 obj\n");
[Theory]
[InlineData("photo.jpg", "image/jpeg")]
[InlineData("IMG_0001.JPG", "image/jpeg")]
[InlineData("photo.jpeg", "image/jpg")]
[InlineData("scan.png", "image/png")]
[InlineData("clip.mp4", "video/mp4")]
[InlineData("VID_0001.MP4", "video/mp4")]
[InlineData("IMG_1587.MOV", "video/quicktime")]
public void IsAllowed_RealMediaSignatures_ReturnsTrueForEveryCategory(string fileName, string contentType)
{
var bytes = BytesFor(fileName);
foreach (var category in new[]
{
WorkOrderMediaCategory.Extra, WorkOrderMediaCategory.Before, WorkOrderMediaCategory.After
})
{
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(bytes, fileName, contentType), category));
}
}
[Theory]
[InlineData("photo.jpg", "")]
[InlineData("IMG_0001.JPG", "application/octet-stream")]
[InlineData("scan.png", "")]
[InlineData("clip.mp4", "application/octet-stream")]
[InlineData("IMG_1587.MOV", "")]
[InlineData("IMG_1587.mov", "application/octet-stream")]
public void IsAllowed_UndeterminedMimeFromMobileBrowser_ResolvesFromExtensionAndSignature(
string fileName,
string contentType)
{
// Some mobile browsers leave File.type empty (or send octet-stream) when the OS
// cannot classify a picked file; the extension plus magic bytes must decide.
Assert.True(WorkOrderMediaFileRules.IsAllowed(
FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before));
}
[Fact]
public void IsAllowed_UndeterminedMime_StillRequiresMatchingSignature()
{
var html = Encoding.UTF8.GetBytes("<html>not an image</html>");
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(html, "photo.jpg", "")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealPng, "photo.jpg", "application/octet-stream")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mov", "")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.heic", "")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo", "application/octet-stream")));
}
[Theory]
[InlineData(WorkOrderMediaCategory.Completion)]
[InlineData(WorkOrderMediaCategory.Before)]
[InlineData(WorkOrderMediaCategory.After)]
public void IsAllowed_UndeterminedMimePdf_StillRefusedOutsideDocumentCategories(WorkOrderMediaCategory category)
{
// SH-337 / SH-171: resolving an empty MIME from ".pdf" must not reopen photo slots to documents.
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealPdf, "report.pdf", ""), category));
Assert.False(WorkOrderMediaFileRules.IsAllowed(
FormFile(RealPdf, "report.pdf", "application/octet-stream"), category));
}
[Fact]
public void IsAllowed_UndeterminedMimePdf_AcceptedForExtra()
{
Assert.True(WorkOrderMediaFileRules.IsAllowed(
FormFile(RealPdf, "report.pdf", ""), WorkOrderMediaCategory.Extra));
}
[Fact]
public void IsAllowed_AllowlistedDeclaredMimeMismatchingExtension_StillRejected()
{
// An allowlisted declared type stays authoritative and pairs against its own
// extension, so declaring video/mp4 for a .mov (or the reverse) is still a spoof.
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.mp4", "video/quicktime")));
}
[Theory]
// SH-381: mobile browsers attach a foreign-but-plausible type to a supported container.
// The extension plus magic bytes must decide, not the unreliable declared MIME.
[InlineData("clip.mp4", "video/3gpp")]
[InlineData("VID_0001.MP4", "video/3gpp")]
[InlineData("IMG_1587.MOV", "video/x-quicktime")]
[InlineData("IMG_1587.mov", "video/mpeg")]
[InlineData("photo.jpg", "image/heic")]
public void IsAllowed_ForeignDeclaredMime_ResolvesFromExtensionAndSignature(
string fileName,
string contentType)
{
Assert.True(WorkOrderMediaFileRules.IsAllowed(
FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before));
}
[Fact]
public void IsAllowed_ForeignDeclaredMime_StillRequiresMatchingSignatureAndExtension()
{
// Falling back to the extension does not weaken the gate: the bytes must still match
// the resolved type, the extension must still be supported, and a resolved document
// stays out of photo/video categories.
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mp4", "video/3gpp")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.exe", "video/3gpp")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(
FormFile(RealPdf, "report.pdf", "application/x-unknown"), WorkOrderMediaCategory.Before));
}
private static byte[] BytesFor(string fileName)
=> Path.GetExtension(fileName).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => RealJpeg,
".png" => RealPng,
".mp4" => RealMp4,
".mov" => RealMov,
".pdf" => RealPdf,
_ => throw new ArgumentOutOfRangeException(nameof(fileName))
};
[Fact]
public void IsAllowed_ValidMp4Ftyp_ReturnsTrue()
{
// size(4) + ftyp(4) + isom(4)
var mp4 = new byte[]
{
0x00, 0x00, 0x00, 0x18,
(byte)'f', (byte)'t', (byte)'y', (byte)'p',
(byte)'i', (byte)'s', (byte)'o', (byte)'m'
};
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(mp4, "clip.mp4", "video/mp4")));
}
[Fact]
public void EnsureAllowed_MalformedBytes_ThrowsUnsupportedMediaType()
{
var ex = Assert.Throws<WorkOrderBoardValidationException>(() =>
WorkOrderMediaFileRules.EnsureAllowed(FormFile(new byte[] { 0x00 }, "a.png", "image/png")));
Assert.Equal("UnsupportedMediaType", ex.Code);
}
[Theory]
[InlineData(WorkOrderMediaCategory.Extra)]
[InlineData(WorkOrderMediaCategory.Aveta)]
public void EnsureAllowed_PdfForDocumentCategory_Succeeds(WorkOrderMediaCategory category)
{
var pdf = Encoding.ASCII.GetBytes("%PDF-1.7");
WorkOrderMediaFileRules.EnsureAllowed(
FormFile(pdf, "report.pdf", "application/pdf"),
category);
}
[Fact]
public void EnsureAllowed_DocWithOleSignatureForExtra_Succeeds()
{
var doc = new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0x00 };
WorkOrderMediaFileRules.EnsureAllowed(
FormFile(doc, "report.doc", "application/msword"),
WorkOrderMediaCategory.Extra);
}
[Fact]
public void EnsureAllowed_RealDocxForExtra_Succeeds()
{
WorkOrderMediaFileRules.EnsureAllowed(
FormFile(
DocxBytes(includeWordEntry: true),
"report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
WorkOrderMediaCategory.Extra);
}
[Theory]
[InlineData(WorkOrderMediaCategory.Before)]
[InlineData(WorkOrderMediaCategory.After)]
public void EnsureAllowed_PdfForPhotoCategory_ThrowsUnsupportedMediaType(
WorkOrderMediaCategory category)
{
var ex = Assert.Throws<WorkOrderBoardValidationException>(() =>
WorkOrderMediaFileRules.EnsureAllowed(
FormFile(Encoding.ASCII.GetBytes("%PDF-1.7"), "report.pdf", "application/pdf"),
category));
Assert.Equal("UnsupportedMediaType", ex.Code);
}
[Fact]
public void EnsureAllowed_ZipWithoutWordEntry_ThrowsUnsupportedMediaType()
{
var ex = Assert.Throws<WorkOrderBoardValidationException>(() =>
WorkOrderMediaFileRules.EnsureAllowed(
FormFile(
DocxBytes(includeWordEntry: false),
"report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
WorkOrderMediaCategory.Extra));
Assert.Equal("UnsupportedMediaType", ex.Code);
}
}