using System.Net; using System.Net.Http.Json; using System.Security.Cryptography; using ItWorx.KnowledgeHub.Contracts.Attachments; using ItWorx.KnowledgeHub.Contracts.Knowledge; using ItWorx.KnowledgeHub.Contracts.Migration; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace ItWorx.KnowledgeHub.IntegrationTests.Migration; [Collection(PostgresTestGroup.Name)] public sealed class SourceBatchPurgeTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03]; private const string BatchId = "canvas-20260801T134020Z"; [Fact] public async Task The_administrator_only_endpoint_rejects_anonymous_requests() { using var client = Factory.CreateClient(); var response = await client.PostAsJsonAsync( "/api/v1/admin/source-batches/purge/dry-run", new SourceBatchPurgeRequest(BatchId, "canvas", [new SourceBatchPurgePageEvidence(Guid.NewGuid(), new string('0', 64))])); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Dry_run_blocks_when_a_canvas_candidate_is_not_in_the_reviewed_batch_list() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var selected = await CreatePageAsync(client, "Geselecteerde bron", ["source:canvas", "course:test"]); await CreatePageAsync(client, "Niet geselecteerde bron", ["source:canvas", "course:test"]); var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/source-batches/purge/dry-run", new SourceBatchPurgeRequest(BatchId, "canvas", [await EvidenceAsync(selected)])); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var dryRun = await response.Content.ReadFromJsonAsync(); Assert.NotNull(dryRun); Assert.False(dryRun.Safe); Assert.Contains(dryRun.Blockers, blocker => blocker.Contains("buiten de gecontroleerde batchlijst", StringComparison.Ordinal)); } [Fact] public async Task Dry_run_blocks_when_a_selected_page_no_longer_matches_its_reviewed_content_hash() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var pageId = await CreatePageAsync(client, "Canvasbron", ["source:canvas", "course:test"]); var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/source-batches/purge/dry-run", new SourceBatchPurgeRequest(BatchId, "canvas", [new SourceBatchPurgePageEvidence(pageId, new string('0', 64))])); var dryRun = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(dryRun); Assert.False(dryRun.Safe); Assert.Contains(dryRun.Blockers, blocker => blocker.Contains("gevalideerde bronhash", StringComparison.Ordinal)); } [Fact] public async Task A_confirmed_safe_purge_removes_the_selected_page_references_and_verified_orphan_blob() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var pageId = await CreatePageAsync(client, "Canvasbron", ["source:canvas", "course:test"]); var upload = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{pageId}/attachments", PngBytes, "canvas.png", "image/png"); Assert.Equal(HttpStatusCode.OK, upload.StatusCode); var dryRun = await DryRunAsync(client, [pageId]); Assert.True(dryRun.Safe); Assert.Equal(1, dryRun.Pages); Assert.Equal(1, dryRun.AttachmentReferences); Assert.Equal(1, dryRun.OrphanedBlobs); var execute = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/source-batches/purge/execute", new ExecuteSourceBatchPurgeRequest(BatchId, "canvas", [await EvidenceAsync(pageId)], dryRun.ConfirmationHash)); Assert.Equal(HttpStatusCode.OK, execute.StatusCode); var result = await execute.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.Equal("purged", result.Status); Assert.Equal(1, result.RemovedPages); Assert.Equal(1, result.RemovedAttachmentReferences); Assert.Equal(1, result.RemovedBlobs); Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/v1/pages/{pageId}")).StatusCode); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(0, await db.AttachmentReferences.CountAsync()); Assert.Equal(0, await db.AttachmentBlobs.CountAsync()); var sha256 = Convert.ToHexString(SHA256.HashData(PngBytes)).ToLowerInvariant(); Assert.False(File.Exists(Path.Combine(Factory.AttachmentsDirectory, sha256[..2], sha256[2..4], sha256))); } [Fact] public async Task A_shared_blob_is_retained_when_a_non_batch_page_still_references_it() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var canvasPageId = await CreatePageAsync(client, "Canvasbron", ["source:canvas", "course:test"]); var retainedPageId = await CreatePageAsync(client, "Infrastructuur", ["infrastructure"]); var canvasUpload = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{canvasPageId}/attachments", PngBytes, "canvas.png", "image/png"); var retainedUpload = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{retainedPageId}/attachments", PngBytes, "infra.png", "image/png"); Assert.Equal(HttpStatusCode.OK, canvasUpload.StatusCode); Assert.Equal(HttpStatusCode.OK, retainedUpload.StatusCode); var retainedAttachment = await retainedUpload.Content.ReadFromJsonAsync(); Assert.NotNull(retainedAttachment); var dryRun = await DryRunAsync(client, [canvasPageId]); Assert.True(dryRun.Safe); Assert.Equal(0, dryRun.OrphanedBlobs); Assert.Equal(1, dryRun.SharedBlobs); var execute = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/source-batches/purge/execute", new ExecuteSourceBatchPurgeRequest(BatchId, "canvas", [await EvidenceAsync(canvasPageId)], dryRun.ConfirmationHash)); var result = await execute.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.Equal(HttpStatusCode.OK, execute.StatusCode); Assert.Equal(0, result.RemovedBlobs); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/api/v1/attachments/{retainedAttachment.Id}/content")).StatusCode); } private static async Task CreatePageAsync(HttpClient client, string title, IReadOnlyList tags) { var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, title, "WikiPage", "\nBron\n", null, tags)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var page = await response.Content.ReadFromJsonAsync(); Assert.NotNull(page); return page.Id; } private async Task DryRunAsync(HttpClient client, IReadOnlyList pageIds) { var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/source-batches/purge/dry-run", new SourceBatchPurgeRequest(BatchId, "canvas", await Task.WhenAll(pageIds.Select(pageId => EvidenceAsync(pageId))))); Assert.Equal(HttpStatusCode.OK, response.StatusCode); return await response.Content.ReadFromJsonAsync() ?? throw new InvalidOperationException("Geen dry-run ontvangen."); } private async Task EvidenceAsync(Guid pageId) { using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var contentHash = await db.Pages .Where(page => page.Id == pageId) .Select(page => page.ContentHash) .SingleAsync(); return new SourceBatchPurgePageEvidence(pageId, contentHash); } }