328 lines
15 KiB
C#
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);
|
|
}
|
|
}
|