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);
}
}