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; /// /// 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). /// [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 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(); 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(); 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>().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(); 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(); var secondSummary = await second.Content.ReadFromJsonAsync(); Assert.NotNull(firstSummary); Assert.NotNull(secondSummary); Assert.NotEqual(firstSummary.Id, secondSummary.Id); var list = await client.GetFromJsonAsync($"/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(); var secondSummary = await uploads[1].Content.ReadFromJsonAsync(); Assert.NotNull(firstSummary); Assert.NotNull(secondSummary); Assert.NotEqual(firstSummary.Id, secondSummary.Id); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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("", "pagina.html")] [InlineData("", "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(); 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(); 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(); 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); } }