using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Application.Attachments; using ItWorx.KnowledgeHub.Application.Audit; using ItWorx.KnowledgeHub.Application.Automation; using ItWorx.KnowledgeHub.Application.Ingestion; using ItWorx.KnowledgeHub.Application.Ingestion.UseCases; using ItWorx.KnowledgeHub.Application.Knowledge; using ItWorx.KnowledgeHub.Contracts.Automation; using ItWorx.KnowledgeHub.Contracts.Ingestion; using ItWorx.KnowledgeHub.Domain.Attachments; using ItWorx.KnowledgeHub.Domain.Automation; using ItWorx.KnowledgeHub.Domain.Ingestion; using ItWorx.KnowledgeHub.Domain.Knowledge; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion; /// Atomic-publication use-case tests using ports that model one shared /// DbContext. They are deliberately Docker-free; HTTP authorization stays covered by /// the PostgreSQL API suite. public sealed class IngestionPublicationTests { private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid ActorId = Guid.Parse("22222222-2222-2222-2222-222222222222"); [Fact] public async Task Create_publication_stages_page_revision_attachment_audit_and_outbox_in_one_save() { var fixture = Fixture.Create(); var result = await fixture.PublishAsync(); Assert.Equal(IngestionProposalResultStatus.Success, result.Status); var page = Assert.Single(fixture.Pages.Added); Assert.Equal("Trusted article", page.TitleValue); Assert.Equal(DocumentStatus.Active, page.DocumentStatus); Assert.Equal(1, page.CurrentRevisionNumber); Assert.Equal(page.Id, Assert.Single(fixture.Pages.Revisions).PageId); Assert.Equal(page.Id, fixture.Attachments.TransferredPageId); Assert.Equal("ingestion-proposal.published", Assert.Single(fixture.Audits.Events).Action); Assert.Equal("ingestion-proposal.published.v1", Assert.Single(fixture.Outbox.Messages).EventType); Assert.Equal(IngestionProposalStatus.Published, fixture.Proposal.Status); Assert.Equal(page.Id, fixture.Proposal.PublishedPageId); Assert.Equal(1, fixture.Proposals.SaveCalls); } [Fact] public async Task Stale_update_returns_proposal_to_review_without_page_attachment_audit_or_outbox_side_effects() { var fixture = Fixture.Update("newer-target-etag"); var result = await fixture.PublishAsync(); Assert.Equal(IngestionProposalResultStatus.StaleTarget, result.Status); Assert.Equal(IngestionProposalStatus.ReadyForReview, fixture.Proposal.Status); Assert.Empty(fixture.Pages.Added); Assert.Empty(fixture.Pages.Revisions); Assert.Null(fixture.Attachments.TransferredPageId); Assert.Empty(fixture.Audits.Events); Assert.Empty(fixture.Outbox.Messages); Assert.Equal(1, fixture.Proposals.SaveCalls); // ReadyForReview only. } [Fact] public async Task Replay_with_same_key_is_exactly_once_even_when_original_etag_is_retried() { var fixture = Fixture.Create(); var first = await fixture.PublishAsync(); var replay = await fixture.PublishAsync(); Assert.Equal(IngestionProposalResultStatus.Success, first.Status); Assert.Equal(IngestionProposalResultStatus.Success, replay.Status); Assert.Equal(first.PageId, replay.PageId); Assert.Single(fixture.Pages.Added); Assert.Single(fixture.Pages.Revisions); Assert.Single(fixture.Outbox.Messages); Assert.Equal(1, fixture.Proposals.SaveCalls); } [Fact] public async Task Shared_save_failure_rolls_back_every_staged_side_effect() { var fixture = Fixture.Create(failSave: true); await Assert.ThrowsAsync(() => fixture.PublishAsync()); Assert.Empty(fixture.Pages.Added); Assert.Empty(fixture.Pages.Revisions); Assert.Null(fixture.Attachments.TransferredPageId); Assert.Empty(fixture.Audits.Events); Assert.Empty(fixture.Outbox.Messages); } private sealed class Fixture { private const string ProposalETag = "proposal-etag"; private const string PublishKey = "publish-once"; private Fixture(IngestionProposal proposal, Proposals proposals, Pages pages, Attachments attachments, Audits audits, Outbox outbox) => (Proposal, Proposals, Pages, Attachments, Audits, Outbox) = (proposal, proposals, pages, attachments, audits, outbox); public IngestionProposal Proposal { get; } public Proposals Proposals { get; } public Pages Pages { get; } public Attachments Attachments { get; } public Audits Audits { get; } public Outbox Outbox { get; } public static Fixture Create(bool failSave = false) => Build(Approved(IngestionMutationKind.CreatePage), new Pages(), failSave); public static Fixture Update(string targetEtag) { var target = Page.Create(WorkspaceId, null, SortKey.First(), PageTitle.Create("Existing"), DocumentType.WikiPage, "original", ActorId, DateTimeOffset.UtcNow); return Build(Approved(IngestionMutationKind.UpdatePage, target.Id), new Pages(target, targetEtag)); } public Task PublishAsync() => new PublishIngestionProposalUseCase( Proposals, Pages, new LinkResolutionService(Pages), Attachments, Audits, Outbox, TimeProvider.System).ExecuteAsync( new(WorkspaceId, Proposal.Id, ProposalETag, PublishKey, ActorId, "publication-test"), CancellationToken.None); private static Fixture Build(IngestionProposal proposal, Pages pages, bool failSave = false) { var attachments = new Attachments(); var audits = new Audits(); var outbox = new Outbox(); var proposals = new Proposals(proposal, failSave, () => { pages.RollBack(); attachments.RollBack(); audits.RollBack(); outbox.RollBack(); }); return new Fixture(proposal, proposals, pages, attachments, audits, outbox); } private static IngestionProposal Approved(IngestionMutationKind kind, Guid? targetPageId = null) { var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "publication", "safe manifest"); proposal.SetPublication(IngestionPublication.Create(kind, "Trusted article", "# Published body", "WikiPage", "Active", targetPageId: targetPageId, expectedTargetETag: kind == IngestionMutationKind.UpdatePage ? "target-etag" : null), ActorId, DateTimeOffset.UtcNow); proposal.AddSource("evidence.txt", "text/plain", 1, "c8c86d360bfb7bb0d036b7352bf89714946fb527c4074340752259109371f429", "clean", Guid.NewGuid()); proposal.BeginAnalysis(ActorId, DateTimeOffset.UtcNow); proposal.ApplyAnalysis("Reviewed analysis", ActorId, DateTimeOffset.UtcNow); proposal.Approve(ActorId, DateTimeOffset.UtcNow); return proposal; } } private sealed class Proposals(IngestionProposal proposal, bool failSave, Action rollback) : IIngestionProposalRepository { private string etag = "proposal-etag"; public int SaveCalls { get; private set; } public Task ListAsync(IngestionProposalQuery query, CancellationToken cancellationToken) => Task.FromResult(new IngestionProposalPage([], null)); public Task FindAsync(Guid workspaceId, Guid proposalId, CancellationToken cancellationToken) => Task.FromResult(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id ? proposal : null); public Task FindByIdempotencyKeyAsync(Guid workspaceId, string idempotencyKey, CancellationToken cancellationToken) => Task.FromResult(null); public Task GetETagAsync(Guid workspaceId, Guid proposalId, CancellationToken cancellationToken) => Task.FromResult(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id ? etag : null); public Task FindForUpdateAsync(Guid workspaceId, Guid proposalId, string expectedETag, CancellationToken cancellationToken) => Task.FromResult(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id && expectedETag == etag ? proposal : null); public void Add(IngestionProposal item) => throw new NotSupportedException(); public Task SaveChangesAsync(CancellationToken cancellationToken) { SaveCalls++; if (failSave) { rollback(); return Task.FromException(new InvalidOperationException("Forced shared DbContext save failure.")); } etag = "proposal-published-etag"; return Task.FromResult(1); } } private sealed class Pages(Page? target = null, string? targetEtag = null) : IPageRepository { public List Added { get; } = []; public List Revisions { get; } = []; public Task FindAsync(Guid pageId, CancellationToken cancellationToken) => Task.FromResult(target?.Id == pageId ? target : null); public Task GetETagAsync(Guid pageId, CancellationToken cancellationToken) => Task.FromResult(target?.Id == pageId ? targetEtag : null); public Task FindForUpdateAsync(Guid pageId, string expectedETag, CancellationToken cancellationToken) => Task.FromResult(target?.Id == pageId && targetEtag == expectedETag ? target : null); public Task GetChildrenAsync(Guid workspaceId, Guid? parentPageId, bool includeTrashed, string? cursor, int limit, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetPublicationManifestAsync(Guid workspaceId, string? spaceTag, string? cursor, int limit, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasChildrenAsync(Guid pageId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetBreadcrumbAsync(Guid pageId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> FindAllByNormalizedTitleAsync(Guid workspaceId, string normalizedTitle, CancellationToken cancellationToken) => Task.FromResult>([]); public Task IsSameOrDescendantAsync(Guid pageId, Guid candidateId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetSiblingsAsync(Guid workspaceId, Guid? parentPageId, bool includeTrashed, CancellationToken cancellationToken) => Task.FromResult>([]); public Task> GetLinksForPageAsync(Guid sourcePageId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetBacklinkSourcesAsync(Guid targetPageId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetRevisionsAsync(Guid pageId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetRevisionAsync(Guid pageId, long revisionNumber, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task PurgeEligibleTrashedPagesAsync(Guid workspaceId, DateTimeOffset now, CancellationToken cancellationToken) => throw new NotSupportedException(); public void Add(Page page) => Added.Add(page); public void ReplaceLinks(Guid sourcePageId, IReadOnlyList links) { } public void AddRevision(PageRevision revision) => Revisions.Add(revision); public Task SaveChangesAsync(CancellationToken cancellationToken) => Task.CompletedTask; public void RollBack() { Added.Clear(); Revisions.Clear(); } } private sealed class Attachments : IAttachmentRepository { public Guid? TransferredPageId { get; private set; } public Task FindBlobAsync(string sha256, CancellationToken cancellationToken) => throw new NotSupportedException(); public void AddBlob(AttachmentBlob blob) => throw new NotSupportedException(); public Task FindReferenceAsync(Guid referenceId, CancellationToken cancellationToken) => throw new NotSupportedException(); public void AddReference(AttachmentReference reference) => throw new NotSupportedException(); public void AddUsage(AttachmentUsage usage) => throw new NotSupportedException(); public Task> GetReferencesForPageAsync(Guid pageId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> FindBlobsAsync(IReadOnlyCollection hashes, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task FindUsageForReferenceAsync(Guid attachmentReferenceId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> FindUnreferencedBlobHashesAsync(IReadOnlyCollection hashes, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task RemoveBlobRecordsAsync(IReadOnlyCollection hashes, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetUsagesForStagingSessionAsync(Guid stagingSessionId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task TransferProposalUsagesToPageAsync(Guid ingestionProposalId, Guid pageId, CancellationToken cancellationToken) { TransferredPageId = pageId; return Task.CompletedTask; } public Task RemoveOrphanedStagingAttachmentsAsync(Guid stagingSessionId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task SaveChangesAsync(CancellationToken cancellationToken) => Task.CompletedTask; public void RollBack() => TransferredPageId = null; } private sealed class Audits : IAuditEventRecorder { public List Events { get; } = []; public void Add(AuditEventRecord auditEvent) => Events.Add(auditEvent); public Task RecordAsync(AuditEventRecord auditEvent, CancellationToken cancellationToken) { Events.Add(auditEvent); return Task.CompletedTask; } public void RollBack() => Events.Clear(); } private sealed class Outbox : IOutboxRepository { public List Messages { get; } = []; public void Add(OutboxMessage message) => Messages.Add(message); public Task> GetUnprocessedAsync(int limit, CancellationToken cancellationToken) => Task.FromResult>([]); public void RollBack() => Messages.Clear(); } } /// Route-level guard for the non-delegable human-review boundary. This is /// fixture-backed because it intentionally exercises the real cookie and bearer auth /// handlers. [Collection(PostgresTestGroup.Name)] public sealed class IngestionPublicationApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase( postgres, new Dictionary { ["IngestionProposal:Enabled"] = "true" }) { [Fact] public async Task Publish_rejects_a_service_account_and_accepts_the_human_reviewer() { using var owner = Factory.CreateClient(); await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword); var serviceToken = await CreateServiceAccountAsync(owner, "publication-analyzer"); using var service = Factory.CreateClient(); service.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", serviceToken.RawToken); using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest("safe manifest", "publication-api", new IngestionPublicationRequest("Published page", "# Body", "WikiPage", "Active", "CreatePage"))) }; create.Headers.Add("Idempotency-Key", "publication-api-create"); var created = await service.SendAsync(create); created.EnsureSuccessStatusCode(); var proposal = Assert.IsType(await created.Content.ReadFromJsonAsync()); Assert.Equal("CreatePage", proposal.MutationKind); Assert.Null(proposal.Destination?.PageId); Assert.Equal("Published page", proposal.Destination?.Title); Assert.Equal("WikiPage", proposal.Metadata?.DocumentType); Assert.Equal("Active", proposal.Metadata?.DocumentStatus); Assert.Null(proposal.ExpectedTargetETag); Assert.Empty(proposal.Attachments); Assert.Empty(proposal.Citations); Assert.Empty(proposal.Warnings); Assert.Empty(proposal.DuplicateCandidates); Assert.Empty(proposal.ConflictCandidates); Assert.NotNull(proposal.Diff); Assert.Null(proposal.Diff.Before); Assert.Equal("# Body", proposal.Diff.After); using var analyze = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}") { Content = JsonContent.Create(new PatchIngestionProposalRequest(null, null, "Safe review analysis.")) }; analyze.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag); var analyzed = await service.SendAsync(analyze); analyzed.EnsureSuccessStatusCode(); using var forbiddenPublish = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal.Id}/publish"); forbiddenPublish.Headers.TryAddWithoutValidation("If-Match", analyzed.Headers.ETag!.Tag); forbiddenPublish.Headers.Add("Idempotency-Key", "service-cannot-publish"); Assert.Equal(HttpStatusCode.Forbidden, (await service.SendAsync(forbiddenPublish)).StatusCode); 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", analyzed.Headers.ETag!.Tag); var approved = await owner.SendAsync(approve); approved.EnsureSuccessStatusCode(); csrf = await SetupHelper.GetCsrfTokenAsync(owner); using var publish = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal.Id}/publish"); publish.Headers.Add(csrf.HeaderName, csrf.Value); publish.Headers.TryAddWithoutValidation("If-Match", approved.Headers.ETag!.Tag); publish.Headers.Add("Idempotency-Key", "human-publish-once"); var published = await owner.SendAsync(publish); Assert.Equal(HttpStatusCode.OK, published.StatusCode); var detail = await published.Content.ReadFromJsonAsync(); Assert.Equal("Published", detail?.Status); Assert.NotNull(detail?.PublishedPageId); } private static async Task CreateServiceAccountAsync(HttpClient owner, string name) { var response = await SetupHelper.PostWithCsrfAsync(owner, "/api/v1/service-accounts", new CreateServiceAccountRequest(name, "trusted-intake publication test", [Scopes.IngestionWrite])); response.EnsureSuccessStatusCode(); return Assert.IsType(await response.Content.ReadFromJsonAsync()); } }