Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

@@ -0,0 +1,250 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace ItWorx.KnowledgeHub.IntegrationTests.Attachments;
/// <summary>
/// The docs/13-attachments.md upload pipeline: signature-based detection (never
/// trusting the declared type/extension alone), safe filename handling, delivery
/// policy, deduplication, and re-authorized serving with safe headers
/// (docs/27-acceptance-matrix.md, M4-A01..A03).
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class AttachmentUploadTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private static readonly byte[] ExecutableBytes = [0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00];
private static async Task<Guid> CreatePageAsync(HttpClient client)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, $"Bijlagepagina {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page.Id;
}
[Fact]
public async Task Uploading_a_real_png_is_accepted_as_inline_eligible_and_can_be_downloaded_back_unchanged()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", PngBytes, "foto.png", "image/png");
Assert.Equal(HttpStatusCode.OK, uploadResponse.StatusCode);
var summary = await uploadResponse.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
Assert.Equal("image/png", summary.MediaType);
Assert.Equal("InlinePreviewEligible", summary.DeliveryPolicy);
Assert.Equal("foto.png", summary.Filename);
var contentResponse = await client.GetAsync($"/api/v1/attachments/{summary.Id}/content");
Assert.Equal(HttpStatusCode.OK, contentResponse.StatusCode);
Assert.Equal("image/png", contentResponse.Content.Headers.ContentType?.MediaType);
Assert.Equal("nosniff", contentResponse.Headers.GetValues("X-Content-Type-Options").Single());
Assert.Contains("inline", contentResponse.Content.Headers.ContentDisposition?.ToString() ?? string.Empty, StringComparison.OrdinalIgnoreCase);
var downloaded = await contentResponse.Content.ReadAsByteArrayAsync();
Assert.Equal(PngBytes, downloaded);
}
[Fact]
public async Task A_valid_attachment_above_kestrels_default_limit_reaches_the_100_mib_application_policy()
{
var kestrel = Factory.Services.GetRequiredService<IOptions<KestrelServerOptions>>().Value;
Assert.Equal(101L * 1024 * 1024, kestrel.Limits.MaxRequestBodySize);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var content = new byte[31 * 1024 * 1024];
content[0] = 0x50;
content[1] = 0x4B;
content[2] = 0x03;
content[3] = 0x04;
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", content, "grote-presentatie.pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var summary = await response.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
Assert.Equal(content.LongLength, summary.ByteLength);
Assert.Equal("DownloadOnly", summary.DeliveryPolicy);
}
[Fact]
public async Task Uploading_identical_content_twice_deduplicates_the_blob_but_keeps_two_independent_references()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var first = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{pageId}/attachments", PngBytes, "eerste.png", "image/png");
var second = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{pageId}/attachments", PngBytes, "tweede.png", "image/png");
var firstSummary = await first.Content.ReadFromJsonAsync<AttachmentSummary>();
var secondSummary = await second.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(firstSummary);
Assert.NotNull(secondSummary);
Assert.NotEqual(firstSummary.Id, secondSummary.Id);
var list = await client.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{pageId}/attachments");
Assert.NotNull(list);
Assert.Equal(2, list.Items.Count);
Assert.Contains(list.Items, item => item.Filename == "eerste.png");
Assert.Contains(list.Items, item => item.Filename == "tweede.png");
// Both references still serve independently and correctly even though they
// share one physical blob.
var firstContent = await client.GetAsync($"/api/v1/attachments/{firstSummary.Id}/content");
var secondContent = await client.GetAsync($"/api/v1/attachments/{secondSummary.Id}/content");
Assert.Equal(PngBytes, await firstContent.Content.ReadAsByteArrayAsync());
Assert.Equal(PngBytes, await secondContent.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task Concurrent_identical_uploads_are_atomic_and_keep_two_references_to_one_blob()
{
using var firstClient = Factory.CreateClient();
using var secondClient = Factory.CreateClient();
await SetupHelper.LoginAsync(firstClient, OwnerEmail, OwnerPassword);
await SetupHelper.LoginAsync(secondClient, OwnerEmail, OwnerPassword);
var firstPageId = await CreatePageAsync(firstClient);
var secondPageId = await CreatePageAsync(secondClient);
var uploads = await Task.WhenAll(
SetupHelper.PostMultipartWithCsrfAsync(firstClient, $"/api/v1/pages/{firstPageId}/attachments", PngBytes, "parallel-een.png", "image/png"),
SetupHelper.PostMultipartWithCsrfAsync(secondClient, $"/api/v1/pages/{secondPageId}/attachments", PngBytes, "parallel-twee.png", "image/png"));
Assert.All(uploads, response => Assert.Equal(HttpStatusCode.OK, response.StatusCode));
var firstSummary = await uploads[0].Content.ReadFromJsonAsync<AttachmentSummary>();
var secondSummary = await uploads[1].Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(firstSummary);
Assert.NotNull(secondSummary);
Assert.NotEqual(firstSummary.Id, secondSummary.Id);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var blobCount = await db.AttachmentBlobs.CountAsync(blob => blob.ByteLength == PngBytes.LongLength);
Assert.Equal(1, blobCount);
}
[Fact]
public async Task An_executable_signature_is_rejected_regardless_of_declared_type_or_extension()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
// Double extension + a declared image type — a classic disguise attempt
// (docs/13-attachments.md security tests: "double extensions").
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", ExecutableBytes, "factuur.pdf.exe", "image/png");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Theory]
[InlineData("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>", "pagina.html")]
[InlineData("<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>", "icoon.svg")]
public async Task HTML_and_SVG_content_is_rejected_in_v1(string payload, string filename)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", Encoding.UTF8.GetBytes(payload), filename, "text/plain");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_path_traversal_filename_is_sanitized_to_its_leaf_name_only()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", "veilige inhoud"u8.ToArray(), "../../../../etc/passwd", "text/plain");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var summary = await response.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
Assert.Equal("passwd", summary.Filename);
}
[Fact]
public async Task A_declared_type_that_does_not_match_the_real_signature_is_stored_under_the_detected_type()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
// Declares image/png but the bytes are plain text — the signature must win.
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", "gewone tekstinhoud"u8.ToArray(), "notitie.png", "image/png");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var summary = await response.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
Assert.Equal("text/plain", summary.MediaType);
Assert.Equal("DownloadOnly", summary.DeliveryPolicy);
}
[Fact]
public async Task An_empty_upload_is_rejected()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", [], "leeg.txt", "text/plain");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Fetching_attachment_content_requires_authentication()
{
using var authClient = Factory.CreateClient();
await SetupHelper.LoginAsync(authClient, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(authClient);
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
authClient, $"/api/v1/pages/{pageId}/attachments", PngBytes, "beveiligd.png", "image/png");
var summary = await uploadResponse.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
using var anonymousClient = Factory.CreateClient();
var response = await anonymousClient.GetAsync($"/api/v1/attachments/{summary.Id}/content");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Fetching_an_unknown_attachment_id_returns_not_found()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await client.GetAsync($"/api/v1/attachments/{Guid.NewGuid()}/content");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
@@ -0,0 +1,229 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Application.Attachments.UseCases;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Attachments;
/// <summary>
/// V2-R5 finding V2-R5-H1: before this fix, any workspace Reader who merely learned or
/// guessed a staged (not-yet-published) attachment's GUID could read its content —
/// `GetAttachmentContentUseCase` checked nothing beyond "not trashed"
/// (docs/adr/ADR-029-v2-r5-final-hardening.md). These tests prove the fix directly
/// against the real HTTP surface and real PostgreSQL, covering every scenario the ADR
/// names: workspace isolation, session-creator vs. unrelated Reader vs. Administrator,
/// committed vs. still-staged, a shared blob reached through two different references,
/// an expired-but-not-yet-cleaned-up session, and a revoked service token.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class StagedAttachmentAuthorizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private async Task<HttpClient> LoggedInClientAsync(string email, string password)
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, email, password);
return client;
}
private static async Task<Guid> BeginSessionAsync(HttpClient client)
{
var response = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/pages/staged", new BeginPageCreationSessionRequest(null));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<PageCreationSessionResponse>();
Assert.NotNull(session);
return session.SessionId;
}
private static async Task<Guid> UploadStagedAttachmentAsync(HttpClient client, Guid sessionId, string filename = "geheim.png")
{
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, filename, "image/png");
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
return attachment.Id;
}
private async Task<HttpClient> CreateReaderAsync(HttpClient adminClient, string email)
{
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users", new CreateUserRequest("Lezer", email, "reader-strong-password", "Reader"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
return await LoggedInClientAsync(email, "reader-strong-password");
}
[Fact]
public async Task The_sessions_own_creator_can_read_their_own_staged_attachment()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
var content = await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
}
[Fact]
public async Task An_unrelated_reader_with_only_the_guid_cannot_read_a_staged_attachment()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
using var reader = await CreateReaderAsync(owner, "onbevoegde-lezer@itworx.test");
var content = await reader.GetAsync($"/api/v1/attachments/{attachmentId}/content");
// Same 404 as "does not exist" — never a 403 that would confirm the ID is real.
Assert.Equal(HttpStatusCode.NotFound, content.StatusCode);
}
[Fact]
public async Task An_administrator_who_did_not_create_the_session_can_still_read_a_staged_attachment()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/admin/users",
new CreateUserRequest("Tweede beheerder", "tweede-beheerder@itworx.test", "admin-strong-password", "Administrator"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
using var secondAdmin = await LoggedInClientAsync("tweede-beheerder@itworx.test", "admin-strong-password");
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
var content = await secondAdmin.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
}
[Fact]
public async Task Once_committed_the_same_attachment_becomes_readable_by_an_ordinary_reader()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
var commit = await SetupHelper.PostWithCsrfAsync(
owner, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Gepubliceerd", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, commit.StatusCode);
using var reader = await CreateReaderAsync(owner, "lezer-na-publicatie@itworx.test");
var content = await reader.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
}
[Fact]
public async Task A_shared_blob_reached_through_two_references_is_gated_independently_per_reference()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
// Reference #1: uploaded directly onto an already-published page — ordinarily
// readable.
var pageResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/pages", new CreatePageRequest(null, $"Gedeelde-blob-pagina {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var publishedUpload = await SetupHelper.PostMultipartWithCsrfAsync(
owner, $"/api/v1/pages/{page.Id}/attachments", PngBytes, "gepubliceerd.png", "image/png");
Assert.Equal(HttpStatusCode.OK, publishedUpload.StatusCode);
var publishedAttachment = await publishedUpload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(publishedAttachment);
// Reference #2: the exact same bytes, staged into a still-open session — same
// physical blob (deduplicated), different logical reference, different owner.
var sessionId = await BeginSessionAsync(owner);
var stagedAttachmentId = await UploadStagedAttachmentAsync(owner, sessionId, "nog-niet-gepubliceerd.png");
using var reader = await CreateReaderAsync(owner, "lezer-gedeelde-blob@itworx.test");
var publishedContent = await reader.GetAsync($"/api/v1/attachments/{publishedAttachment.Id}/content");
Assert.Equal(HttpStatusCode.OK, publishedContent.StatusCode);
var stagedContent = await reader.GetAsync($"/api/v1/attachments/{stagedAttachmentId}/content");
Assert.Equal(HttpStatusCode.NotFound, stagedContent.StatusCode);
}
[Fact]
public async Task An_expired_but_not_yet_cleaned_up_session_is_treated_as_closed_even_for_its_own_creator()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE page_creation_sessions SET expires_at = {DateTimeOffset.UtcNow.AddHours(-1)} WHERE id = {sessionId}");
}
var content = await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.NotFound, content.StatusCode);
}
[Fact]
public async Task A_revoked_service_token_is_rejected_immediately_when_requesting_attachment_content()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var pageResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/pages", new CreatePageRequest(null, $"Token-pagina {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
owner, $"/api/v1/pages/{page.Id}/attachments", PngBytes, "token-test.png", "image/png");
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
// Creating a service account issues its first token in the same response
// (docs/09-api-contract.md) — no separate /tokens call needed.
var accountResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/service-accounts",
new CreateServiceAccountRequest("Bijlage-token-test", "Test", ["attachments:read"]));
Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
var token = await accountResponse.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(token);
using var serviceClient = Factory.CreateClient();
serviceClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.RawToken);
var beforeRevoke = await serviceClient.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.OK, beforeRevoke.StatusCode);
var revoke = await SetupHelper.PostWithCsrfAsync(owner, $"/api/v1/service-accounts/tokens/{token.TokenId}/revoke", new { });
Assert.Equal(HttpStatusCode.OK, revoke.StatusCode);
var afterRevoke = await serviceClient.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.Unauthorized, afterRevoke.StatusCode);
}
[Fact]
public async Task A_reference_belonging_to_a_different_workspace_is_never_served_even_with_valid_role()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
// No second real workspace exists in this single-tenant deployment to drive
// through HTTP end-to-end — the use case's workspace check is exercised
// directly instead, proving the defense-in-depth guard independently of the
// single-workspace HTTP routing that always resolves the same default
// workspace today (docs/adr/ADR-029-v2-r5-final-hardening.md).
using var scope = Factory.Services.CreateScope();
var useCase = scope.ServiceProvider.GetRequiredService<GetAttachmentContentUseCase>();
var result = await useCase.ExecuteAsync(
Guid.NewGuid(), attachmentId, new AttachmentRequestor(null, WorkspaceRole.Administrator), CancellationToken.None);
Assert.Null(result);
}
}