Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

@@ -0,0 +1,767 @@
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;
/// <summary>HTTP contract for the bounded trusted-intake route group.</summary>
[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<ProblemDetails>();
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<ProblemDetails>();
Assert.Equal(IngestionProblemTypes.FeatureDisabled, sourceProblem?.Type);
}
}
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionProposalSourceApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(
postgres, new Dictionary<string, string?> { ["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<IngestionProposalDetail>();
Assert.NotNull(proposal);
var key = Guid.NewGuid().ToString();
async Task<HttpResponseMessage> 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<IngestionProposalDetail>();
var replayDetail = await replay.Content.ReadFromJsonAsync<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<ProblemDetails>();
Assert.Equal(IngestionProblemTypes.ConcurrencyConflict, problem?.Type);
var current = await client.GetFromJsonAsync<IngestionProposalDetail>($"/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<ProblemDetails>();
Assert.Equal(ProblemTypes.IdempotencyKeyConflict, problem?.Type);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
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<IngestionProposalDetail>();
var receivedTwoDetail = await receivedTwo.Content.ReadFromJsonAsync<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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>();
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<IngestionProposalListResponse>(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<IngestionProposalListResponse>("/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<IngestionProposalDetail>();
var newer = await newerResponse.Content.ReadFromJsonAsync<IngestionProposalDetail>();
var tied = await tiedResponse.Content.ReadFromJsonAsync<IngestionProposalDetail>();
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<KnowledgeHubDbContext>();
var createdAt = DateTimeOffset.UtcNow;
await db.IngestionProposalRevisions
.Where(revision => EF.Property<Guid>(revision, "IngestionProposalId") == older!.Id)
.ExecuteUpdateAsync(update => update.SetProperty(revision => revision.OccurredAt, createdAt.AddMinutes(-5)));
await db.IngestionProposalRevisions
.Where(revision => EF.Property<Guid>(revision, "IngestionProposalId") == newer!.Id)
.ExecuteUpdateAsync(update => update.SetProperty(revision => revision.OccurredAt, createdAt));
await db.IngestionProposalRevisions
.Where(revision => EF.Property<Guid>(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>();
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<IngestionProposalListResponse>(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<IngestionProposalListResponse>(
$"/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<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(session?.UserId);
var response = await client.GetFromJsonAsync<IngestionProposalListResponse>("/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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<KnowledgeHubDbContext>();
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<IngestionProposalDetail>();
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<KnowledgeHubDbContext>();
Assert.Empty(await db.IngestionProposalSources.Where(source => EF.Property<Guid>(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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<IngestionProposalDetail>();
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<KnowledgeHubDbContext>();
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<HttpResponseMessage> 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<CreatedServiceAccountToken> 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<CreatedServiceAccountToken>();
return Assert.IsType<CreatedServiceAccountToken>(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<string, string?>
{
["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<IngestionProposalDetail>();
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<HttpResponseMessage> 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);
}
}