using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Automation; using ItWorx.KnowledgeHub.Contracts.Errors; using ItWorx.KnowledgeHub.Contracts.Ingestion; using ItWorx.KnowledgeHub.Contracts.Knowledge; 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; [Collection(PostgresTestGroup.Name)] public sealed class IngestionPilotContractTests(PostgresContainerFixture postgres) : OwnerSetupTestBase( postgres, new Dictionary { ["IngestionProposal:Enabled"] = "true" }) { private readonly Dictionary _matrixActors = new(StringComparer.Ordinal); [Fact] public async Task Proposal_creation_requires_an_idempotency_key() { _matrixActors.Clear(); using var reviewer = Factory.CreateClient(); await SetupHelper.LoginAsync(reviewer, OwnerEmail, OwnerPassword); _matrixActors.Add("reviewer", reviewer); var response = await SendMatrixRequestAsync( "reviewer", "POST", "/api/v1/ingestion-proposals", new CreateIngestionProposalRequest("Synthetic request without a key", "pilot-no-key")); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task Synthetic_actor_matrix_enforces_review_and_publication_replay_is_exactly_once() { _matrixActors.Clear(); using var reviewer = Factory.CreateClient(); await SetupHelper.LoginAsync(reviewer, OwnerEmail, OwnerPassword); _matrixActors.Add("reviewer", reviewer); var writerToken = await CreateServiceAccountAsync(reviewer, "pilot-ingestion-writer", Scopes.IngestionWrite); using var writer = BearerClient(writerToken.RawToken); _matrixActors.Add(Scopes.IngestionWrite, writer); var readOnlyToken = await CreateServiceAccountAsync(reviewer, "pilot-read-only", Scopes.PagesRead); using var readOnly = BearerClient(readOnlyToken.RawToken); _matrixActors.Add(Scopes.PagesRead, readOnly); const string proposalPath = "/api/v1/ingestion-proposals"; const string createKey = "pilot-contract-create-1"; var publication = new IngestionPublicationRequest( "Synthetic pilot publication", "# Synthetic body", "WikiPage", "Active", "CreatePage"); var created = await SendMatrixRequestAsync( Scopes.IngestionWrite, "POST", proposalPath, new CreateIngestionProposalRequest("Synthetic contract manifest", "pilot-contract-create", publication), idempotencyKey: createKey); Assert.Equal(HttpStatusCode.Created, created.StatusCode); Assert.NotNull(created.Headers.ETag); var proposal = Assert.IsType(await created.Content.ReadFromJsonAsync()); var sameKeyDifferentBody = await SendMatrixRequestAsync( Scopes.IngestionWrite, "POST", proposalPath, new CreateIngestionProposalRequest("Different synthetic manifest", "pilot-contract-create", publication), idempotencyKey: createKey); await AssertTypedProblemAsync(sameKeyDifferentBody, HttpStatusCode.Conflict, ProblemTypes.IdempotencyKeyConflict); var missingIngestionScope = await SendMatrixRequestAsync( Scopes.PagesRead, "POST", proposalPath, new CreateIngestionProposalRequest("Must be denied", "pilot-contract-denied"), idempotencyKey: "pilot-contract-denied-1"); Assert.Equal(HttpStatusCode.Forbidden, missingIngestionScope.StatusCode); var writerCannotReadReviewQueue = await SendMatrixRequestAsync( Scopes.IngestionWrite, "GET", proposalPath); Assert.Equal(HttpStatusCode.Forbidden, writerCannotReadReviewQueue.StatusCode); var analyze = await SendMatrixRequestAsync( Scopes.IngestionWrite, "PATCH", $"{proposalPath}/{proposal.Id}", new PatchIngestionProposalRequest(null, null, "Synthetic analysis is ready for review."), created.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.OK, analyze.StatusCode); var ready = Assert.IsType(await analyze.Content.ReadFromJsonAsync()); Assert.Equal("ReadyForReview", ready.Status); var staleProposalEdit = await SendMatrixRequestAsync( "reviewer", "PATCH", $"{proposalPath}/{proposal.Id}", new PatchIngestionProposalRequest("Stale edit must not replace the latest proposal.", null), created.Headers.ETag!.Tag); await AssertTypedProblemAsync(staleProposalEdit, HttpStatusCode.PreconditionFailed, IngestionProblemTypes.ConcurrencyConflict); var writerCannotApprove = await SendMatrixRequestAsync( Scopes.IngestionWrite, "POST", $"{proposalPath}/{proposal.Id}/approve", new IngestionProposalDecisionRequest(null), analyze.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.Forbidden, writerCannotApprove.StatusCode); var reviewerCannotPublishBeforeApproval = await SendMatrixRequestAsync( "reviewer", "POST", $"{proposalPath}/{proposal.Id}/publish", ifMatch: analyze.Headers.ETag!.Tag, idempotencyKey: "pilot-contract-unapproved-publish"); await AssertTypedProblemAsync( reviewerCannotPublishBeforeApproval, HttpStatusCode.Conflict, IngestionProblemTypes.InvalidTransition); var approvedResponse = await SendMatrixRequestAsync( "reviewer", "POST", $"{proposalPath}/{proposal.Id}/approve", new IngestionProposalDecisionRequest(null), analyze.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.OK, approvedResponse.StatusCode); var approved = Assert.IsType(await approvedResponse.Content.ReadFromJsonAsync()); Assert.Equal("Approved", approved.Status); const string publishKey = "pilot-contract-publish-1"; var writerCannotPublish = await SendMatrixRequestAsync( Scopes.IngestionWrite, "POST", $"{proposalPath}/{proposal.Id}/publish", ifMatch: approvedResponse.Headers.ETag!.Tag, idempotencyKey: publishKey); Assert.Equal(HttpStatusCode.Forbidden, writerCannotPublish.StatusCode); var publishedResponse = await SendMatrixRequestAsync( "reviewer", "POST", $"{proposalPath}/{proposal.Id}/publish", ifMatch: approvedResponse.Headers.ETag!.Tag, idempotencyKey: publishKey); Assert.Equal(HttpStatusCode.OK, publishedResponse.StatusCode); var published = Assert.IsType(await publishedResponse.Content.ReadFromJsonAsync()); Assert.Equal("Published", published.Status); Assert.NotNull(published.PublishedPageId); Assert.Equal(1, published.PublishedPageRevisionNumber); var replay = await SendMatrixRequestAsync( "reviewer", "POST", $"{proposalPath}/{proposal.Id}/publish", ifMatch: approvedResponse.Headers.ETag!.Tag, idempotencyKey: publishKey); Assert.Equal(HttpStatusCode.OK, replay.StatusCode); var replayed = Assert.IsType(await replay.Content.ReadFromJsonAsync()); Assert.Equal(published.PublishedPageId, replayed.PublishedPageId); Assert.Equal(published.PublishedPageRevisionNumber, replayed.PublishedPageRevisionNumber); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(1, await db.Pages.CountAsync(page => page.Id == published.PublishedPageId)); Assert.Equal(1, await db.PageRevisions.CountAsync(revision => revision.PageId == published.PublishedPageId)); } [Fact] public async Task Stale_target_etag_returns_a_typed_problem_and_does_not_publish_a_revision() { _matrixActors.Clear(); using var reviewer = Factory.CreateClient(); await SetupHelper.LoginAsync(reviewer, OwnerEmail, OwnerPassword); _matrixActors.Add("reviewer", reviewer); var writerToken = await CreateServiceAccountAsync(reviewer, "pilot-stale-target-writer", Scopes.IngestionWrite); using var writer = BearerClient(writerToken.RawToken); _matrixActors.Add(Scopes.IngestionWrite, writer); var pageResponse = await SetupHelper.PostWithCsrfAsync( reviewer, "/api/v1/pages", new CreatePageRequest(null, "Synthetic stale target", "WikiPage", "Initial page body.", null)); Assert.Equal(HttpStatusCode.OK, pageResponse.StatusCode); var initialPage = Assert.IsType(await pageResponse.Content.ReadFromJsonAsync()); var editResponse = await SetupHelper.PutWithCsrfAsync( reviewer, $"/api/v1/pages/{initialPage.Id}", new UpdatePageRequest( initialPage.Title, "Target changed after the proposal snapshot.", initialPage.DocumentType, initialPage.DocumentStatus, "Synthetic target ETag advance"), initialPage.ETag); Assert.Equal(HttpStatusCode.OK, editResponse.StatusCode); var currentPage = Assert.IsType(await editResponse.Content.ReadFromJsonAsync()); Assert.NotEqual(initialPage.ETag, currentPage.ETag); var revisionsBeforePublish = await GetPageRevisionCountAsync(initialPage.Id); var createProposal = await SendMatrixRequestAsync( Scopes.IngestionWrite, "POST", "/api/v1/ingestion-proposals", new CreateIngestionProposalRequest( "Synthetic update proposal", "pilot-stale-target", new IngestionPublicationRequest( initialPage.Title, "Proposed replacement body.", initialPage.DocumentType, initialPage.DocumentStatus, "UpdatePage", TargetPageId: initialPage.Id, ExpectedTargetETag: initialPage.ETag)), idempotencyKey: "pilot-stale-target-create"); Assert.Equal(HttpStatusCode.Created, createProposal.StatusCode); var proposal = Assert.IsType(await createProposal.Content.ReadFromJsonAsync()); var analyzed = await SendMatrixRequestAsync( Scopes.IngestionWrite, "PATCH", $"/api/v1/ingestion-proposals/{proposal.Id}", new PatchIngestionProposalRequest(null, null, "Synthetic update analysis is ready."), createProposal.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.OK, analyzed.StatusCode); var approved = await SendMatrixRequestAsync( "reviewer", "POST", $"/api/v1/ingestion-proposals/{proposal.Id}/approve", new IngestionProposalDecisionRequest(null), analyzed.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.OK, approved.StatusCode); var stalePublish = await SendMatrixRequestAsync( "reviewer", "POST", $"/api/v1/ingestion-proposals/{proposal.Id}/publish", ifMatch: approved.Headers.ETag!.Tag, idempotencyKey: "pilot-stale-target-publish"); await AssertTypedProblemAsync(stalePublish, HttpStatusCode.Conflict, IngestionProblemTypes.StaleTarget); var proposalAfterConflict = await reviewer.GetFromJsonAsync( $"/api/v1/ingestion-proposals/{proposal.Id}"); Assert.Equal("ReadyForReview", proposalAfterConflict?.Status); Assert.Equal(revisionsBeforePublish, await GetPageRevisionCountAsync(initialPage.Id)); var pageAfterConflict = await reviewer.GetFromJsonAsync($"/api/v1/pages/{initialPage.Id}"); Assert.Equal("Target changed after the proposal snapshot.", pageAfterConflict?.Markdown); Assert.Equal(currentPage.ETag, pageAfterConflict?.ETag); } private async Task SendMatrixRequestAsync( string actor, string method, string path, object? body = null, string? ifMatch = null, string? idempotencyKey = null) { var client = _matrixActors[actor]; using var request = new HttpRequestMessage(new HttpMethod(method), path); if (body is not null) { request.Content = JsonContent.Create(body); } if (ifMatch is not null) { request.Headers.TryAddWithoutValidation("If-Match", ifMatch); } if (idempotencyKey is not null) { request.Headers.Add("Idempotency-Key", idempotencyKey); } if (actor == "reviewer" && request.Method != HttpMethod.Get) { var csrf = await SetupHelper.GetCsrfTokenAsync(client); request.Headers.Add(csrf.HeaderName, csrf.Value); } return await client.SendAsync(request); } private static async Task CreateServiceAccountAsync(HttpClient reviewer, string name, params string[] scopes) { var response = await SetupHelper.PostWithCsrfAsync( reviewer, "/api/v1/service-accounts", new CreateServiceAccountRequest(name, "Synthetic pilot contract actor", scopes)); response.EnsureSuccessStatusCode(); return Assert.IsType(await response.Content.ReadFromJsonAsync()); } private HttpClient BearerClient(string rawToken) { var client = Factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); return client; } private async Task GetPageRevisionCountAsync(Guid pageId) { using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await db.PageRevisions.CountAsync(revision => revision.PageId == pageId); } private static async Task AssertTypedProblemAsync(HttpResponseMessage response, HttpStatusCode statusCode, string type) { Assert.Equal(statusCode, response.StatusCode); var problem = await response.Content.ReadFromJsonAsync(); Assert.Equal(type, problem?.Type); } }