Files
itworx-knowledge-hub-Public/tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPublicationTests.cs
T

305 lines
20 KiB
C#

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;
/// <summary>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.</summary>
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<InvalidOperationException>(() => 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<PublishIngestionProposalResult> 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<IngestionProposalPage> ListAsync(IngestionProposalQuery query, CancellationToken cancellationToken) => Task.FromResult(new IngestionProposalPage([], null));
public Task<IngestionProposal?> FindAsync(Guid workspaceId, Guid proposalId, CancellationToken cancellationToken) => Task.FromResult<IngestionProposal?>(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id ? proposal : null);
public Task<IngestionProposal?> FindByIdempotencyKeyAsync(Guid workspaceId, string idempotencyKey, CancellationToken cancellationToken) => Task.FromResult<IngestionProposal?>(null);
public Task<string?> GetETagAsync(Guid workspaceId, Guid proposalId, CancellationToken cancellationToken) => Task.FromResult<string?>(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id ? etag : null);
public Task<IngestionProposal?> FindForUpdateAsync(Guid workspaceId, Guid proposalId, string expectedETag, CancellationToken cancellationToken) => Task.FromResult<IngestionProposal?>(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id && expectedETag == etag ? proposal : null);
public void Add(IngestionProposal item) => throw new NotSupportedException();
public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
{
SaveCalls++;
if (failSave) { rollback(); return Task.FromException<int>(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<Page> Added { get; } = [];
public List<PageRevision> Revisions { get; } = [];
public Task<Page?> FindAsync(Guid pageId, CancellationToken cancellationToken) => Task.FromResult(target?.Id == pageId ? target : null);
public Task<string?> GetETagAsync(Guid pageId, CancellationToken cancellationToken) => Task.FromResult(target?.Id == pageId ? targetEtag : null);
public Task<Page?> FindForUpdateAsync(Guid pageId, string expectedETag, CancellationToken cancellationToken) => Task.FromResult(target?.Id == pageId && targetEtag == expectedETag ? target : null);
public Task<PageChildrenPage> GetChildrenAsync(Guid workspaceId, Guid? parentPageId, bool includeTrashed, string? cursor, int limit, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<RagPublicationManifestPage> GetPublicationManifestAsync(Guid workspaceId, string? spaceTag, string? cursor, int limit, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasChildrenAsync(Guid pageId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<Page>> GetBreadcrumbAsync(Guid pageId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<Page>> FindAllByNormalizedTitleAsync(Guid workspaceId, string normalizedTitle, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<Page>>([]);
public Task<bool> IsSameOrDescendantAsync(Guid pageId, Guid candidateId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<Page>> GetSiblingsAsync(Guid workspaceId, Guid? parentPageId, bool includeTrashed, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<Page>>([]);
public Task<IReadOnlyList<PageLink>> GetLinksForPageAsync(Guid sourcePageId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<Page>> GetBacklinkSourcesAsync(Guid targetPageId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<PageRevision>> GetRevisionsAsync(Guid pageId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<PageRevision?> GetRevisionAsync(Guid pageId, long revisionNumber, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<int> PurgeEligibleTrashedPagesAsync(Guid workspaceId, DateTimeOffset now, CancellationToken cancellationToken) => throw new NotSupportedException();
public void Add(Page page) => Added.Add(page);
public void ReplaceLinks(Guid sourcePageId, IReadOnlyList<PageLink> 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<AttachmentBlob?> FindBlobAsync(string sha256, CancellationToken cancellationToken) => throw new NotSupportedException();
public void AddBlob(AttachmentBlob blob) => throw new NotSupportedException();
public Task<AttachmentReference?> 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<IReadOnlyList<AttachmentReference>> GetReferencesForPageAsync(Guid pageId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyDictionary<string, AttachmentBlob>> FindBlobsAsync(IReadOnlyCollection<string> hashes, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<AttachmentUsage?> FindUsageForReferenceAsync(Guid attachmentReferenceId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<string>> FindUnreferencedBlobHashesAsync(IReadOnlyCollection<string> hashes, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<int> RemoveBlobRecordsAsync(IReadOnlyCollection<string> hashes, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<AttachmentUsage>> GetUsagesForStagingSessionAsync(Guid stagingSessionId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task TransferProposalUsagesToPageAsync(Guid ingestionProposalId, Guid pageId, CancellationToken cancellationToken) { TransferredPageId = pageId; return Task.CompletedTask; }
public Task<StagingAttachmentCleanupResult> 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<AuditEventRecord> 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<OutboxMessage> Messages { get; } = [];
public void Add(OutboxMessage message) => Messages.Add(message);
public Task<IReadOnlyList<OutboxMessage>> GetUnprocessedAsync(int limit, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<OutboxMessage>>([]);
public void RollBack() => Messages.Clear();
}
}
/// <summary>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.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionPublicationApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(
postgres, new Dictionary<string, string?> { ["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<IngestionProposalDetail>(await created.Content.ReadFromJsonAsync<IngestionProposalDetail>());
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<IngestionProposalDetail>();
Assert.Equal("Published", detail?.Status);
Assert.NotNull(detail?.PublishedPageId);
}
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 publication test", [Scopes.IngestionWrite]));
response.EnsureSuccessStatusCode();
return Assert.IsType<CreatedServiceAccountToken>(await response.Content.ReadFromJsonAsync<CreatedServiceAccountToken>());
}
}