251 lines
12 KiB
C#
251 lines
12 KiB
C#
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);
|
|
}
|
|
}
|