Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
+250
@@ -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);
|
||||
}
|
||||
}
|
||||
+229
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user