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

328 lines
15 KiB
C#

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<string, string?> { ["IngestionProposal:Enabled"] = "true" })
{
private readonly Dictionary<string, HttpClient> _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<IngestionProposalDetail>(await created.Content.ReadFromJsonAsync<IngestionProposalDetail>());
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<IngestionProposalDetail>(await analyze.Content.ReadFromJsonAsync<IngestionProposalDetail>());
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<IngestionProposalDetail>(await approvedResponse.Content.ReadFromJsonAsync<IngestionProposalDetail>());
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<IngestionProposalDetail>(await publishedResponse.Content.ReadFromJsonAsync<IngestionProposalDetail>());
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<IngestionProposalDetail>(await replay.Content.ReadFromJsonAsync<IngestionProposalDetail>());
Assert.Equal(published.PublishedPageId, replayed.PublishedPageId);
Assert.Equal(published.PublishedPageRevisionNumber, replayed.PublishedPageRevisionNumber);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
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<PageDetail>(await pageResponse.Content.ReadFromJsonAsync<PageDetail>());
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<PageDetail>(await editResponse.Content.ReadFromJsonAsync<PageDetail>());
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<IngestionProposalDetail>(await createProposal.Content.ReadFromJsonAsync<IngestionProposalDetail>());
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<IngestionProposalDetail>(
$"/api/v1/ingestion-proposals/{proposal.Id}");
Assert.Equal("ReadyForReview", proposalAfterConflict?.Status);
Assert.Equal(revisionsBeforePublish, await GetPageRevisionCountAsync(initialPage.Id));
var pageAfterConflict = await reviewer.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{initialPage.Id}");
Assert.Equal("Target changed after the proposal snapshot.", pageAfterConflict?.Markdown);
Assert.Equal(currentPage.ETag, pageAfterConflict?.ETag);
}
private async Task<HttpResponseMessage> 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<CreatedServiceAccountToken> 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<CreatedServiceAccountToken>(await response.Content.ReadFromJsonAsync<CreatedServiceAccountToken>());
}
private HttpClient BearerClient(string rawToken)
{
var client = Factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
return client;
}
private async Task<int> GetPageRevisionCountAsync(Guid pageId)
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
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<ProblemDetails>();
Assert.Equal(type, problem?.Type);
}
}