mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 13:53:30 +00:00
feat(work-orders): authorize WO media content reads
This commit is contained in:
parent
a25fd0bd5d
commit
b4f2c8b763
10 changed files with 278 additions and 5 deletions
|
|
@ -1,9 +1,14 @@
|
||||||
using Api.SeaHavenIndustries.Controllers;
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using System.Security.Claims;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Tests;
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
@ -89,3 +94,47 @@ public class WorkOrderCompletionControllerUploadLimitTests
|
||||||
Assert.Equal(50_000_000L, bytes.Value);
|
Assert.Equal(50_000_000L, bytes.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class WorkOrderMediaServiceCancellationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMediaContent_ForwardsCancellationTokenToAllDataReads()
|
||||||
|
{
|
||||||
|
using var source = new CancellationTokenSource();
|
||||||
|
var token = source.Token;
|
||||||
|
var mediaData = new Mock<IWorkOrderMediaDataService>(MockBehavior.Strict);
|
||||||
|
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token))
|
||||||
|
.ReturnsAsync(new WorkOrder { Id = 1 });
|
||||||
|
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, token))
|
||||||
|
.ReturnsAsync(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.test/Assets/Documents/report.pdf"
|
||||||
|
});
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
storage.Setup(candidate => candidate.OpenRead(
|
||||||
|
"https://example.test/Assets/Documents/report.pdf"))
|
||||||
|
.Returns(new MemoryStream([1, 2, 3]));
|
||||||
|
var service = new WorkOrderMediaService(
|
||||||
|
mediaData.Object,
|
||||||
|
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
|
||||||
|
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
|
||||||
|
storage.Object);
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "actor-1"),
|
||||||
|
new Claim(ClaimTypes.Role, "Admin"),
|
||||||
|
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
|
||||||
|
},
|
||||||
|
"Test"));
|
||||||
|
|
||||||
|
var result = await service.GetMediaContentAsync(1, 10, user, "actor-1", token);
|
||||||
|
result.Content.Dispose();
|
||||||
|
|
||||||
|
mediaData.Verify(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token), Times.Once);
|
||||||
|
mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once);
|
||||||
|
mediaData.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,16 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<WorkOrderAttachments?> GetAttachmentForReadAsync(
|
||||||
|
int mediaId,
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> _context.workOrderAttachments
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
|
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
|
||||||
=> _context.workOrderAttachments.FirstOrDefaultAsync(
|
=> _context.workOrderAttachments.FirstOrDefaultAsync(
|
||||||
a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true,
|
a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
int? accountId,
|
int? accountId,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<WorkOrderAttachments?> GetAttachmentForReadAsync(
|
||||||
|
int mediaId,
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
|
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
|
||||||
void TrackAttachment(WorkOrderAttachments attachment);
|
void TrackAttachment(WorkOrderAttachments attachment);
|
||||||
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,13 @@ namespace SeaHaven.Services.DTOs
|
||||||
public bool IsLegacy { get; set; }
|
public bool IsLegacy { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class WorkOrderMediaContentDto
|
||||||
|
{
|
||||||
|
public required Stream Content { get; init; }
|
||||||
|
public required string ContentType { get; init; }
|
||||||
|
public required string FileName { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public class WorkOrderCompletionDocUploadDto
|
public class WorkOrderCompletionDocUploadDto
|
||||||
{
|
{
|
||||||
public string? SignOffName { get; set; }
|
public string? SignOffName { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderMediaDataService _mediaData;
|
private readonly IWorkOrderMediaDataService _mediaData;
|
||||||
private readonly IWorkOrderDetailDataService _detailData;
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
private readonly IWorkOrderAuditService _auditService;
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
private readonly IFileStoragePort _fileStorage;
|
||||||
|
|
||||||
public WorkOrderMediaService(
|
public WorkOrderMediaService(
|
||||||
IWorkOrderMediaDataService mediaData,
|
IWorkOrderMediaDataService mediaData,
|
||||||
IWorkOrderDetailDataService detailData,
|
IWorkOrderDetailDataService detailData,
|
||||||
IWorkOrderAuditService auditService)
|
IWorkOrderAuditService auditService,
|
||||||
|
IFileStoragePort fileStorage)
|
||||||
{
|
{
|
||||||
_mediaData = mediaData;
|
_mediaData = mediaData;
|
||||||
_detailData = detailData;
|
_detailData = detailData;
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
|
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
|
||||||
|
|
@ -48,6 +51,42 @@ namespace SeaHaven.Services.Implementation
|
||||||
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
|
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderMediaContentDto> GetMediaContentAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int mediaId,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
string? actorId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
|
||||||
|
if (mediaId <= 0)
|
||||||
|
throw MediaNotFound();
|
||||||
|
|
||||||
|
var workOrder = await GetMutableWorkOrderForAuthAsync(
|
||||||
|
workOrderId,
|
||||||
|
user,
|
||||||
|
actorId!,
|
||||||
|
cancellationToken);
|
||||||
|
var attachment = await _mediaData.GetAttachmentForReadAsync(
|
||||||
|
mediaId,
|
||||||
|
workOrder.Id,
|
||||||
|
cancellationToken);
|
||||||
|
if (attachment == null || string.IsNullOrWhiteSpace(attachment.Attachments))
|
||||||
|
throw MediaNotFound();
|
||||||
|
|
||||||
|
var content = _fileStorage.OpenRead(attachment.Attachments);
|
||||||
|
if (content == null)
|
||||||
|
throw MediaNotFound();
|
||||||
|
|
||||||
|
var fileName = GetSafeFileName(attachment.Attachments);
|
||||||
|
return new WorkOrderMediaContentDto
|
||||||
|
{
|
||||||
|
Content = content,
|
||||||
|
ContentType = GetContentType(fileName),
|
||||||
|
FileName = fileName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task EnsureCanMutateMediaAsync(
|
public async Task EnsureCanMutateMediaAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
|
|
@ -361,5 +400,37 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
private static string FormatMediaAuditValue(int? mediaId, string category)
|
private static string FormatMediaAuditValue(int? mediaId, string category)
|
||||||
=> mediaId.HasValue ? $"{mediaId.Value}:{category}" : category;
|
=> mediaId.HasValue ? $"{mediaId.Value}:{category}" : category;
|
||||||
|
|
||||||
|
private static WorkOrderBoardValidationException MediaNotFound()
|
||||||
|
=> new("NotFound", "Media not found.");
|
||||||
|
|
||||||
|
private static string GetSafeFileName(string fileUrl)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
|
||||||
|
return "download";
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(Uri.UnescapeDataString(uri.AbsolutePath));
|
||||||
|
if (fileName.Length > 37
|
||||||
|
&& fileName[36] == '_'
|
||||||
|
&& Guid.TryParse(fileName[..36], out _))
|
||||||
|
{
|
||||||
|
fileName = fileName[37..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(fileName) ? "download" : Path.GetFileName(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetContentType(string fileName)
|
||||||
|
=> Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".mp4" => "video/mp4",
|
||||||
|
".mov" => "video/quicktime",
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
".doc" => "application/msword",
|
||||||
|
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ namespace SeaHaven.Services.Interfaces
|
||||||
string? actorId,
|
string? actorId,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<WorkOrderMediaContentDto> GetMediaContentAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int mediaId,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
string? actorId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authorizes the caller and validates the work order is mutable before any blob storage write.
|
/// Authorizes the caller and validates the work order is mutable before any blob storage write.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
28
SeaHavenIndustries.Tests/TestFileStoragePort.cs
Normal file
28
SeaHavenIndustries.Tests/TestFileStoragePort.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
internal sealed class TestFileStoragePort : IFileStoragePort
|
||||||
|
{
|
||||||
|
private readonly Func<string, Stream?> _openRead;
|
||||||
|
|
||||||
|
public TestFileStoragePort(Func<string, Stream?>? openRead = null)
|
||||||
|
{
|
||||||
|
_openRead = openRead ?? (_ => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int TryDeleteCalls { get; private set; }
|
||||||
|
|
||||||
|
public Task<string> SaveFileAsync(IFormFile file)
|
||||||
|
=> Task.FromResult("https://example.test/Assets/Documents/saved.bin");
|
||||||
|
|
||||||
|
public bool TryDelete(string fileUrl)
|
||||||
|
{
|
||||||
|
TryDeleteCalls++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Stream? OpenRead(string fileUrl)
|
||||||
|
=> _openRead(fileUrl);
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,8 @@ public class WorkOrderCompletedSelectiveLockTests
|
||||||
var service = new WorkOrderMediaService(
|
var service = new WorkOrderMediaService(
|
||||||
new WorkOrderMediaDataService(context),
|
new WorkOrderMediaDataService(context),
|
||||||
new WorkOrderDetailDataService(context),
|
new WorkOrderDetailDataService(context),
|
||||||
audit);
|
audit,
|
||||||
|
new TestFileStoragePort());
|
||||||
return (context, service);
|
return (context, service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,8 @@ public class WorkOrderMediaConcurrencyRelationalTests
|
||||||
return new WorkOrderMediaService(
|
return new WorkOrderMediaService(
|
||||||
new WorkOrderMediaDataService(context),
|
new WorkOrderMediaDataService(context),
|
||||||
new WorkOrderDetailDataService(context),
|
new WorkOrderDetailDataService(context),
|
||||||
audit);
|
audit,
|
||||||
|
new TestFileStoragePort());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)
|
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace SeaHavenIndustries.Tests;
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
|
@ -798,7 +799,8 @@ public class WorkOrderCommentServiceTests
|
||||||
|
|
||||||
public class WorkOrderMediaServiceTests
|
public class WorkOrderMediaServiceTests
|
||||||
{
|
{
|
||||||
private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut()
|
private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut(
|
||||||
|
IFileStoragePort? fileStorage = null)
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
|
@ -809,7 +811,8 @@ public class WorkOrderMediaServiceTests
|
||||||
var service = new WorkOrderMediaService(
|
var service = new WorkOrderMediaService(
|
||||||
new WorkOrderMediaDataService(context),
|
new WorkOrderMediaDataService(context),
|
||||||
new WorkOrderDetailDataService(context),
|
new WorkOrderDetailDataService(context),
|
||||||
audit);
|
audit,
|
||||||
|
fileStorage ?? new TestFileStoragePort());
|
||||||
return (context, service);
|
return (context, service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1731,6 +1734,97 @@ public class WorkOrderMediaServiceTests
|
||||||
|
|
||||||
Assert.Equal("NotFound", ex.Code);
|
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 WorkOrderMediaFileRulesTests
|
public class WorkOrderMediaFileRulesTests
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue