using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using ItWorx.KnowledgeHub.Contracts.Auth; using ItWorx.KnowledgeHub.Contracts.Automation; using ItWorx.KnowledgeHub.Contracts.Errors; using ItWorx.KnowledgeHub.Contracts.Ingestion; using ItWorx.KnowledgeHub.Domain.Automation; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion; /// HTTP contract for the bounded trusted-intake route group. [Collection(PostgresTestGroup.Name)] public sealed class IngestionProposalApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Disabled_feature_returns_the_typed_ingestion_problem_instead_of_exposing_a_route() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/ingestion-proposals", new CreateIngestionProposalRequest("safe manifest", "correlation-1")); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var problem = await response.Content.ReadFromJsonAsync(); Assert.Equal(IngestionProblemTypes.FeatureDisabled, problem?.Type); var sourceCsrf = await SetupHelper.GetCsrfTokenAsync(client); using var sourceContent = new MultipartFormDataContent(); var sourceBody = new ByteArrayContent("disabled source"u8.ToArray()); sourceBody.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); sourceContent.Add(sourceBody, "file", "disabled.txt"); using var sourceRequest = new HttpRequestMessage( HttpMethod.Post, $"/api/v1/ingestion-proposals/{Guid.NewGuid()}/sources") { Content = sourceContent }; sourceRequest.Headers.Add(sourceCsrf.HeaderName, sourceCsrf.Value); sourceRequest.Headers.Add("Idempotency-Key", "disabled-source-upload"); sourceRequest.Headers.TryAddWithoutValidation("If-Match", "\"unused\""); var sourceResponse = await client.SendAsync(sourceRequest); Assert.Equal(HttpStatusCode.NotFound, sourceResponse.StatusCode); var sourceProblem = await sourceResponse.Content.ReadFromJsonAsync(); Assert.Equal(IngestionProblemTypes.FeatureDisabled, sourceProblem?.Type); } } [Collection(PostgresTestGroup.Name)] public sealed class IngestionProposalSourceApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase( postgres, new Dictionary { ["IngestionProposal:Enabled"] = "true" }) { [Fact] public async Task Upload_retry_with_a_new_multipart_boundary_replays_once_and_changed_bytes_conflict() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest("Retry document", "retry-source")) }; create.Headers.Add(csrf.HeaderName, csrf.Value); create.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); var created = await client.SendAsync(create); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); var key = Guid.NewGuid().ToString(); async Task UploadAsync(string content) { using var multipart = new MultipartFormDataContent(); var file = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(content)); file.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); multipart.Add(file, "file", "retry.txt"); using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal.Id}/sources") { Content = multipart }; request.Headers.Add(csrf.HeaderName, csrf.Value); request.Headers.Add("Idempotency-Key", key); request.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); return await client.SendAsync(request); } using var first = await UploadAsync("same bytes"); using var replay = await UploadAsync("same bytes"); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, replay.StatusCode); var firstDetail = await first.Content.ReadFromJsonAsync(); var replayDetail = await replay.Content.ReadFromJsonAsync(); Assert.Equal(Assert.Single(firstDetail!.Sources).AttachmentReferenceId, Assert.Single(replayDetail!.Sources).AttachmentReferenceId); using var conflict = await UploadAsync("changed bytes"); Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode); } [Fact] public async Task Human_document_upload_enters_review_only_after_source_and_explicit_submission() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest("Handmatig document", "manual-source", new IngestionPublicationRequest("Handboek", "Origineel als bijlage", "Reference", "Draft", "CreatePage"))) }; create.Headers.Add(csrf.HeaderName, csrf.Value); create.Headers.Add("Idempotency-Key", "manual-source-create"); var created = await client.SendAsync(create); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var detail = await created.Content.ReadFromJsonAsync(); Assert.NotNull(detail); using var tooEarly = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{detail.Id}/submit") { Content = JsonContent.Create(new { }) }; tooEarly.Headers.Add(csrf.HeaderName, csrf.Value); tooEarly.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.Conflict, (await client.SendAsync(tooEarly)).StatusCode); using var multipart = new MultipartFormDataContent(); var file = new ByteArrayContent("Original document"u8.ToArray()); file.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); multipart.Add(file, "file", "document.txt"); using var upload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{detail.Id}/sources") { Content = multipart }; upload.Headers.Add(csrf.HeaderName, csrf.Value); upload.Headers.Add("Idempotency-Key", "manual-source-upload"); upload.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); var uploaded = await client.SendAsync(upload); Assert.Equal(HttpStatusCode.OK, uploaded.StatusCode); using var submit = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{detail.Id}/submit") { Content = JsonContent.Create(new { }) }; submit.Headers.Add(csrf.HeaderName, csrf.Value); submit.Headers.TryAddWithoutValidation("If-Match", uploaded.Headers.ETag!.Tag); var submitted = await client.SendAsync(submit); Assert.Equal(HttpStatusCode.OK, submitted.StatusCode); var reviewed = await submitted.Content.ReadFromJsonAsync(); Assert.Equal("ReadyForReview", reviewed?.Status); Assert.Null(reviewed?.Analysis); Assert.Single(reviewed!.Sources); Assert.Contains(reviewed.Revisions, revision => revision.Operation == "manual-review-submitted"); using var lateMultipart = new MultipartFormDataContent(); var lateFile = new ByteArrayContent("late content"u8.ToArray()); lateFile.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); lateMultipart.Add(lateFile, "file", "late.txt"); using var lateUpload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{detail.Id}/sources") { Content = lateMultipart }; lateUpload.Headers.Add(csrf.HeaderName, csrf.Value); lateUpload.Headers.Add("Idempotency-Key", "manual-source-late-upload"); lateUpload.Headers.TryAddWithoutValidation("If-Match", submitted.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.Conflict, (await client.SendAsync(lateUpload)).StatusCode); } [Fact] public async Task Service_account_cannot_use_the_human_manual_submission_route() { using var owner = Factory.CreateClient(); await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword); var token = await CreateServiceAccountAsync(owner, "manual-submit-denied"); using var service = BearerClient(token.RawToken); using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{Guid.NewGuid()}/submit") { Content = JsonContent.Create(new { }) }; request.Headers.TryAddWithoutValidation("If-Match", "\"1\""); var response = await service.SendAsync(request); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task Source_upload_rate_limit_is_per_remote_ip() { using var clientA = Factory.CreateClient(); await SetupHelper.LoginAsync(clientA, OwnerEmail, OwnerPassword); var created = await CreateProposalAsync(clientA, "rate-limited source", "source-rate-limit", "source-rate-create"); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var currentETag = created.Headers.ETag!.Tag; clientA.DefaultRequestHeaders.Add("X-Test-Remote-Ip", "192.0.2.20"); for (var attempt = 0; attempt < 5; attempt++) { var csrf = await SetupHelper.GetCsrfTokenAsync(clientA); using var multipart = new MultipartFormDataContent(); var file = new ByteArrayContent("rate-limit source"u8.ToArray()); file.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); multipart.Add(file, "file", $"source-{attempt}.txt"); using var upload = new HttpRequestMessage( HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources") { Content = multipart }; upload.Headers.Add(csrf.HeaderName, csrf.Value); upload.Headers.Add("Idempotency-Key", $"source-rate-upload-a-{attempt}"); upload.Headers.TryAddWithoutValidation("If-Match", currentETag); var response = await clientA.SendAsync(upload); Assert.Equal(HttpStatusCode.OK, response.StatusCode); currentETag = response.Headers.ETag!.Tag; } using var clientB = Factory.CreateClient(); await SetupHelper.LoginAsync(clientB, OwnerEmail, OwnerPassword); clientB.DefaultRequestHeaders.Add("X-Test-Remote-Ip", "192.0.2.21"); var clientBCsrf = await SetupHelper.GetCsrfTokenAsync(clientB); using var clientBMultipart = new MultipartFormDataContent(); var clientBFile = new ByteArrayContent("other source"u8.ToArray()); clientBFile.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); clientBMultipart.Add(clientBFile, "file", "source-client-b.txt"); using var clientBUpload = new HttpRequestMessage( HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources") { Content = clientBMultipart }; clientBUpload.Headers.Add(clientBCsrf.HeaderName, clientBCsrf.Value); clientBUpload.Headers.Add("Idempotency-Key", "source-rate-upload-b"); clientBUpload.Headers.TryAddWithoutValidation("If-Match", currentETag); var clientBResponse = await clientB.SendAsync(clientBUpload); Assert.Equal(HttpStatusCode.OK, clientBResponse.StatusCode); currentETag = clientBResponse.Headers.ETag!.Tag; var clientACsrf = await SetupHelper.GetCsrfTokenAsync(clientA); using var exhaustedMultipart = new MultipartFormDataContent(); var exhaustedFile = new ByteArrayContent("blocked source"u8.ToArray()); exhaustedFile.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); exhaustedMultipart.Add(exhaustedFile, "file", "source-client-a-blocked.txt"); using var exhaustedUpload = new HttpRequestMessage( HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal.Id}/sources") { Content = exhaustedMultipart }; exhaustedUpload.Headers.Add(clientACsrf.HeaderName, clientACsrf.Value); exhaustedUpload.Headers.Add("Idempotency-Key", "source-rate-upload-a-exhausted"); exhaustedUpload.Headers.TryAddWithoutValidation("If-Match", currentETag); var clientAResponse = await clientA.SendAsync(exhaustedUpload); Assert.Equal(HttpStatusCode.TooManyRequests, clientAResponse.StatusCode); } [Fact] public async Task Patch_requires_an_IfMatch_header_and_rejects_a_stale_ETag() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var created = await CreateProposalAsync(client, "etag manifest", "etag-correlation", "etag-create"); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); var missingIfMatch = await SetupHelper.PatchWithCsrfAsync( client, $"/api/v1/ingestion-proposals/{proposal!.Id}", new PatchIngestionProposalRequest("without concurrency precondition", null)); Assert.Equal((HttpStatusCode)428, missingIfMatch.StatusCode); var firstCsrf = await SetupHelper.GetCsrfTokenAsync(client); using var firstPatch = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}") { Content = JsonContent.Create(new PatchIngestionProposalRequest("updated manifest", null)) }; firstPatch.Headers.Add(firstCsrf.HeaderName, firstCsrf.Value); firstPatch.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); var firstUpdated = await client.SendAsync(firstPatch); Assert.Equal(HttpStatusCode.OK, firstUpdated.StatusCode); var staleCsrf = await SetupHelper.GetCsrfTokenAsync(client); using var stalePatch = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}") { Content = JsonContent.Create(new PatchIngestionProposalRequest("stale manifest", null)) }; stalePatch.Headers.Add(staleCsrf.HeaderName, staleCsrf.Value); stalePatch.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag.Tag); var stale = await client.SendAsync(stalePatch); Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode); var problem = await stale.Content.ReadFromJsonAsync(); Assert.Equal(IngestionProblemTypes.ConcurrencyConflict, problem?.Type); var current = await client.GetFromJsonAsync($"/api/v1/ingestion-proposals/{proposal.Id}"); Assert.Equal("updated manifest", current?.Manifest); } [Fact] public async Task Reusing_an_idempotency_key_with_a_different_proposal_body_returns_conflict() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var first = await CreateProposalAsync(client, "original manifest", "idempotency-correlation", "same-key"); Assert.Equal(HttpStatusCode.Created, first.StatusCode); var second = await CreateProposalAsync(client, "different manifest", "idempotency-correlation", "same-key"); Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); var problem = await second.Content.ReadFromJsonAsync(); Assert.Equal(ProblemTypes.IdempotencyKeyConflict, problem?.Type); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(1, await db.IngestionProposals.CountAsync()); } [Fact] public async Task List_filters_by_status_and_uses_cursor_pagination_without_duplicates() { using var owner = Factory.CreateClient(); await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword); var receivedOne = await CreateProposalAsync(owner, "received one", "queue-1", "queue-1"); var receivedTwo = await CreateProposalAsync(owner, "received two", "queue-2", "queue-2"); var receivedOneDetail = await receivedOne.Content.ReadFromJsonAsync(); var receivedTwoDetail = await receivedTwo.Content.ReadFromJsonAsync(); Assert.NotNull(receivedOneDetail); Assert.NotNull(receivedTwoDetail); var service = await CreateServiceAccountAsync(owner, "queue-analyzer"); using var serviceClient = BearerClient(service.RawToken); var ready = await CreateProposalAsync(serviceClient, "ready manifest", "queue-ready", "queue-ready"); var readyDetail = await ready.Content.ReadFromJsonAsync(); Assert.NotNull(readyDetail); using var analysis = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{readyDetail!.Id}") { Content = JsonContent.Create(new PatchIngestionProposalRequest(null, null, "safe queue analysis")) }; analysis.Headers.TryAddWithoutValidation("If-Match", ready.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.OK, (await serviceClient.SendAsync(analysis)).StatusCode); var receivedIds = new List(); Guid? cursor = null; do { var path = cursor is null ? "/api/v1/ingestion-proposals?status=Received&limit=1" : $"/api/v1/ingestion-proposals?status=Received&limit=1&cursor={cursor}"; var page = await owner.GetFromJsonAsync(path); Assert.NotNull(page); Assert.All(page.Items, item => Assert.Equal("Received", item.Status)); receivedIds.AddRange(page.Items.Select(item => item.Id)); cursor = page.NextCursor; } while (cursor is not null); Assert.Equal(2, receivedIds.Count); Assert.Equal(2, receivedIds.Distinct().Count()); Assert.Contains(receivedOneDetail!.Id, receivedIds); Assert.Contains(receivedTwoDetail!.Id, receivedIds); var readyPage = await owner.GetFromJsonAsync("/api/v1/ingestion-proposals?status=ReadyForReview"); var readySummary = Assert.Single(readyPage!.Items); Assert.Equal(readyDetail.Id, readySummary.Id); Assert.Equal("ReadyForReview", readySummary.Status); } [Fact] public async Task Numbered_queue_pages_are_stable_for_equal_timestamps_and_unknown_cursors_do_not_change_order() { using var owner = Factory.CreateClient(); await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword); var olderResponse = await CreateProposalAsync(owner, "older queue item", "queue-older", "queue-older"); var newerResponse = await CreateProposalAsync(owner, "newer queue item", "queue-newer", "queue-newer"); var tiedResponse = await CreateProposalAsync(owner, "same-time queue item", "queue-tied", "queue-tied"); var older = await olderResponse.Content.ReadFromJsonAsync(); var newer = await newerResponse.Content.ReadFromJsonAsync(); var tied = await tiedResponse.Content.ReadFromJsonAsync(); Assert.NotNull(older); Assert.NotNull(newer); Assert.NotNull(tied); // Pin the immutable creation time so this regression is deterministic even // on a host whose clock has coarse timestamp precision. using (var scope = Factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var createdAt = DateTimeOffset.UtcNow; await db.IngestionProposalRevisions .Where(revision => EF.Property(revision, "IngestionProposalId") == older!.Id) .ExecuteUpdateAsync(update => update.SetProperty(revision => revision.OccurredAt, createdAt.AddMinutes(-5))); await db.IngestionProposalRevisions .Where(revision => EF.Property(revision, "IngestionProposalId") == newer!.Id) .ExecuteUpdateAsync(update => update.SetProperty(revision => revision.OccurredAt, createdAt)); await db.IngestionProposalRevisions .Where(revision => EF.Property(revision, "IngestionProposalId") == tied!.Id) .ExecuteUpdateAsync(update => update.SetProperty(revision => revision.OccurredAt, createdAt)); } var expectedIds = new[] { newer!.Id, tied!.Id } .OrderByDescending(id => id) .Append(older!.Id) .ToArray(); var actualIds = new List(); Guid? cursor = null; do { var path = cursor is null ? "/api/v1/ingestion-proposals?page=1&pageSize=1" : $"/api/v1/ingestion-proposals?cursor={cursor}&limit=1"; var page = await owner.GetFromJsonAsync(path); Assert.NotNull(page); actualIds.AddRange(page.Items.Select(item => item.Id)); cursor = page.NextCursor; } while (cursor is not null); Assert.Equal(expectedIds, actualIds); var unknownCursor = await owner.GetFromJsonAsync( $"/api/v1/ingestion-proposals?cursor={Guid.NewGuid()}&limit=1"); Assert.NotNull(unknownCursor); Assert.Empty(unknownCursor.Items); Assert.Null(unknownCursor.NextCursor); } [Fact] public async Task Queue_returns_a_bounded_page_total_and_truthful_publication_dossier() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var created = await CreateProposalAsync(client, "paged", "queue-contract", "queue-contract-key"); var session = await client.GetFromJsonAsync("/api/v1/auth/session"); Assert.NotNull(session?.UserId); var response = await client.GetFromJsonAsync("/api/v1/ingestion-proposals?page=1&pageSize=25&submitterId=" + session.UserId); Assert.NotNull(response); Assert.Equal(1, response.Page); Assert.Equal(25, response.PageSize); Assert.True(response.Total >= 1); var item = Assert.Single(response.Items); Assert.Equal(session.UserId, item.SubmitterId); Assert.Null(item.Destination); var createdDetail = await created.Content.ReadFromJsonAsync(); Assert.NotNull(createdDetail); Assert.NotNull(createdDetail.Permissions); Assert.Empty(createdDetail.Warnings); Assert.Empty(createdDetail.Citations); } [Fact] public async Task Create_persists_its_correlation_and_echoes_the_request_correlation_header() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); const string requestCorrelation = "ingestion-http-correlation"; var response = await CreateProposalAsync(client, "correlated manifest", "proposal-correlation", "correlation-key", requestCorrelation); Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(requestCorrelation, Assert.Single(response.Headers.GetValues("X-Correlation-ID"))); var detail = await response.Content.ReadFromJsonAsync(); Assert.Equal("proposal-correlation", detail?.CorrelationId); Assert.NotNull(response.Headers.ETag); Assert.Equal($"\"{detail!.ETag}\"", response.Headers.ETag!.Tag); } [Fact] public async Task Accepted_source_uses_the_stored_digest_and_a_proposal_only_attachment_usage() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest("safe manifest", "source-correlation")) }; create.Headers.Add(csrf.HeaderName, csrf.Value); create.Headers.Add("Idempotency-Key", "source-create-1"); var created = await client.SendAsync(create); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); var sourceCsrf = await SetupHelper.GetCsrfTokenAsync(client); using var multipart = new MultipartFormDataContent(); var sourceBody = new ByteArrayContent("trusted source bytes"u8.ToArray()); sourceBody.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); multipart.Add(sourceBody, "file", "evidence.pdf"); using var sourceRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal.Id}/sources") { Content = multipart }; sourceRequest.Headers.Add(sourceCsrf.HeaderName, sourceCsrf.Value); sourceRequest.Headers.Add("Idempotency-Key", "source-upload-1"); sourceRequest.Headers.Add("If-Match", created.Headers.ETag!.Tag); var response = await client.SendAsync(sourceRequest); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var detail = await response.Content.ReadFromJsonAsync(); var staged = Assert.Single(detail!.Sources); Assert.Equal("evidence.pdf", staged.FileName); Assert.NotEqual(new string('0', 64), staged.DigestSha256); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var usage = await db.AttachmentUsages.SingleAsync(item => item.AttachmentReferenceId == staged.AttachmentReferenceId); var reference = await db.AttachmentReferences.SingleAsync(item => item.Id == staged.AttachmentReferenceId); Assert.Equal(proposal.Id, usage.IngestionProposalId); Assert.Null(usage.PageId); Assert.Null(usage.StagingSessionId); Assert.Equal(reference.BlobSha256, staged.DigestSha256); var audit = await db.AuditEvents.SingleAsync(item => item.Action == "ingestion-proposal.source-staged" && item.TargetId == proposal.Id.ToString()); Assert.Equal("User", audit.ActorKind); Assert.Equal("web", audit.Source); Assert.DoesNotContain("trusted source bytes", audit.Summary, StringComparison.Ordinal); } [Fact] public async Task Rejected_source_does_not_create_a_proposal_attachment_usage_or_source_descriptor() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest("safe manifest", "rejected-source")) }; create.Headers.Add(csrf.HeaderName, csrf.Value); create.Headers.Add("Idempotency-Key", "rejected-source-create"); var created = await client.SendAsync(create); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); var sourceCsrf = await SetupHelper.GetCsrfTokenAsync(client); using var multipart = new MultipartFormDataContent(); var executable = new ByteArrayContent([0x4D, 0x5A, 0x90, 0x00]); executable.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); multipart.Add(executable, "file", "disguised.pdf"); using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources") { Content = multipart }; request.Headers.Add(sourceCsrf.HeaderName, sourceCsrf.Value); request.Headers.Add("Idempotency-Key", "rejected-source-upload"); request.Headers.Add("If-Match", created.Headers.ETag!.Tag); var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); Assert.Empty(await db.IngestionProposalSources.Where(source => EF.Property(source, "IngestionProposalId") == proposal.Id).ToListAsync()); Assert.Empty(await db.AttachmentUsages.Where(usage => usage.IngestionProposalId == proposal.Id).ToListAsync()); Assert.Empty(await db.AttachmentReferences.ToListAsync()); Assert.Empty(await db.AttachmentBlobs.ToListAsync()); } [Fact] public async Task Service_account_can_complete_only_its_own_analysis_and_read_its_metadata_only_status() { using var owner = Factory.CreateClient(); await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword); var first = await CreateServiceAccountAsync(owner, "ingestion-first"); var second = await CreateServiceAccountAsync(owner, "ingestion-second"); using var firstClient = BearerClient(first.RawToken); using var secondClient = BearerClient(second.RawToken); using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest("service manifest", "service-correlation")) }; create.Headers.Add("Idempotency-Key", "service-proposal"); var created = await firstClient.SendAsync(create); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); var foreignDetail = await secondClient.GetAsync($"/api/v1/ingestion-proposals/{proposal!.Id}"); Assert.Equal(HttpStatusCode.NotFound, foreignDetail.StatusCode); using var foreignAnalysis = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}") { Content = JsonContent.Create(new PatchIngestionProposalRequest(null, null, "foreign analysis")) }; foreignAnalysis.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.NotFound, (await secondClient.SendAsync(foreignAnalysis)).StatusCode); var ownStatus = await firstClient.GetAsync($"/api/v1/ingestion-proposals/{proposal.Id}"); Assert.Equal(HttpStatusCode.OK, ownStatus.StatusCode); var safeStatus = await ownStatus.Content.ReadFromJsonAsync(); Assert.Equal("service manifest", safeStatus?.Manifest); Assert.Empty(safeStatus!.Sources); using var forbiddenPatch = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}") { Content = JsonContent.Create(new PatchIngestionProposalRequest("forbidden", null, null)) }; forbiddenPatch.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.Forbidden, (await firstClient.SendAsync(forbiddenPatch)).StatusCode); using var analysisPatch = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}") { Content = JsonContent.Create(new PatchIngestionProposalRequest(null, null, "Safe analysis outcome.")) }; analysisPatch.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); var analyzed = await firstClient.SendAsync(analysisPatch); Assert.Equal(HttpStatusCode.OK, analyzed.StatusCode); var detail = await analyzed.Content.ReadFromJsonAsync(); Assert.NotNull(detail); Assert.Equal("ReadyForReview", detail.Status); Assert.Equal("service manifest", detail.Manifest); Assert.Equal("Safe analysis outcome.", detail.Analysis); Assert.Null(detail.ReviewerComment); Assert.Null(detail.FailureMessage); } [Fact] public async Task Service_account_with_ingestion_write_cannot_make_a_human_review_decision() { using var owner = Factory.CreateClient(); await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword); var service = await CreateServiceAccountAsync(owner, "review-denied"); using var serviceClient = BearerClient(service.RawToken); var created = await CreateProposalAsync(serviceClient, "review-bound manifest", "review-bound", "review-bound-key"); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); Assert.Equal(HttpStatusCode.Forbidden, (await serviceClient.GetAsync("/api/v1/ingestion-proposals")).StatusCode); foreach (var (route, request) in new[] { ("request-changes", new IngestionProposalDecisionRequest("review requested")), ("approve", new IngestionProposalDecisionRequest(null)), ("reject", new IngestionProposalDecisionRequest("review rejected")) }) { using var decision = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/{route}") { Content = JsonContent.Create(request) }; decision.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.Forbidden, (await serviceClient.SendAsync(decision)).StatusCode); } } [Fact] public async Task Cookie_reviewer_can_approve_a_ready_for_review_proposal() { using var owner = Factory.CreateClient(); await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword); var service = await CreateServiceAccountAsync(owner, "review-positive"); using var serviceClient = BearerClient(service.RawToken); var created = await CreateProposalAsync(serviceClient, "reviewable manifest", "review-positive", "review-positive-key"); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); using var analysis = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal!.Id}") { Content = JsonContent.Create(new PatchIngestionProposalRequest(null, null, "safe review analysis")) }; analysis.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); var analyzed = await serviceClient.SendAsync(analysis); var ready = await analyzed.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, analyzed.StatusCode); Assert.NotNull(ready); var csrf = await SetupHelper.GetCsrfTokenAsync(owner); using var approve = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal.Id}/approve") { Content = JsonContent.Create(new IngestionProposalDecisionRequest(null)) }; approve.Headers.Add(csrf.HeaderName, csrf.Value); approve.Headers.TryAddWithoutValidation("If-Match", $"\"{ready!.ETag}\""); var approved = await owner.SendAsync(approve); var responseBody = await approved.Content.ReadAsStringAsync(); Assert.True(approved.IsSuccessStatusCode, $"Expected approval to succeed, received {(int)approved.StatusCode} {approved.StatusCode}: {responseBody}"); using var responseJson = JsonDocument.Parse(responseBody); var status = responseJson.RootElement.GetProperty("status"); Assert.Equal(JsonValueKind.String, status.ValueKind); Assert.Equal("Approved", status.GetString()); } [Fact] public async Task Service_source_upload_audits_the_service_account_with_api_source() { using var owner = Factory.CreateClient(); await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword); var service = await CreateServiceAccountAsync(owner, "service-source-audit"); using var serviceClient = BearerClient(service.RawToken); var created = await CreateProposalAsync(serviceClient, "source audit manifest", "service-source-audit", "service-source-create"); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); using var multipart = new MultipartFormDataContent(); var sourceBody = new ByteArrayContent("service evidence"u8.ToArray()); sourceBody.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); multipart.Add(sourceBody, "file", "service-evidence.txt"); using var upload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources") { Content = multipart }; upload.Headers.Add("Idempotency-Key", "service-source-upload"); upload.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.OK, (await serviceClient.SendAsync(upload)).StatusCode); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var audit = await db.AuditEvents.SingleAsync(item => item.Action == "ingestion-proposal.source-staged" && item.TargetId == proposal.Id.ToString()); Assert.Equal("ServiceAccount", audit.ActorKind); Assert.Equal("api", audit.Source); } private static async Task CreateProposalAsync(HttpClient client, string manifest, string correlationId, string idempotencyKey, string? requestCorrelation = null) { var csrf = client.DefaultRequestHeaders.Authorization is null ? await SetupHelper.GetCsrfTokenAsync(client) : null; var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest(manifest, correlationId)) }; if (csrf is not null) request.Headers.Add(csrf.HeaderName, csrf.Value); request.Headers.Add("Idempotency-Key", idempotencyKey); if (requestCorrelation is not null) request.Headers.Add("X-Correlation-ID", requestCorrelation); return await client.SendAsync(request); } private static async Task CreateServiceAccountAsync(HttpClient owner, string name) { var response = await SetupHelper.PostWithCsrfAsync(owner, "/api/v1/service-accounts", new CreateServiceAccountRequest(name, "trusted-intake test", [Scopes.IngestionWrite])); response.EnsureSuccessStatusCode(); var token = await response.Content.ReadFromJsonAsync(); return Assert.IsType(token); } private HttpClient BearerClient(string token) { var client = Factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return client; } } [Collection(PostgresTestGroup.Name)] public sealed class IngestionProposalSourceLimitApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase( postgres, new Dictionary { ["IngestionProposal:Enabled"] = "true", ["IngestionProposal:MaximumSourceBytes"] = "4" }) { [Fact] public async Task Source_larger_than_the_configured_limit_is_rejected_before_staging() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var created = await CreateProposalAsync(client, "limit manifest", "limit-correlation", "limit-create"); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var multipart = new MultipartFormDataContent(); var sourceBody = new ByteArrayContent("12345"u8.ToArray()); sourceBody.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); multipart.Add(sourceBody, "file", "too-large.txt"); using var upload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources") { Content = multipart }; upload.Headers.Add(csrf.HeaderName, csrf.Value); upload.Headers.Add("Idempotency-Key", "limit-source"); upload.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await client.SendAsync(upload)).StatusCode); } private static async Task CreateProposalAsync(HttpClient client, string manifest, string correlationId, string idempotencyKey) { var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest(manifest, correlationId)) }; request.Headers.Add(csrf.HeaderName, csrf.Value); request.Headers.Add("Idempotency-Key", idempotencyKey); return await client.SendAsync(request); } }