Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../../'))" />
<PropertyGroup>
<!--
Test projects intentionally use xUnit's idiomatic underscore-separated method
naming (e.g. `Method_condition_expectedResult`) instead of PascalCase. CA1707
targets production API naming and is not relevant to test method names.
CA1861/CA1859 are micro-performance suggestions that do not apply to test code.
-->
<NoWarn>$(NoWarn);CA1707;CA1861;CA1859</NoWarn>
</PropertyGroup>
</Project>
@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
namespace ItWorx.KnowledgeHub.Api.Tests;
/// <summary>
/// Proves the reverse-proxy trust boundary documented in
/// docs/23-deployment-unraid.md "Reverse proxy" and EVIDENCE.md's "Production
/// configuration" item: forwarded headers (X-Forwarded-Proto) are only honored when
/// they arrive from an explicitly configured KnowledgeHub:TrustedProxies entry, never
/// by default and never from an untrusted source. The CSRF cookie's
/// CookieSecurePolicy.SameAsRequest makes the resulting "Secure" attribute a direct,
/// observable proxy for whether ASP.NET Core actually treated the request as HTTPS.
/// </summary>
public sealed class ForwardedHeadersTrustTests
{
private const string SimulatedRemoteIp = "203.0.113.7";
// WebApplicationFactory.CreateClient() defaults its base address to https://localhost,
// which alone already makes TestServer report Request.IsHttps == true — independent of
// any forwarded header. Every test below forces an http:// base address instead, so the
// "Secure" cookie attribute genuinely reflects whether the ForwardedHeaders middleware
// honored (or correctly ignored) X-Forwarded-Proto, not this client default.
private static readonly WebApplicationFactoryClientOptions HttpClientOptions = new()
{
BaseAddress = new Uri("http://localhost"),
};
[Fact]
public async Task An_untrusted_X_Forwarded_Proto_header_is_ignored_by_default()
{
using var factory = new KnowledgeHubApiFactory();
using var client = factory.CreateClient(HttpClientOptions);
var csrfCookie = await GetCsrfCookieAsync(client, SimulatedRemoteIp);
Assert.DoesNotContain("Secure", csrfCookie, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task An_X_Forwarded_Proto_header_from_a_configured_trusted_proxy_is_honored()
{
using var factory = new KnowledgeHubApiFactory().WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["KnowledgeHub:TrustedProxies"] = $"{SimulatedRemoteIp};127.0.0.1",
});
});
});
using var client = factory.CreateClient(HttpClientOptions);
var csrfCookie = await GetCsrfCookieAsync(client, SimulatedRemoteIp);
Assert.Contains("Secure", csrfCookie, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task A_trusted_proxy_list_that_does_not_include_the_caller_still_ignores_the_header()
{
using var factory = new KnowledgeHubApiFactory().WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
// A real trusted proxy is configured, but it is not the address this
// request actually arrives from — the header must still be ignored.
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["KnowledgeHub:TrustedProxies"] = "198.51.100.9",
});
});
});
using var client = factory.CreateClient(HttpClientOptions);
var csrfCookie = await GetCsrfCookieAsync(client, SimulatedRemoteIp);
Assert.DoesNotContain("Secure", csrfCookie, StringComparison.OrdinalIgnoreCase);
}
private static async Task<string> GetCsrfCookieAsync(HttpClient client, string simulatedRemoteIp)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/auth/csrf");
// TestServer never simulates a real socket, so Connection.RemoteIpAddress is null
// unless something sets it. Program.cs sets it from this header in the Test
// environment only (never reachable in Production), giving the KnownProxies
// trust check a deterministic "direct caller" address — the same role a real
// kernel-level TCP accept plays in production.
request.Headers.Add("X-Test-Remote-Ip", simulatedRemoteIp);
request.Headers.Add("X-Forwarded-Proto", "https");
request.Headers.Add("X-Forwarded-For", "198.51.100.50");
using var response = await client.SendAsync(request);
var csrfCookie = response.Headers.TryGetValues("Set-Cookie", out var values)
? values.FirstOrDefault(v => v.StartsWith("ikh_csrf=", StringComparison.Ordinal))
: null;
Assert.NotNull(csrfCookie);
return csrfCookie;
}
}
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,45 @@
using System.Net;
using System.Net.Http.Json;
namespace ItWorx.KnowledgeHub.Api.Tests;
public sealed class HealthEndpointTests : IClassFixture<KnowledgeHubApiFactory>
{
private readonly HttpClient _client;
public HealthEndpointTests(KnowledgeHubApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Liveness_returns_healthy_product_response()
{
using var response = await _client.GetAsync("/health/live");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<LivenessResponse>();
Assert.NotNull(payload);
Assert.Equal("healthy", payload.Status);
Assert.Equal("ItWorx Knowledge Hub", payload.Product);
}
[Fact]
public async Task System_info_requires_administrator_authentication()
{
// docs/19-observability.md: "/api/v1/system/info" is "Permissioned
// information", distinct from the two anonymous-by-design health endpoints
// above (docs/23-deployment-unraid.md "Operational checks" lists
// "authenticated system info" separately from /health/live and
// /health/ready). An anonymous caller must never see product/schema/version
// detail. Full authenticated-payload coverage (schemaVersion etc.) lives in
// the real-PostgreSQL integration tests, since this lightweight factory has
// no working login flow.
using var response = await _client.GetAsync("/api/v1/system/info");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
private sealed record LivenessResponse(string Status, string Product);
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
namespace ItWorx.KnowledgeHub.Api.Tests;
public sealed class KnowledgeHubApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseContentRoot(
Path.Combine(
FindRepositoryRoot().FullName,
"src",
"backend",
"ItWorx.KnowledgeHub.Api"));
builder.UseEnvironment("Test");
}
private static DirectoryInfo FindRepositoryRoot()
{
DirectoryInfo? current = new(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "global.json"))
&& (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|| File.Exists(Path.Combine(current.FullName, "PUBLIC_SOURCE_EXPORT.md"))))
{
return current;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate the repository root.");
}
}
@@ -0,0 +1,90 @@
using ItWorx.KnowledgeHub.Api.Operations;
using ItWorx.KnowledgeHub.Application.Operations;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.Api.Tests;
/// <summary>
/// <see cref="RestoreModeRunner"/> is deliberately pure/testable without booting a real
/// web host or database — no <c>Environment.Exit</c> call inside the tested unit
/// itself (only Program.cs's thin top-level wrapper performs the real process exit),
/// so a fake <see cref="IRestoreService"/> is enough to prove the CLI contract.
/// </summary>
public sealed class RestoreModeRunnerTests
{
[Fact]
public void IsRestoreMode_recognizes_the_flag_alone()
{
Assert.True(RestoreModeRunner.IsRestoreMode(["--restore"]));
}
[Fact]
public void IsRestoreMode_recognizes_the_flag_alongside_other_arguments()
{
Assert.True(RestoreModeRunner.IsRestoreMode(["--restore", "--backup-id=x"]));
}
[Fact]
public void IsRestoreMode_is_false_for_ordinary_startup_args()
{
Assert.False(RestoreModeRunner.IsRestoreMode([]));
}
[Fact]
public async Task Missing_required_arguments_returns_usage_exit_code_without_touching_the_restore_service()
{
var services = new ServiceCollection()
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: true))
.BuildServiceProvider();
using var output = new StringWriter();
var exitCode = await RestoreModeRunner.RunAsync(["--restore"], services, output, CancellationToken.None);
Assert.Equal(2, exitCode);
}
[Fact]
public async Task A_successful_restore_returns_exit_code_zero()
{
var services = new ServiceCollection()
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: true))
.BuildServiceProvider();
using var output = new StringWriter();
var exitCode = await RestoreModeRunner.RunAsync(
["--restore", "--backup-id=abc", "--target-connection=conn", "--target-attachments=/tmp/x"],
services, output, CancellationToken.None);
Assert.Equal(0, exitCode);
Assert.Contains("GESLAAGD", output.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task A_failed_restore_returns_a_non_zero_exit_code_and_reports_the_mismatch()
{
var services = new ServiceCollection()
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: false))
.BuildServiceProvider();
using var output = new StringWriter();
var exitCode = await RestoreModeRunner.RunAsync(
["--restore", "--backup-id=abc", "--target-connection=conn", "--target-attachments=/tmp/x"],
services, output, CancellationToken.None);
Assert.Equal(1, exitCode);
Assert.Contains("MISLUKT", output.ToString(), StringComparison.Ordinal);
Assert.Contains("AFWIJKING", output.ToString(), StringComparison.Ordinal);
}
private sealed class FakeRestoreService(bool shouldSucceed) : IRestoreService
{
public Task<RestoreReport> RestoreAsync(string backupId, RestoreTarget target, CancellationToken cancellationToken)
{
var tables = shouldSucceed
? new List<RestoreTableReport> { new("pages", 3, 3) }
: new List<RestoreTableReport> { new("pages", 2, 3) };
return Task.FromResult(new RestoreReport(backupId, shouldSucceed, tables, shouldSucceed ? 1 : 0, 1, []));
}
}
}
@@ -0,0 +1,49 @@
namespace ItWorx.KnowledgeHub.Api.Tests;
public sealed class SecurityHeaderTests : IClassFixture<KnowledgeHubApiFactory>
{
private readonly HttpClient _client;
public SecurityHeaderTests(KnowledgeHubApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Responses_carry_the_documented_baseline_security_headers()
{
using var response = await _client.GetAsync("/health/live");
Assert.Equal("nosniff", GetHeader(response, "X-Content-Type-Options"));
Assert.Equal("DENY", GetHeader(response, "X-Frame-Options"));
Assert.Equal("same-origin", GetHeader(response, "Referrer-Policy"));
Assert.Contains("frame-ancestors 'none'", GetHeader(response, "Content-Security-Policy"));
Assert.Contains("object-src 'none'", GetHeader(response, "Content-Security-Policy"));
Assert.DoesNotContain("script-src 'self' 'unsafe-inline'", GetHeader(response, "Content-Security-Policy"));
Assert.NotNull(GetHeader(response, "Permissions-Policy"));
}
[Fact]
public async Task Every_response_carries_a_correlation_id_header()
{
using var response = await _client.GetAsync("/health/live");
var correlationId = GetHeader(response, "X-Correlation-ID");
Assert.False(string.IsNullOrWhiteSpace(correlationId));
Assert.True(Guid.TryParse(correlationId, out _));
}
[Fact]
public async Task A_supplied_correlation_id_is_echoed_back_unchanged()
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/health/live");
request.Headers.Add("X-Correlation-ID", "caller-supplied-id-123");
using var response = await _client.SendAsync(request);
Assert.Equal("caller-supplied-id-123", GetHeader(response, "X-Correlation-ID"));
}
private static string? GetHeader(HttpResponseMessage response, string name) =>
response.Headers.TryGetValues(name, out var values) ? string.Join(", ", values) : null;
}
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,72 @@
using System.Xml.Linq;
namespace ItWorx.KnowledgeHub.Architecture.Tests;
public sealed class ProjectReferenceTests
{
[Fact]
public void Domain_and_contracts_have_no_project_dependencies()
{
Assert.Empty(ReadProjectReferences("src/backend/ItWorx.KnowledgeHub.Domain/ItWorx.KnowledgeHub.Domain.csproj"));
Assert.Empty(ReadProjectReferences("src/backend/ItWorx.KnowledgeHub.Contracts/ItWorx.KnowledgeHub.Contracts.csproj"));
}
[Fact]
public void Application_depends_only_on_domain_and_contracts()
{
var references = ReadProjectReferences(
"src/backend/ItWorx.KnowledgeHub.Application/ItWorx.KnowledgeHub.Application.csproj");
Assert.Equal(
new[]
{
"ItWorx.KnowledgeHub.Contracts",
"ItWorx.KnowledgeHub.Domain"
},
references.Order(StringComparer.Ordinal));
}
[Fact]
public void Infrastructure_does_not_depend_on_api()
{
var references = ReadProjectReferences(
"src/backend/ItWorx.KnowledgeHub.Infrastructure/ItWorx.KnowledgeHub.Infrastructure.csproj");
Assert.DoesNotContain("ItWorx.KnowledgeHub.Api", references);
}
private static IReadOnlyCollection<string> ReadProjectReferences(string relativeProjectPath)
{
var repositoryRoot = FindRepositoryRoot();
var projectPath = Path.Combine(
repositoryRoot.FullName,
relativeProjectPath.Replace('/', Path.DirectorySeparatorChar));
var document = XDocument.Load(projectPath);
return document
.Descendants("ProjectReference")
.Select(element => element.Attribute("Include")?.Value)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => Path.GetFileNameWithoutExtension(value!))
.ToArray();
}
private static DirectoryInfo FindRepositoryRoot()
{
DirectoryInfo? current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "global.json"))
&& (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|| File.Exists(Path.Combine(current.FullName, "PUBLIC_SOURCE_EXPORT.md"))))
{
return current;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate the repository root.");
}
}
@@ -0,0 +1,16 @@
using ItWorx.KnowledgeHub.Domain.Attachments;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class AttachmentUsageTests
{
[Fact]
public void Proposal_owned_usage_has_no_page_or_staging_owner()
{
var usage = AttachmentUsage.ForIngestionProposal(Guid.NewGuid(), Guid.NewGuid(), DateTimeOffset.UtcNow);
Assert.NotNull(usage.IngestionProposalId);
Assert.Null(usage.PageId);
Assert.Null(usage.StagingSessionId);
}
}
@@ -0,0 +1,102 @@
using ItWorx.KnowledgeHub.Domain.Decisions;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class DecisionRecordTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
private static readonly DateOnly Today = DateOnly.FromDateTime(Now.UtcDateTime);
private static DecisionRecord CreateProposed(Guid? supersedes = null) =>
DecisionRecord.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 1, Today, "Samenvatting", supersedes, Guid.NewGuid(), Now);
[Fact]
public void Create_starts_proposed()
{
var decision = CreateProposed();
Assert.Equal(DecisionStatus.Proposed, decision.Status);
Assert.Null(decision.SupersededByDecisionId);
}
[Fact]
public void Accept_transitions_from_proposed_to_accepted()
{
var decision = CreateProposed();
decision.Accept(Now);
Assert.Equal(DecisionStatus.Accepted, decision.Status);
}
[Fact]
public void Accept_a_non_proposed_decision_throws()
{
var decision = CreateProposed();
decision.Accept(Now);
var exception = Record.Exception(() => decision.Accept(Now));
Assert.IsType<InvalidOperationException>(exception);
}
[Fact]
public void Reject_transitions_from_proposed_to_rejected()
{
var decision = CreateProposed();
decision.Reject(Now);
Assert.Equal(DecisionStatus.Rejected, decision.Status);
}
[Theory]
[InlineData(DecisionStatus.Proposed)]
[InlineData(DecisionStatus.Accepted)]
public void Withdraw_is_allowed_from_proposed_or_accepted(DecisionStatus initial)
{
var decision = CreateProposed();
if (initial == DecisionStatus.Accepted)
{
decision.Accept(Now);
}
decision.Withdraw(Now);
Assert.Equal(DecisionStatus.Withdrawn, decision.Status);
}
[Fact]
public void Withdraw_a_rejected_decision_throws()
{
var decision = CreateProposed();
decision.Reject(Now);
var exception = Record.Exception(() => decision.Withdraw(Now));
Assert.IsType<InvalidOperationException>(exception);
}
[Fact]
public void MarkSupersededBy_requires_the_decision_to_currently_be_accepted()
{
var decision = CreateProposed();
var exception = Record.Exception(() => decision.MarkSupersededBy(Guid.NewGuid(), Now));
Assert.IsType<InvalidOperationException>(exception);
}
[Fact]
public void MarkSupersededBy_records_the_replacement_and_flips_status()
{
var decision = CreateProposed();
decision.Accept(Now);
var replacementId = Guid.NewGuid();
decision.MarkSupersededBy(replacementId, Now);
Assert.Equal(DecisionStatus.Superseded, decision.Status);
Assert.Equal(replacementId, decision.SupersededByDecisionId);
}
}
@@ -0,0 +1,55 @@
namespace ItWorx.KnowledgeHub.Domain.Tests;
using ItWorx.KnowledgeHub.Domain.Knowledge;
/// <summary>V2-R1 (audit finding M3): a draft must capture every field the editor
/// actually lets a user change, not just title/Markdown, so a metadata-only edit is
/// recoverable exactly like a content edit is.</summary>
public sealed class DraftTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 3, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Create_captures_tags_document_type_status_and_change_summary()
{
var draft = Draft.Create(
Guid.NewGuid(), Guid.NewGuid(), "draft-key", null, null,
"Titel", "Markdown", ["netwerk", "referentie"], "Runbook", "Active", "Kleine wijziging",
Now, TimeSpan.FromDays(14));
Assert.Equal(["netwerk", "referentie"], draft.Tags);
Assert.Equal("Runbook", draft.DocumentType);
Assert.Equal("Active", draft.DocumentStatus);
Assert.Equal("Kleine wijziging", draft.ChangeSummary);
}
[Fact]
public void Create_with_no_metadata_defaults_to_an_empty_tag_list_and_null_metadata_never_null_reference()
{
var draft = Draft.Create(
Guid.NewGuid(), Guid.NewGuid(), "draft-key", null, null,
"Titel", "Markdown", null, null, null, null, Now, TimeSpan.FromDays(14));
Assert.Empty(draft.Tags);
Assert.Null(draft.DocumentType);
Assert.Null(draft.DocumentStatus);
Assert.Null(draft.ChangeSummary);
}
[Fact]
public void Update_replaces_every_field_including_a_metadata_only_change()
{
var draft = Draft.Create(
Guid.NewGuid(), Guid.NewGuid(), "draft-key", null, null,
"Titel", "Markdown", ["oud"], "WikiPage", "Draft", null, Now, TimeSpan.FromDays(14));
draft.Update("Titel", "Markdown", ["nieuw"], "Runbook", "Active", "Bijgewerkt", Now.AddMinutes(1), TimeSpan.FromDays(14));
Assert.Equal(["nieuw"], draft.Tags);
Assert.Equal("Runbook", draft.DocumentType);
Assert.Equal("Active", draft.DocumentStatus);
Assert.Equal("Bijgewerkt", draft.ChangeSummary);
Assert.Equal(Now.AddMinutes(1), draft.UpdatedAt);
Assert.Equal(Now.AddMinutes(1).AddDays(14), draft.ExpiresAt);
}
}
@@ -0,0 +1,33 @@
using ItWorx.KnowledgeHub.Domain.Identity;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class EmailAddressTests
{
[Fact]
public void Create_normalizes_case_and_whitespace()
{
var email = EmailAddress.Create(" Owner@ItWorx.Example ");
Assert.Equal("owner@itworx.example", email.Value);
}
[Theory]
[InlineData("")]
[InlineData("not-an-email")]
[InlineData("missing-domain@")]
[InlineData("@missing-local.example")]
public void Create_rejects_invalid_shapes(string value)
{
Assert.Throws<ArgumentException>(() => EmailAddress.Create(value));
}
[Fact]
public void TryCreate_returns_false_instead_of_throwing()
{
var succeeded = EmailAddress.TryCreate("not-an-email", out var emailAddress);
Assert.False(succeeded);
Assert.Null(emailAddress);
}
}
@@ -0,0 +1,25 @@
using ItWorx.KnowledgeHub.Domain.Infrastructure;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class FileSystemPathBoundaryTests
{
[Fact]
public void Contains_accepts_the_root_and_descendants()
{
var root = Path.Combine(Path.GetTempPath(), "knowledge-imports");
Assert.True(FileSystemPathBoundary.Contains(root, root));
Assert.True(FileSystemPathBoundary.Contains(root, Path.Combine(root, "nested", "source")));
}
[Fact]
public void Contains_rejects_parent_and_prefix_sibling_paths()
{
var parent = Path.Combine(Path.GetTempPath(), $"knowledge-parent-{Guid.NewGuid():N}");
var root = Path.Combine(parent, "imports");
Assert.False(FileSystemPathBoundary.Contains(root, parent));
Assert.False(FileSystemPathBoundary.Contains(root, Path.Combine(parent, "imports-private")));
}
}
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,58 @@
using ItWorx.KnowledgeHub.Domain.Infrastructure;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class InfrastructureKnowledgeTests
{
[Fact]
public void Editorial_profile_normalizes_tags_without_touching_source_facts()
{
var now = DateTimeOffset.UtcNow;
var profile = EditorialProfile.Create(
Guid.NewGuid(), InfrastructureObjectKind.Service, Guid.NewGuid(), Guid.NewGuid(), now);
profile.Update(
" Knowledge Hub ", "Beschrijving", "Productiviteit", "Doel", null, null,
null, null, "Gedekt", null, "Notities", "Risico's", "Update", "Rollback",
["docker", "Docker", " kennis "], true, false, false, Guid.NewGuid(), now.AddMinutes(1));
Assert.Equal("Knowledge Hub", profile.DisplayName);
Assert.Equal(["docker", "kennis"], profile.Tags);
Assert.True(profile.IsFavorite);
}
[Fact]
public void A_missing_service_is_never_deleted_and_can_be_observed_again()
{
var now = DateTimeOffset.UtcNow;
var service = InfrastructureService.Create(
Guid.NewGuid(), "docker:knowledge-hub", "knowledge-hub", now, Guid.NewGuid());
service.Confirm();
service.MarkMissing(now.AddMinutes(1));
Assert.Equal(InventoryPresence.Missing, service.Presence);
service.Refresh(
null, "knowledge-hub", "container", "running", HealthState.Healthy,
"itworx/knowledge-hub:v2", "sha256:test", "10001:10001", true, true, false,
FactConfidence.High, Guid.NewGuid(), now.AddMinutes(2));
Assert.Equal(InventoryPresence.Present, service.Presence);
}
[Fact]
public void A_rejected_proposal_stays_archived_when_seen_in_a_later_snapshot()
{
var now = DateTimeOffset.UtcNow;
var service = InfrastructureService.Create(
Guid.NewGuid(), "docker:temporary", "temporary", now, Guid.NewGuid());
service.Reject();
service.Refresh(
null, "temporary", "container", "running", HealthState.Healthy,
"example/temporary:latest", "sha256:test", "1000:1000", false, true, false,
FactConfidence.High, Guid.NewGuid(), now.AddMinutes(1));
Assert.Equal(InventoryPresence.Archived, service.Presence);
}
}
@@ -0,0 +1,354 @@
using ItWorx.KnowledgeHub.Domain.Ingestion;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class IngestionProposalTests
{
[Fact]
public void Manual_submission_requires_original_source_publication_and_submitter()
{
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "manual", "manual upload");
Assert.Throws<DomainException>(() => proposal.SubmitManualReview(ActorId, Now));
proposal.SetPublication(IngestionPublication.Create(IngestionMutationKind.CreatePage,
"Document", "Inhoud", "Reference", "Draft"), ActorId, Now);
Assert.Throws<DomainException>(() => proposal.SubmitManualReview(ActorId, Now));
proposal.AddSource("document.pdf", "application/pdf", 10, new string('a', 64), "accepted", Guid.NewGuid());
Assert.Throws<DomainException>(() => proposal.SubmitManualReview(ReviewerId, Now));
proposal.SubmitManualReview(ActorId, Now);
Assert.Equal(IngestionProposalStatus.ReadyForReview, proposal.Status);
Assert.Null(proposal.Analysis);
Assert.Equal("manual-review-submitted", proposal.Revisions[^1].Operation);
Assert.Throws<DomainException>(() => proposal.SubmitManualReview(ActorId, Now));
Assert.Throws<DomainException>(() => proposal.AddSource("late.pdf", "application/pdf", 10, new string('b', 64), "accepted", Guid.NewGuid()));
}
[Fact]
public void Editor_can_update_a_received_manifest_and_preserves_append_only_history()
{
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "edit-correlation", "initial manifest");
proposal.Edit("changed manifest", "clarified", ActorId, DateTimeOffset.UtcNow);
Assert.Equal(2, proposal.RevisionNumber);
Assert.Equal("changed manifest", proposal.Manifest);
Assert.Equal("clarified", proposal.ReviewerComment);
Assert.Equal("edited", proposal.Revisions[^1].Operation);
}
private static readonly Guid WorkspaceId = Guid.NewGuid();
private static readonly Guid ActorId = Guid.NewGuid();
private static readonly Guid ReviewerId = Guid.NewGuid();
private static readonly DateTimeOffset Now = new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Approval_captures_current_revision_and_reject_is_terminal()
{
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "correlation", "manifest");
proposal.BeginAnalysis(ActorId, Now);
proposal.ApplyAnalysis("analysis", ActorId, Now);
proposal.Approve(ReviewerId, Now);
Assert.Equal(proposal.RevisionNumber, proposal.ApprovedRevisionNumber);
Assert.Throws<DomainException>(() => proposal.Reject(ReviewerId, "late", Now));
}
[Fact]
public void Failed_publishing_can_be_retried_only_up_to_the_configured_limit()
{
var proposal = ReadyAndApproved(maxPublishRetries: 1);
proposal.BeginPublishing(ReviewerId, Now);
proposal.MarkFailed(ReviewerId, IngestionProcessingStage.Publish, "publish-error", "Publication could not complete.", Now);
proposal.Retry(ReviewerId, Now);
Assert.Equal(IngestionProposalStatus.Approved, proposal.Status);
Assert.Equal(1, proposal.RetryCount);
proposal.BeginPublishing(ReviewerId, Now);
proposal.MarkFailed(ReviewerId, IngestionProcessingStage.Publish, "publish-error", "Publication could not complete.", Now);
Assert.Throws<DomainException>(() => proposal.Retry(ReviewerId, Now));
}
[Fact]
public void Reviewer_changes_after_approval_append_an_immutable_revision()
{
var proposal = ReadyAndApproved();
var approvedRevision = Assert.Single(proposal.Revisions, revision => revision.Operation == "approved");
proposal.RequestChanges(ReviewerId, "Please correct the classification.", Now.AddMinutes(1));
Assert.Equal(IngestionProposalStatus.ChangesRequested, proposal.Status);
Assert.Null(proposal.ApprovedRevisionNumber);
Assert.Equal(approvedRevision, proposal.Revisions.Single(revision => revision.Operation == "approved"));
Assert.Equal(proposal.RevisionNumber, proposal.Revisions.Count);
}
[Fact]
public void Editor_changes_after_approval_return_the_proposal_to_review_and_clear_approval()
{
var proposal = ReadyAndApproved();
var approvedRevision = proposal.ApprovedRevisionNumber;
proposal.Edit("corrected manifest", "Corrected after approval.", ReviewerId, Now.AddMinutes(1));
Assert.Equal(IngestionProposalStatus.ReadyForReview, proposal.Status);
Assert.Null(proposal.ApprovedRevisionNumber);
Assert.Null(proposal.ApprovedByUserId);
Assert.Equal("corrected manifest", proposal.Manifest);
Assert.Equal("edited", proposal.Revisions[^1].Operation);
Assert.Equal(approvedRevision + 1, proposal.RevisionNumber);
}
[Fact]
public void Published_and_rejected_are_terminal()
{
var published = ReadyAndApproved();
published.BeginPublishing(ReviewerId, Now);
published.MarkPublished(ReviewerId, Guid.NewGuid(), 1, "published-test", Now);
Assert.Throws<DomainException>(() => published.ReturnToReviewForStaleTarget(ReviewerId, Now));
var rejected = IngestionProposal.Create(WorkspaceId, ActorId, "other-correlation", "manifest");
rejected.BeginAnalysis(ActorId, Now);
rejected.ApplyAnalysis("analysis", ActorId, Now);
rejected.Reject(ReviewerId, "Not suitable.", Now);
Assert.Throws<DomainException>(() => rejected.ApplyAnalysis("late analysis", ActorId, Now));
}
private static IngestionProposal ReadyAndApproved(int maxPublishRetries = IngestionProposal.DefaultMaxPublishRetries)
{
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, Guid.NewGuid().ToString("N"), "manifest", maxPublishRetries);
proposal.BeginAnalysis(ActorId, Now);
proposal.ApplyAnalysis("analysis", ActorId, Now);
proposal.Approve(ReviewerId, Now);
return proposal;
}
[Theory]
[InlineData(IngestionProposalStatus.Received, IngestionProposalStatus.Received)]
[InlineData(IngestionProposalStatus.Analyzing, IngestionProposalStatus.Analyzing)]
[InlineData(IngestionProposalStatus.Publishing, IngestionProposalStatus.Approved)]
public void Retry_returns_to_the_stage_specific_safe_state(
IngestionProposalStatus failedStage, IngestionProposalStatus expectedStatus)
{
var proposal = ProposalAt(failedStage);
proposal.MarkFailed(ActorId, ProcessingStageFor(failedStage), "transient-error", "Processing could not complete.", Now);
proposal.Retry(ActorId, Now);
Assert.Equal(expectedStatus, proposal.Status);
}
[Theory]
[InlineData(IngestionProposalStatus.Received)]
[InlineData(IngestionProposalStatus.Analyzing)]
[InlineData(IngestionProposalStatus.Publishing)]
public void Failure_from_a_processing_stage_records_safe_metadata_and_a_revision(IngestionProposalStatus stage)
{
var proposal = ProposalAt(stage);
var priorRevision = proposal.RevisionNumber;
proposal.MarkFailed(ActorId, ProcessingStageFor(stage), "transient-error", "Processing could not complete.", Now);
Assert.Equal(IngestionProposalStatus.Failed, proposal.Status);
Assert.Equal(ProcessingStageFor(stage), proposal.FailedProcessingStage);
Assert.Equal("transient-error", proposal.FailureErrorType);
Assert.Equal("Processing could not complete.", proposal.FailureMessage);
Assert.True(proposal.IsRetryEligible);
var revision = Assert.Single(proposal.Revisions, item => item.Operation == "processing-failed");
Assert.Equal(priorRevision + 1, revision.RevisionNumber);
Assert.Equal(ProcessingStageFor(stage), revision.FailedProcessingStage);
}
[Theory]
[InlineData(IngestionProposalStatus.ReadyForReview)]
[InlineData(IngestionProposalStatus.Approved)]
public void Reviewer_changes_follow_the_explicit_changes_requested_and_analysis_path(IngestionProposalStatus stage)
{
var proposal = ProposalAt(stage);
proposal.RequestChanges(ReviewerId, "Please correct the classification.", Now);
Assert.Equal(IngestionProposalStatus.ChangesRequested, proposal.Status);
proposal.BeginAnalysis(ActorId, Now);
proposal.ApplyAnalysis("updated analysis", ActorId, Now);
Assert.Equal(IngestionProposalStatus.ReadyForReview, proposal.Status);
Assert.Null(proposal.ApprovedRevisionNumber);
}
[Fact]
public void Rejection_is_allowed_only_from_ready_for_review()
{
var received = IngestionProposal.Create(WorkspaceId, ActorId, "received", "manifest");
Assert.Throws<DomainException>(() => received.Reject(ReviewerId, "No.", Now));
var approved = ReadyAndApproved();
Assert.Throws<DomainException>(() => approved.Reject(ReviewerId, "No.", Now));
}
[Fact]
public void Stale_target_during_publishing_returns_to_review_and_clears_approval()
{
var proposal = ReadyAndApproved();
proposal.BeginPublishing(ReviewerId, Now);
proposal.ReturnToReviewForStaleTarget(ReviewerId, Now);
Assert.Equal(IngestionProposalStatus.ReadyForReview, proposal.Status);
Assert.Null(proposal.ApprovedRevisionNumber);
Assert.Null(proposal.ApprovedByUserId);
}
[Theory]
[InlineData("C:\\evidence\\proposal.pdf")]
[InlineData("/var/tmp/proposal.pdf")]
[InlineData("../proposal.pdf")]
[InlineData("https://example.test/proposal.pdf")]
public void Source_rejects_path_and_url_like_filenames(string unsafeFileName)
{
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "source-safety", "manifest");
Assert.Throws<DomainException>(() => proposal.AddSource(
unsafeFileName,
"application/pdf",
1,
new string('a', 64),
"clean",
Guid.NewGuid()));
}
[Fact]
public void Source_permits_a_safe_basename()
{
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "source-basename", "manifest");
proposal.AddSource("proposal.pdf", "application/pdf", 1, new string('b', 64), "clean", Guid.NewGuid());
Assert.Equal("proposal.pdf", Assert.Single(proposal.Sources).FileName);
}
private static IngestionProposal ProposalAt(
IngestionProposalStatus stage,
int maxPublishRetries = IngestionProposal.DefaultMaxPublishRetries)
{
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, Guid.NewGuid().ToString("N"), "manifest", maxPublishRetries);
if (stage == IngestionProposalStatus.Received)
{
return proposal;
}
proposal.BeginAnalysis(ActorId, Now);
if (stage == IngestionProposalStatus.Analyzing)
{
return proposal;
}
proposal.ApplyAnalysis("analysis", ActorId, Now);
if (stage == IngestionProposalStatus.ReadyForReview)
{
return proposal;
}
proposal.Approve(ReviewerId, Now);
if (stage == IngestionProposalStatus.Approved)
{
return proposal;
}
proposal.BeginPublishing(ReviewerId, Now);
return proposal;
}
[Theory]
[InlineData(IngestionProcessingStage.Receive, IngestionProposalStatus.Received)]
[InlineData(IngestionProcessingStage.Scan, IngestionProposalStatus.Analyzing)]
[InlineData(IngestionProcessingStage.Extract, IngestionProposalStatus.Analyzing)]
[InlineData(IngestionProcessingStage.Normalize, IngestionProposalStatus.Analyzing)]
[InlineData(IngestionProcessingStage.Retrieve, IngestionProposalStatus.Analyzing)]
[InlineData(IngestionProcessingStage.Compare, IngestionProposalStatus.Analyzing)]
[InlineData(IngestionProcessingStage.Compose, IngestionProposalStatus.Analyzing)]
[InlineData(IngestionProcessingStage.Publish, IngestionProposalStatus.Publishing)]
public void Failure_records_an_approved_processing_stage_at_its_matching_lifecycle_stage(
IngestionProcessingStage processingStage, IngestionProposalStatus lifecycleStage)
{
var proposal = ProposalAt(lifecycleStage);
proposal.MarkFailed(ActorId, processingStage, "transient-error", "Processing could not complete.", Now);
Assert.Equal(processingStage, proposal.FailedProcessingStage);
}
[Theory]
[InlineData(IngestionProposalStatus.Received, IngestionProcessingStage.Scan)]
[InlineData(IngestionProposalStatus.Analyzing, IngestionProcessingStage.Receive)]
[InlineData(IngestionProposalStatus.Analyzing, IngestionProcessingStage.Publish)]
[InlineData(IngestionProposalStatus.Publishing, IngestionProcessingStage.Compose)]
public void Failure_rejects_an_invalid_processing_stage_for_its_lifecycle_stage(
IngestionProposalStatus lifecycleStage, IngestionProcessingStage processingStage)
{
var proposal = ProposalAt(lifecycleStage);
var revisionNumber = proposal.RevisionNumber;
Assert.Throws<DomainException>(() => proposal.MarkFailed(ActorId, processingStage, "transient-error", "Processing could not complete.", Now));
Assert.Equal(lifecycleStage, proposal.Status);
Assert.Equal(revisionNumber, proposal.RevisionNumber);
}
[Theory]
[InlineData(IngestionProposalStatus.Received)]
[InlineData(IngestionProposalStatus.Analyzing)]
[InlineData(IngestionProposalStatus.Publishing)]
public void Retry_exhaustion_is_enforced_for_each_retryable_lifecycle_stage(IngestionProposalStatus stage)
{
var proposal = ProposalAt(stage, maxPublishRetries: 1);
proposal.MarkFailed(ActorId, ProcessingStageFor(stage), "transient-error", "Processing could not complete.", Now);
proposal.Retry(ActorId, Now);
if (stage == IngestionProposalStatus.Received)
{
proposal.MarkFailed(ActorId, IngestionProcessingStage.Receive, "transient-error", "Processing could not complete.", Now);
}
else if (stage == IngestionProposalStatus.Analyzing)
{
proposal.MarkFailed(ActorId, IngestionProcessingStage.Scan, "transient-error", "Processing could not complete.", Now);
}
else
{
proposal.BeginPublishing(ActorId, Now);
proposal.MarkFailed(ActorId, IngestionProcessingStage.Publish, "transient-error", "Processing could not complete.", Now);
}
Assert.Throws<DomainException>(() => proposal.Retry(ActorId, Now));
}
[Fact]
public void Retry_clears_live_failure_metadata_but_preserves_the_immutable_failure_revision()
{
var proposal = ProposalAt(IngestionProposalStatus.Analyzing);
proposal.MarkFailed(ActorId, IngestionProcessingStage.Extract, "transient-error", "Processing could not complete.", Now);
var failureRevision = Assert.Single(proposal.Revisions, revision => revision.Operation == "processing-failed");
proposal.Retry(ActorId, Now);
Assert.Null(proposal.FailedProcessingStage);
Assert.Null(proposal.FailureErrorType);
Assert.Null(proposal.FailureMessage);
Assert.False(proposal.IsRetryEligible);
Assert.Equal(IngestionProcessingStage.Extract, failureRevision.FailedProcessingStage);
var retryRevision = Assert.Single(proposal.Revisions, revision => revision.Operation == "retry-requested");
Assert.Null(retryRevision.FailedProcessingStage);
Assert.Null(retryRevision.FailureErrorType);
Assert.Null(retryRevision.FailureMessage);
}
private static IngestionProcessingStage ProcessingStageFor(IngestionProposalStatus status) => status switch
{
IngestionProposalStatus.Received => IngestionProcessingStage.Receive,
IngestionProposalStatus.Analyzing => IngestionProcessingStage.Scan,
IngestionProposalStatus.Publishing => IngestionProcessingStage.Publish,
_ => throw new ArgumentOutOfRangeException(nameof(status))
};
}
@@ -0,0 +1,29 @@
using ItWorx.KnowledgeHub.Domain.Identity;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class InstallationTests
{
[Fact]
public void CreateUninitialized_has_no_completion_timestamp()
{
var installation = Installation.CreateUninitialized("1.0.0", "m1", "nl-BE", "Europe/Brussels");
Assert.False(installation.IsSetupComplete);
Assert.Null(installation.SetupCompletedAt);
Assert.Equal(Installation.SingletonId, installation.Id);
}
[Fact]
public void CompleteSetup_can_only_happen_once()
{
var installation = Installation.CreateUninitialized("1.0.0", "m1", "nl-BE", "Europe/Brussels");
var now = DateTimeOffset.UtcNow;
installation.CompleteSetup(now);
Assert.True(installation.IsSetupComplete);
Assert.Equal(now, installation.SetupCompletedAt);
Assert.Throws<InvalidOperationException>(() => installation.CompleteSetup(now));
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../src/backend/ItWorx.KnowledgeHub.Domain/ItWorx.KnowledgeHub.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,77 @@
namespace ItWorx.KnowledgeHub.Domain.Tests;
using ItWorx.KnowledgeHub.Domain.Knowledge;
public sealed class PageCreationSessionTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 3, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Open_starts_in_the_Open_status_with_an_expiry_derived_from_the_lifespan()
{
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
Assert.True(session.IsOpen);
Assert.Equal(PageCreationSessionStatus.Open, session.Status);
Assert.Equal(Now.AddHours(24), session.ExpiresAt);
Assert.Null(session.CommittedPageId);
Assert.Null(session.ClosedAt);
Assert.False(session.IsExpired(Now));
Assert.True(session.IsExpired(Now.AddHours(25)));
}
[Fact]
public void MarkCommitted_transitions_to_Committed_and_records_the_page_id()
{
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
var pageId = Guid.NewGuid();
session.MarkCommitted(pageId, Now.AddMinutes(5));
Assert.False(session.IsOpen);
Assert.Equal(PageCreationSessionStatus.Committed, session.Status);
Assert.Equal(pageId, session.CommittedPageId);
Assert.Equal(Now.AddMinutes(5), session.ClosedAt);
}
[Fact]
public void MarkCommitted_a_second_time_throws_never_silently_double_commits()
{
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
session.MarkCommitted(Guid.NewGuid(), Now);
Assert.Throws<InvalidOperationException>(() => session.MarkCommitted(Guid.NewGuid(), Now));
}
[Fact]
public void MarkDiscarded_transitions_to_Discarded()
{
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
session.MarkDiscarded(Now.AddMinutes(1));
Assert.Equal(PageCreationSessionStatus.Discarded, session.Status);
Assert.Equal(Now.AddMinutes(1), session.ClosedAt);
}
[Fact]
public void MarkDiscarded_is_idempotent_when_already_discarded()
{
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
session.MarkDiscarded(Now);
var exception = Record.Exception(() => session.MarkDiscarded(Now.AddMinutes(1)));
Assert.Null(exception);
Assert.Equal(PageCreationSessionStatus.Discarded, session.Status);
}
[Fact]
public void MarkDiscarded_after_MarkCommitted_throws_a_committed_page_can_never_be_discarded()
{
var session = PageCreationSession.Open(Guid.NewGuid(), null, Guid.NewGuid(), Now, TimeSpan.FromHours(24));
session.MarkCommitted(Guid.NewGuid(), Now);
Assert.Throws<InvalidOperationException>(() => session.MarkDiscarded(Now));
}
}
@@ -0,0 +1,130 @@
using ItWorx.KnowledgeHub.Domain.Knowledge;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class PageTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 31, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Create_sets_the_initial_revision_number_and_publishes_immediately()
{
var page = Page.Create(
Guid.NewGuid(),
null,
SortKey.First(),
PageTitle.Create("Voorbeeldpagina"),
DocumentType.WikiPage,
"# Hallo",
Guid.NewGuid(),
Now);
Assert.Equal(1, page.CurrentRevisionNumber);
Assert.Equal(Now, page.PublishedAt);
Assert.False(page.IsTrashed);
Assert.Equal("voorbeeldpagina", page.Slug.Value);
}
[Fact]
public void Publish_increments_the_revision_number_and_updates_the_slug()
{
var editor = Guid.NewGuid();
var page = Page.Create(
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Oude titel"),
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
page.Publish(PageTitle.Create("Nieuwe titel"), "Bijgewerkte inhoud", DocumentType.WikiPage, DocumentStatus.Active, editor, Now.AddMinutes(5));
Assert.Equal(2, page.CurrentRevisionNumber);
Assert.Equal("nieuwe-titel", page.Slug.Value);
Assert.Equal(DocumentStatus.Active, page.DocumentStatus);
Assert.Equal(editor, page.UpdatedBy);
}
[Fact]
public void MoveTo_rejects_a_page_becoming_its_own_parent()
{
var page = Page.Create(
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
Assert.Throws<InvalidOperationException>(
() => page.MoveTo(page.Id, SortKey.First(), Guid.NewGuid(), Now));
}
[Fact]
public void A_trashed_page_cannot_be_edited_or_moved_until_restored()
{
var actor = Guid.NewGuid();
var page = Page.Create(
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
DocumentType.WikiPage, "Inhoud", actor, Now);
page.Trash(actor, Now, TimeSpan.FromDays(30));
Assert.True(page.IsTrashed);
Assert.Throws<InvalidOperationException>(
() => page.Publish(PageTitle.Create("Pagina"), "Nieuw", DocumentType.WikiPage, DocumentStatus.Active, actor, Now));
Assert.Throws<InvalidOperationException>(
() => page.MoveTo(null, SortKey.First(), actor, Now));
page.Restore(actor, Now.AddMinutes(1));
Assert.False(page.IsTrashed);
}
[Fact]
public void Trashing_twice_is_a_harmless_no_op()
{
var actor = Guid.NewGuid();
var page = Page.Create(
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
DocumentType.WikiPage, "Inhoud", actor, Now);
page.Trash(actor, Now, TimeSpan.FromDays(30));
var purgeAfterFirst = page.PurgeAfter;
page.Trash(actor, Now.AddDays(1), TimeSpan.FromDays(30));
Assert.Equal(purgeAfterFirst, page.PurgeAfter);
}
[Fact]
public void ReplaceTags_trims_deduplicates_case_insensitively_and_keeps_first_seen_casing()
{
var page = Page.Create(
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
page.ReplaceTags([" Netwerk ", "netwerk", "Archief", "", " "]);
Assert.Equal(["Netwerk", "Archief"], page.Tags);
}
[Fact]
public void ReplaceTags_bounds_the_number_of_tags_and_the_length_of_each_tag()
{
var page = Page.Create(
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
var tooMany = Enumerable.Range(0, Page.MaxTagCount + 10).Select(i => $"tag{i}");
page.ReplaceTags(tooMany);
Assert.Equal(Page.MaxTagCount, page.Tags.Count);
var tooLong = new string('a', Page.MaxTagLength + 20);
page.ReplaceTags([tooLong]);
Assert.Equal(Page.MaxTagLength, page.Tags[0].Length);
}
[Fact]
public void ReplaceTags_does_not_advance_the_revision_number_or_change_UpdatedAt()
{
var page = Page.Create(
Guid.NewGuid(), null, SortKey.First(), PageTitle.Create("Pagina"),
DocumentType.WikiPage, "Inhoud", Guid.NewGuid(), Now);
page.ReplaceTags(["Runbook"]);
Assert.Equal(1, page.CurrentRevisionNumber);
Assert.Equal(Now, page.UpdatedAt);
}
}
@@ -0,0 +1,31 @@
using ItWorx.KnowledgeHub.Domain.Knowledge;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class PageTitleTests
{
[Fact]
public void Create_trims_surrounding_whitespace()
{
var title = PageTitle.Create(" PostgreSQL back-up ");
Assert.Equal("PostgreSQL back-up", title.Value);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void Create_rejects_empty_values(string value)
{
Assert.Throws<ArgumentException>(() => PageTitle.Create(value));
}
[Fact]
public void Create_rejects_values_above_the_limit()
{
var value = new string('x', PageTitle.MaximumLength + 1);
Assert.Throws<ArgumentOutOfRangeException>(() => PageTitle.Create(value));
}
}
@@ -0,0 +1,72 @@
using ItWorx.KnowledgeHub.Domain.Projects;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class ProjectTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
private static Project CreateProject() =>
Project.Create(Guid.NewGuid(), "Kennisbank vernieuwing", "Samenvatting", ProjectPriority.Medium, "Huidige stand.", Guid.NewGuid(), Guid.NewGuid(), Now);
[Fact]
public void Create_starts_in_concept_status_with_a_derived_slug()
{
var project = CreateProject();
Assert.Equal(ProjectLifecycleStatus.Concept, project.Status);
Assert.Equal("kennisbank-vernieuwing", project.Slug);
Assert.Empty(project.Technologies);
}
[Theory]
[InlineData(ProjectLifecycleStatus.Concept, ProjectLifecycleStatus.Planned, true)]
[InlineData(ProjectLifecycleStatus.Planned, ProjectLifecycleStatus.Active, true)]
[InlineData(ProjectLifecycleStatus.Active, ProjectLifecycleStatus.Blocked, true)]
[InlineData(ProjectLifecycleStatus.Blocked, ProjectLifecycleStatus.Active, true)]
[InlineData(ProjectLifecycleStatus.Active, ProjectLifecycleStatus.Maintenance, true)]
[InlineData(ProjectLifecycleStatus.Active, ProjectLifecycleStatus.Completed, true)]
[InlineData(ProjectLifecycleStatus.Completed, ProjectLifecycleStatus.Paused, true)]
[InlineData(ProjectLifecycleStatus.Paused, ProjectLifecycleStatus.Active, true)]
[InlineData(ProjectLifecycleStatus.Concept, ProjectLifecycleStatus.Archived, true)]
[InlineData(ProjectLifecycleStatus.Concept, ProjectLifecycleStatus.Active, false)]
[InlineData(ProjectLifecycleStatus.Archived, ProjectLifecycleStatus.Active, false)]
[InlineData(ProjectLifecycleStatus.Completed, ProjectLifecycleStatus.Active, false)]
public void ChangeStatus_only_allows_the_documented_lifecycle_transitions(
ProjectLifecycleStatus from, ProjectLifecycleStatus to, bool expectedAllowed)
{
Assert.Equal(expectedAllowed, Project.CanTransition(from, to));
}
[Fact]
public void ChangeStatus_throws_for_a_disallowed_transition()
{
var project = CreateProject();
var exception = Record.Exception(() => project.ChangeStatus(ProjectLifecycleStatus.Active, Guid.NewGuid(), Now));
Assert.IsType<InvalidOperationException>(exception);
}
[Fact]
public void ChangeStatus_out_of_archived_is_always_rejected()
{
var project = CreateProject();
project.ChangeStatus(ProjectLifecycleStatus.Archived, Guid.NewGuid(), Now);
var exception = Record.Exception(() => project.ChangeStatus(ProjectLifecycleStatus.Active, Guid.NewGuid(), Now));
Assert.IsType<InvalidOperationException>(exception);
Assert.True(project.IsArchived);
}
[Fact]
public void ReplaceTechnologies_normalizes_trims_deduplicates_and_bounds_the_list()
{
var project = CreateProject();
project.ReplaceTechnologies([" .NET ", ".net", "PostgreSQL", "", "React"]);
Assert.Equal([".NET", "PostgreSQL", "React"], project.Technologies);
}
}
@@ -0,0 +1,38 @@
using ItWorx.KnowledgeHub.Domain.Runbooks;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class RunbookProfileTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void A_never_validated_runbook_is_stale_against_any_revision()
{
var runbook = RunbookProfile.Create(Guid.NewGuid(), Guid.NewGuid(), RunbookRiskLevel.Medium, false, null, null, Now);
Assert.True(runbook.IsStaleAgainst(1));
}
[Fact]
public void Validate_records_the_reviewer_and_the_current_revision()
{
var runbook = RunbookProfile.Create(Guid.NewGuid(), Guid.NewGuid(), RunbookRiskLevel.High, true, 30, null, Now);
var validator = Guid.NewGuid();
runbook.Validate(validator, 3, Now);
Assert.Equal(validator, runbook.ValidatedBy);
Assert.Equal(Now, runbook.LastValidatedAt);
Assert.False(runbook.IsStaleAgainst(3));
}
[Fact]
public void A_new_content_revision_after_validation_makes_the_runbook_stale_again()
{
var runbook = RunbookProfile.Create(Guid.NewGuid(), Guid.NewGuid(), RunbookRiskLevel.Low, false, null, null, Now);
runbook.Validate(Guid.NewGuid(), 2, Now);
Assert.True(runbook.IsStaleAgainst(3));
}
}
@@ -0,0 +1,64 @@
using ItWorx.KnowledgeHub.Domain.Attachments;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class SafeFilenameTests
{
[Theory]
[InlineData("../../../../etc/passwd", "passwd")]
[InlineData("..\\..\\windows\\system32\\config", "config")]
[InlineData("subdir/verslag.pdf", "verslag.pdf")]
public void Create_strips_any_directory_component_and_keeps_only_the_leaf_name(string raw, string expected)
{
var filename = SafeFilename.Create(raw);
Assert.Equal(expected, filename.Value);
}
[Fact]
public void Create_rejects_a_filename_containing_a_null_byte()
{
Assert.Throws<ArgumentException>(() => SafeFilename.Create("bestand\0.txt"));
}
[Fact]
public void Create_rejects_a_filename_containing_a_control_character()
{
Assert.Throws<ArgumentException>(() => SafeFilename.Create("bestand.txt"));
}
[Fact]
public void Create_rejects_an_empty_or_dot_only_filename()
{
Assert.Throws<ArgumentException>(() => SafeFilename.Create(""));
Assert.Throws<ArgumentException>(() => SafeFilename.Create("."));
Assert.Throws<ArgumentException>(() => SafeFilename.Create(".."));
}
[Fact]
public void Create_truncates_an_excessively_long_filename()
{
var raw = new string('a', 400) + ".txt";
var filename = SafeFilename.Create(raw);
Assert.True(filename.Value.Length <= SafeFilename.MaximumLength);
}
[Fact]
public void AllExtensions_detects_a_double_extension()
{
var filename = SafeFilename.Create("factuur.pdf.exe");
Assert.Equal([".pdf", ".exe"], filename.AllExtensions());
Assert.Equal(".exe", filename.LastExtension());
}
[Fact]
public void LastExtension_is_null_when_there_is_no_extension()
{
var filename = SafeFilename.Create("readme");
Assert.Null(filename.LastExtension());
}
}
@@ -0,0 +1,124 @@
using ItWorx.KnowledgeHub.Domain.Automation;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class ServiceAccountTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Create_starts_enabled()
{
var account = ServiceAccount.Create(Guid.NewGuid(), "CI-bot", "Beschrijving.", Guid.NewGuid(), Now);
Assert.True(account.Enabled);
Assert.Null(account.DisabledAt);
}
[Fact]
public void Disable_is_idempotent_and_records_the_first_disable_time_only()
{
var account = ServiceAccount.Create(Guid.NewGuid(), "CI-bot", "B.", Guid.NewGuid(), Now);
account.Disable(Now);
account.Disable(Now.AddHours(1));
Assert.False(account.Enabled);
Assert.Equal(Now, account.DisabledAt);
}
}
public sealed class ServiceAccountTokenTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Create_rejects_a_token_with_no_valid_scopes()
{
var exception = Record.Exception(() =>
ServiceAccountToken.Create(Guid.NewGuid(), "pub123", "hash", ["not-a-real-scope"], null, Now));
Assert.IsType<ArgumentException>(exception);
}
[Fact]
public void Create_normalizes_and_deduplicates_scopes()
{
var token = ServiceAccountToken.Create(
Guid.NewGuid(), "pub123", "hash", [Scopes.PagesRead, Scopes.PagesRead, "bogus"], null, Now);
Assert.Equal([Scopes.PagesRead], token.Scopes);
}
[Fact]
public void A_never_expiring_never_revoked_token_is_active()
{
var token = ServiceAccountToken.Create(Guid.NewGuid(), "pub", "hash", [Scopes.PagesRead], null, Now);
Assert.True(token.IsActive(Now.AddYears(10)));
}
[Fact]
public void An_expired_token_is_not_active()
{
var token = ServiceAccountToken.Create(Guid.NewGuid(), "pub", "hash", [Scopes.PagesRead], Now.AddDays(1), Now);
Assert.False(token.IsActive(Now.AddDays(2)));
}
[Fact]
public void A_revoked_token_is_not_active_even_before_its_expiry()
{
var token = ServiceAccountToken.Create(Guid.NewGuid(), "pub", "hash", [Scopes.PagesRead], Now.AddYears(1), Now);
token.Revoke(Now.AddHours(1));
Assert.False(token.IsActive(Now.AddHours(2)));
}
[Fact]
public void HasScope_is_true_only_for_a_granted_scope()
{
var token = ServiceAccountToken.Create(Guid.NewGuid(), "pub", "hash", [Scopes.PagesRead], null, Now);
Assert.True(token.HasScope(Scopes.PagesRead));
Assert.False(token.HasScope(Scopes.PagesWrite));
}
}
public sealed class OutboxMessageTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Create_starts_unprocessed_and_immediately_available()
{
var message = OutboxMessage.Create("page.created.v1", "page", Guid.NewGuid().ToString(), "{}", Now);
Assert.False(message.IsProcessed);
Assert.Equal(Now, message.AvailableAt);
Assert.Equal(0, message.Attempts);
}
[Fact]
public void MarkFailed_increments_attempts_and_delays_the_next_availability()
{
var message = OutboxMessage.Create("page.created.v1", "page", Guid.NewGuid().ToString(), "{}", Now);
message.MarkFailed("transient error", TimeSpan.FromMinutes(5), Now);
Assert.Equal(1, message.Attempts);
Assert.Equal(Now.AddMinutes(5), message.AvailableAt);
Assert.False(message.IsProcessed);
}
[Fact]
public void MarkProcessed_sets_the_processed_timestamp()
{
var message = OutboxMessage.Create("page.created.v1", "page", Guid.NewGuid().ToString(), "{}", Now);
message.MarkProcessed(Now.AddSeconds(1));
Assert.True(message.IsProcessed);
}
}
@@ -0,0 +1,36 @@
using ItWorx.KnowledgeHub.Domain.Knowledge;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class SortKeyTests
{
[Fact]
public void Next_produces_a_key_that_sorts_after_the_previous_one()
{
var first = SortKey.First();
var second = SortKey.Next(first);
var third = SortKey.Next(second);
Assert.True(string.CompareOrdinal(first.Value, second.Value) < 0);
Assert.True(string.CompareOrdinal(second.Value, third.Value) < 0);
}
[Fact]
public void Between_produces_a_key_strictly_between_two_others()
{
var lower = SortKey.First();
var upper = SortKey.Next(lower);
var middle = SortKey.Between(lower, upper);
Assert.True(string.CompareOrdinal(lower.Value, middle.Value) < 0);
Assert.True(string.CompareOrdinal(middle.Value, upper.Value) < 0);
}
[Fact]
public void All_keys_share_the_same_fixed_width_for_stable_ordinal_comparison()
{
var keys = new[] { SortKey.First(), SortKey.Next(SortKey.First()) };
Assert.All(keys, key => Assert.Equal(keys[0].Value.Length, key.Value.Length));
}
}
@@ -0,0 +1,38 @@
using ItWorx.KnowledgeHub.Domain.Identity;
namespace ItWorx.KnowledgeHub.Domain.Tests;
public sealed class WorkspaceNameTests
{
[Fact]
public void Create_trims_and_normalizes_the_name()
{
var name = WorkspaceName.Create(" ItWorx Kennisbank ");
Assert.Equal("ItWorx Kennisbank", name.Value);
}
[Theory]
[InlineData("ItWorx Kennisbank", "itworx-kennisbank")]
[InlineData("École & Café", "ecole-cafe")]
public void Create_derives_a_url_safe_slug(string input, string expectedSlug)
{
var name = WorkspaceName.Create(input);
Assert.Equal(expectedSlug, name.Slug);
}
[Fact]
public void Create_rejects_an_empty_name()
{
Assert.Throws<ArgumentException>(() => WorkspaceName.Create(" "));
}
[Fact]
public void Create_rejects_a_name_above_the_limit()
{
var tooLong = new string('a', WorkspaceName.MaximumLength + 1);
Assert.Throws<ArgumentOutOfRangeException>(() => WorkspaceName.Create(tooLong));
}
}
@@ -0,0 +1,250 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace ItWorx.KnowledgeHub.IntegrationTests.Attachments;
/// <summary>
/// The docs/13-attachments.md upload pipeline: signature-based detection (never
/// trusting the declared type/extension alone), safe filename handling, delivery
/// policy, deduplication, and re-authorized serving with safe headers
/// (docs/27-acceptance-matrix.md, M4-A01..A03).
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class AttachmentUploadTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private static readonly byte[] ExecutableBytes = [0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00];
private static async Task<Guid> CreatePageAsync(HttpClient client)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, $"Bijlagepagina {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page.Id;
}
[Fact]
public async Task Uploading_a_real_png_is_accepted_as_inline_eligible_and_can_be_downloaded_back_unchanged()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", PngBytes, "foto.png", "image/png");
Assert.Equal(HttpStatusCode.OK, uploadResponse.StatusCode);
var summary = await uploadResponse.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
Assert.Equal("image/png", summary.MediaType);
Assert.Equal("InlinePreviewEligible", summary.DeliveryPolicy);
Assert.Equal("foto.png", summary.Filename);
var contentResponse = await client.GetAsync($"/api/v1/attachments/{summary.Id}/content");
Assert.Equal(HttpStatusCode.OK, contentResponse.StatusCode);
Assert.Equal("image/png", contentResponse.Content.Headers.ContentType?.MediaType);
Assert.Equal("nosniff", contentResponse.Headers.GetValues("X-Content-Type-Options").Single());
Assert.Contains("inline", contentResponse.Content.Headers.ContentDisposition?.ToString() ?? string.Empty, StringComparison.OrdinalIgnoreCase);
var downloaded = await contentResponse.Content.ReadAsByteArrayAsync();
Assert.Equal(PngBytes, downloaded);
}
[Fact]
public async Task A_valid_attachment_above_kestrels_default_limit_reaches_the_100_mib_application_policy()
{
var kestrel = Factory.Services.GetRequiredService<IOptions<KestrelServerOptions>>().Value;
Assert.Equal(101L * 1024 * 1024, kestrel.Limits.MaxRequestBodySize);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var content = new byte[31 * 1024 * 1024];
content[0] = 0x50;
content[1] = 0x4B;
content[2] = 0x03;
content[3] = 0x04;
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", content, "grote-presentatie.pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var summary = await response.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
Assert.Equal(content.LongLength, summary.ByteLength);
Assert.Equal("DownloadOnly", summary.DeliveryPolicy);
}
[Fact]
public async Task Uploading_identical_content_twice_deduplicates_the_blob_but_keeps_two_independent_references()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var first = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{pageId}/attachments", PngBytes, "eerste.png", "image/png");
var second = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{pageId}/attachments", PngBytes, "tweede.png", "image/png");
var firstSummary = await first.Content.ReadFromJsonAsync<AttachmentSummary>();
var secondSummary = await second.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(firstSummary);
Assert.NotNull(secondSummary);
Assert.NotEqual(firstSummary.Id, secondSummary.Id);
var list = await client.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{pageId}/attachments");
Assert.NotNull(list);
Assert.Equal(2, list.Items.Count);
Assert.Contains(list.Items, item => item.Filename == "eerste.png");
Assert.Contains(list.Items, item => item.Filename == "tweede.png");
// Both references still serve independently and correctly even though they
// share one physical blob.
var firstContent = await client.GetAsync($"/api/v1/attachments/{firstSummary.Id}/content");
var secondContent = await client.GetAsync($"/api/v1/attachments/{secondSummary.Id}/content");
Assert.Equal(PngBytes, await firstContent.Content.ReadAsByteArrayAsync());
Assert.Equal(PngBytes, await secondContent.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task Concurrent_identical_uploads_are_atomic_and_keep_two_references_to_one_blob()
{
using var firstClient = Factory.CreateClient();
using var secondClient = Factory.CreateClient();
await SetupHelper.LoginAsync(firstClient, OwnerEmail, OwnerPassword);
await SetupHelper.LoginAsync(secondClient, OwnerEmail, OwnerPassword);
var firstPageId = await CreatePageAsync(firstClient);
var secondPageId = await CreatePageAsync(secondClient);
var uploads = await Task.WhenAll(
SetupHelper.PostMultipartWithCsrfAsync(firstClient, $"/api/v1/pages/{firstPageId}/attachments", PngBytes, "parallel-een.png", "image/png"),
SetupHelper.PostMultipartWithCsrfAsync(secondClient, $"/api/v1/pages/{secondPageId}/attachments", PngBytes, "parallel-twee.png", "image/png"));
Assert.All(uploads, response => Assert.Equal(HttpStatusCode.OK, response.StatusCode));
var firstSummary = await uploads[0].Content.ReadFromJsonAsync<AttachmentSummary>();
var secondSummary = await uploads[1].Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(firstSummary);
Assert.NotNull(secondSummary);
Assert.NotEqual(firstSummary.Id, secondSummary.Id);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var blobCount = await db.AttachmentBlobs.CountAsync(blob => blob.ByteLength == PngBytes.LongLength);
Assert.Equal(1, blobCount);
}
[Fact]
public async Task An_executable_signature_is_rejected_regardless_of_declared_type_or_extension()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
// Double extension + a declared image type — a classic disguise attempt
// (docs/13-attachments.md security tests: "double extensions").
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", ExecutableBytes, "factuur.pdf.exe", "image/png");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Theory]
[InlineData("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>", "pagina.html")]
[InlineData("<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>", "icoon.svg")]
public async Task HTML_and_SVG_content_is_rejected_in_v1(string payload, string filename)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", Encoding.UTF8.GetBytes(payload), filename, "text/plain");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_path_traversal_filename_is_sanitized_to_its_leaf_name_only()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", "veilige inhoud"u8.ToArray(), "../../../../etc/passwd", "text/plain");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var summary = await response.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
Assert.Equal("passwd", summary.Filename);
}
[Fact]
public async Task A_declared_type_that_does_not_match_the_real_signature_is_stored_under_the_detected_type()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
// Declares image/png but the bytes are plain text — the signature must win.
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", "gewone tekstinhoud"u8.ToArray(), "notitie.png", "image/png");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var summary = await response.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
Assert.Equal("text/plain", summary.MediaType);
Assert.Equal("DownloadOnly", summary.DeliveryPolicy);
}
[Fact]
public async Task An_empty_upload_is_rejected()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client);
var response = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{pageId}/attachments", [], "leeg.txt", "text/plain");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Fetching_attachment_content_requires_authentication()
{
using var authClient = Factory.CreateClient();
await SetupHelper.LoginAsync(authClient, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(authClient);
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
authClient, $"/api/v1/pages/{pageId}/attachments", PngBytes, "beveiligd.png", "image/png");
var summary = await uploadResponse.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(summary);
using var anonymousClient = Factory.CreateClient();
var response = await anonymousClient.GetAsync($"/api/v1/attachments/{summary.Id}/content");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Fetching_an_unknown_attachment_id_returns_not_found()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await client.GetAsync($"/api/v1/attachments/{Guid.NewGuid()}/content");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
@@ -0,0 +1,229 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Application.Attachments.UseCases;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Attachments;
/// <summary>
/// V2-R5 finding V2-R5-H1: before this fix, any workspace Reader who merely learned or
/// guessed a staged (not-yet-published) attachment's GUID could read its content —
/// `GetAttachmentContentUseCase` checked nothing beyond "not trashed"
/// (docs/adr/ADR-029-v2-r5-final-hardening.md). These tests prove the fix directly
/// against the real HTTP surface and real PostgreSQL, covering every scenario the ADR
/// names: workspace isolation, session-creator vs. unrelated Reader vs. Administrator,
/// committed vs. still-staged, a shared blob reached through two different references,
/// an expired-but-not-yet-cleaned-up session, and a revoked service token.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class StagedAttachmentAuthorizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private async Task<HttpClient> LoggedInClientAsync(string email, string password)
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, email, password);
return client;
}
private static async Task<Guid> BeginSessionAsync(HttpClient client)
{
var response = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/pages/staged", new BeginPageCreationSessionRequest(null));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<PageCreationSessionResponse>();
Assert.NotNull(session);
return session.SessionId;
}
private static async Task<Guid> UploadStagedAttachmentAsync(HttpClient client, Guid sessionId, string filename = "geheim.png")
{
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, filename, "image/png");
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
return attachment.Id;
}
private async Task<HttpClient> CreateReaderAsync(HttpClient adminClient, string email)
{
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users", new CreateUserRequest("Lezer", email, "reader-strong-password", "Reader"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
return await LoggedInClientAsync(email, "reader-strong-password");
}
[Fact]
public async Task The_sessions_own_creator_can_read_their_own_staged_attachment()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
var content = await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
}
[Fact]
public async Task An_unrelated_reader_with_only_the_guid_cannot_read_a_staged_attachment()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
using var reader = await CreateReaderAsync(owner, "onbevoegde-lezer@itworx.test");
var content = await reader.GetAsync($"/api/v1/attachments/{attachmentId}/content");
// Same 404 as "does not exist" — never a 403 that would confirm the ID is real.
Assert.Equal(HttpStatusCode.NotFound, content.StatusCode);
}
[Fact]
public async Task An_administrator_who_did_not_create_the_session_can_still_read_a_staged_attachment()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/admin/users",
new CreateUserRequest("Tweede beheerder", "tweede-beheerder@itworx.test", "admin-strong-password", "Administrator"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
using var secondAdmin = await LoggedInClientAsync("tweede-beheerder@itworx.test", "admin-strong-password");
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
var content = await secondAdmin.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
}
[Fact]
public async Task Once_committed_the_same_attachment_becomes_readable_by_an_ordinary_reader()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
var commit = await SetupHelper.PostWithCsrfAsync(
owner, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Gepubliceerd", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, commit.StatusCode);
using var reader = await CreateReaderAsync(owner, "lezer-na-publicatie@itworx.test");
var content = await reader.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
}
[Fact]
public async Task A_shared_blob_reached_through_two_references_is_gated_independently_per_reference()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
// Reference #1: uploaded directly onto an already-published page — ordinarily
// readable.
var pageResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/pages", new CreatePageRequest(null, $"Gedeelde-blob-pagina {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var publishedUpload = await SetupHelper.PostMultipartWithCsrfAsync(
owner, $"/api/v1/pages/{page.Id}/attachments", PngBytes, "gepubliceerd.png", "image/png");
Assert.Equal(HttpStatusCode.OK, publishedUpload.StatusCode);
var publishedAttachment = await publishedUpload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(publishedAttachment);
// Reference #2: the exact same bytes, staged into a still-open session — same
// physical blob (deduplicated), different logical reference, different owner.
var sessionId = await BeginSessionAsync(owner);
var stagedAttachmentId = await UploadStagedAttachmentAsync(owner, sessionId, "nog-niet-gepubliceerd.png");
using var reader = await CreateReaderAsync(owner, "lezer-gedeelde-blob@itworx.test");
var publishedContent = await reader.GetAsync($"/api/v1/attachments/{publishedAttachment.Id}/content");
Assert.Equal(HttpStatusCode.OK, publishedContent.StatusCode);
var stagedContent = await reader.GetAsync($"/api/v1/attachments/{stagedAttachmentId}/content");
Assert.Equal(HttpStatusCode.NotFound, stagedContent.StatusCode);
}
[Fact]
public async Task An_expired_but_not_yet_cleaned_up_session_is_treated_as_closed_even_for_its_own_creator()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE page_creation_sessions SET expires_at = {DateTimeOffset.UtcNow.AddHours(-1)} WHERE id = {sessionId}");
}
var content = await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.NotFound, content.StatusCode);
}
[Fact]
public async Task A_revoked_service_token_is_rejected_immediately_when_requesting_attachment_content()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var pageResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/pages", new CreatePageRequest(null, $"Token-pagina {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
owner, $"/api/v1/pages/{page.Id}/attachments", PngBytes, "token-test.png", "image/png");
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
// Creating a service account issues its first token in the same response
// (docs/09-api-contract.md) — no separate /tokens call needed.
var accountResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/service-accounts",
new CreateServiceAccountRequest("Bijlage-token-test", "Test", ["attachments:read"]));
Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
var token = await accountResponse.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(token);
using var serviceClient = Factory.CreateClient();
serviceClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.RawToken);
var beforeRevoke = await serviceClient.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.OK, beforeRevoke.StatusCode);
var revoke = await SetupHelper.PostWithCsrfAsync(owner, $"/api/v1/service-accounts/tokens/{token.TokenId}/revoke", new { });
Assert.Equal(HttpStatusCode.OK, revoke.StatusCode);
var afterRevoke = await serviceClient.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.Unauthorized, afterRevoke.StatusCode);
}
[Fact]
public async Task A_reference_belonging_to_a_different_workspace_is_never_served_even_with_valid_role()
{
using var owner = await LoggedInClientAsync(OwnerEmail, OwnerPassword);
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadStagedAttachmentAsync(owner, sessionId);
// No second real workspace exists in this single-tenant deployment to drive
// through HTTP end-to-end — the use case's workspace check is exercised
// directly instead, proving the defense-in-depth guard independently of the
// single-workspace HTTP routing that always resolves the same default
// workspace today (docs/adr/ADR-029-v2-r5-final-hardening.md).
using var scope = Factory.Services.CreateScope();
var useCase = scope.ServiceProvider.GetRequiredService<GetAttachmentContentUseCase>();
var result = await useCase.ExecuteAsync(
Guid.NewGuid(), attachmentId, new AttachmentRequestor(null, WorkspaceRole.Administrator), CancellationToken.None);
Assert.Null(result);
}
}
@@ -0,0 +1,29 @@
using System.Net;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Auth;
/// <summary>Regression coverage for a real bug found via E2E testing: two concurrent
/// logins for the same account raced on ASP.NET Core Identity's optimistic-concurrency
/// token while recording the last-login timestamp, turning a successful authentication
/// into a 500 for whichever request lost the race.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class ConcurrentLoginTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Concurrent_logins_for_the_same_account_never_fail_with_a_server_error()
{
using var clientA = Factory.CreateClient();
using var clientB = Factory.CreateClient();
var loginA = SetupHelper.LoginAsync(clientA, OwnerEmail, OwnerPassword);
var loginB = SetupHelper.LoginAsync(clientB, OwnerEmail, OwnerPassword);
var results = await Task.WhenAll(loginA, loginB);
foreach (var response in results)
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
}
@@ -0,0 +1,50 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Auth;
[Collection(PostgresTestGroup.Name)]
public sealed class CsrfTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Cookie_authenticated_mutation_without_a_csrf_header_is_rejected()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
// Deliberately bypasses SetupHelper.PostWithCsrfAsync to send the mutation
// without ever having fetched/attached a CSRF token.
using var response = await client.PostAsJsonAsync("/api/v1/auth/logout", new { });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Cookie_authenticated_mutation_with_a_valid_csrf_header_succeeds()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync<object?>(client, "/api/v1/auth/logout", null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task A_csrf_token_obtained_by_one_session_does_not_validate_for_another()
{
using var attackerClient = Factory.CreateClient();
var stolenToken = await SetupHelper.GetCsrfTokenAsync(attackerClient);
using var victimClient = Factory.CreateClient();
await SetupHelper.LoginAsync(victimClient, OwnerEmail, OwnerPassword);
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
request.Headers.Add(stolenToken.HeaderName, stolenToken.Value);
using var response = await victimClient.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
@@ -0,0 +1,132 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Auth;
[Collection(PostgresTestGroup.Name)]
public sealed class LoginLogoutLockoutTests(PostgresContainerFixture postgres)
: OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Login_with_correct_credentials_establishes_an_authenticated_session()
{
using var client = Factory.CreateClient();
var response = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<SessionResponse>();
Assert.NotNull(session);
Assert.True(session.Authenticated);
Assert.Equal(OwnerEmail, session.Email);
Assert.Equal("Administrator", session.Role);
// The same client (with its session cookie) now sees itself as authenticated
// on a fresh request too, not only in the login response body.
var sessionAfter = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(sessionAfter);
Assert.True(sessionAfter.Authenticated);
}
[Fact]
public async Task Login_with_wrong_password_is_rejected_generically()
{
using var client = Factory.CreateClient();
var response = await SetupHelper.LoginAsync(client, OwnerEmail, "totally-wrong-password");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain(OwnerPassword, body, StringComparison.Ordinal);
}
[Fact]
public async Task Login_for_an_unknown_account_returns_the_same_response_as_a_wrong_password()
{
using var client = Factory.CreateClient();
var response = await SetupHelper.LoginAsync(client, "does-not-exist@itworx.test", "whatever-password");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Login_rate_limit_is_per_remote_ip_and_ignores_untrusted_forwarded_headers()
{
using var clientA = Factory.CreateClient();
clientA.DefaultRequestHeaders.Add("X-Test-Remote-Ip", "192.0.2.10");
// Without a configured trusted proxy, these caller-supplied values must not
// create fresh rate-limit partitions. All attempts originate from client A's
// effective remote address.
for (var attempt = 0; attempt < 10; attempt++)
{
clientA.DefaultRequestHeaders.Remove("X-Forwarded-For");
clientA.DefaultRequestHeaders.Add("X-Forwarded-For", $"203.0.113.{attempt + 1}");
var response = await SetupHelper.LoginAsync(
clientA,
"unknown-rate-limit-user@itworx.test",
"invalid-password");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
clientA.DefaultRequestHeaders.Remove("X-Forwarded-For");
clientA.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.250");
var exhaustedClientA = await SetupHelper.LoginAsync(
clientA,
"unknown-rate-limit-user@itworx.test",
"invalid-password");
Assert.Equal(HttpStatusCode.TooManyRequests, exhaustedClientA.StatusCode);
using var clientB = Factory.CreateClient();
clientB.DefaultRequestHeaders.Add("X-Test-Remote-Ip", "192.0.2.11");
clientB.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.251");
var clientBLogin = await SetupHelper.LoginAsync(clientB, OwnerEmail, OwnerPassword);
Assert.Equal(HttpStatusCode.OK, clientBLogin.StatusCode);
clientA.DefaultRequestHeaders.Remove("X-Forwarded-For");
clientA.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.252");
var stillExhaustedClientA = await SetupHelper.LoginAsync(
clientA,
"unknown-rate-limit-user@itworx.test",
"invalid-password");
Assert.Equal(HttpStatusCode.TooManyRequests, stillExhaustedClientA.StatusCode);
}
[Fact]
public async Task Repeated_failed_logins_lock_the_account()
{
using var client = Factory.CreateClient();
// Configured lockout threshold is 5 failed attempts.
HttpResponseMessage last = null!;
for (var attempt = 0; attempt < 6; attempt++)
{
last = await SetupHelper.LoginAsync(client, OwnerEmail, "wrong-password-attempt");
}
Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode);
// Even the correct password is now rejected while locked out.
var withCorrectPassword = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
Assert.Equal(HttpStatusCode.TooManyRequests, withCorrectPassword.StatusCode);
}
[Fact]
public async Task Logout_ends_the_session()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var logoutResponse = await SetupHelper.PostWithCsrfAsync<object?>(client, "/api/v1/auth/logout", null);
Assert.Equal(HttpStatusCode.OK, logoutResponse.StatusCode);
var session = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(session);
Assert.False(session.Authenticated);
}
}
@@ -0,0 +1,147 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Auth;
[Collection(PostgresTestGroup.Name)]
public sealed class RecoveryTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Administrator_issued_token_lets_the_user_set_a_new_password_and_old_sessions_stop_working()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
const string email = "vergeten@itworx.test";
const string originalPassword = "original-strong-password";
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient,
"/api/v1/admin/users",
new CreateUserRequest("Vergeetachtige Gebruiker", email, originalPassword, "Editor"));
var user = await createResponse.Content.ReadFromJsonAsync<UserSummaryResponse>();
Assert.NotNull(user);
// The user logs in once with the original password to prove that session gets
// invalidated once the password is reset via recovery.
using var userClient = Factory.CreateClient();
await SetupHelper.LoginAsync(userClient, email, originalPassword);
var beforeReset = await userClient.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(beforeReset);
Assert.True(beforeReset.Authenticated);
var issueResponse = await SetupHelper.PostWithCsrfAsync<object?>(
adminClient,
$"/api/v1/admin/users/{user.Id}/recovery-tokens",
null);
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
var issued = await issueResponse.Content.ReadFromJsonAsync<IssueRecoveryTokenResponse>();
Assert.NotNull(issued);
Assert.False(string.IsNullOrWhiteSpace(issued.Token));
const string newPassword = "brand-new-strong-password";
using var redeemClient = Factory.CreateClient();
var redeemResponse = await redeemClient.PostAsJsonAsync(
"/api/v1/auth/recovery/redeem",
new RecoveryRedeemRequest(issued.Token, newPassword));
Assert.Equal(HttpStatusCode.OK, redeemResponse.StatusCode);
// The pre-reset session must be dead now (security stamp rotated).
var afterReset = await userClient.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(afterReset);
Assert.False(afterReset.Authenticated);
// The old password no longer works...
using var oldPasswordClient = Factory.CreateClient();
var oldPasswordAttempt = await SetupHelper.LoginAsync(oldPasswordClient, email, originalPassword);
Assert.Equal(HttpStatusCode.Unauthorized, oldPasswordAttempt.StatusCode);
// ...but the new one does.
using var newPasswordClient = Factory.CreateClient();
var newPasswordAttempt = await SetupHelper.LoginAsync(newPasswordClient, email, newPassword);
Assert.Equal(HttpStatusCode.OK, newPasswordAttempt.StatusCode);
}
[Fact]
public async Task Redeeming_the_same_recovery_token_twice_only_works_once()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
const string email = "eenmalig@itworx.test";
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient,
"/api/v1/admin/users",
new CreateUserRequest("Eenmalige Gebruiker", email, "starting-strong-password", "Reader"));
var user = await createResponse.Content.ReadFromJsonAsync<UserSummaryResponse>();
Assert.NotNull(user);
var issueResponse = await SetupHelper.PostWithCsrfAsync<object?>(
adminClient,
$"/api/v1/admin/users/{user.Id}/recovery-tokens",
null);
var issued = await issueResponse.Content.ReadFromJsonAsync<IssueRecoveryTokenResponse>();
Assert.NotNull(issued);
using var client = Factory.CreateClient();
var first = await client.PostAsJsonAsync(
"/api/v1/auth/recovery/redeem",
new RecoveryRedeemRequest(issued.Token, "first-new-strong-password"));
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var second = await client.PostAsJsonAsync(
"/api/v1/auth/recovery/redeem",
new RecoveryRedeemRequest(issued.Token, "second-new-strong-password"));
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
}
[Fact]
public async Task Redeeming_an_unknown_token_fails_without_revealing_anything()
{
using var client = Factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/v1/auth/recovery/redeem",
new RecoveryRedeemRequest("ikhrec_not-a-real-token", "whatever-strong-password"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
// V2-M7: closes the recovery-token admin UI (issuance) — this authorization
// boundary (matching TrashPurgeTests.Purge_is_administrator_only's exact pattern)
// had no test at all before, even though the endpoint itself already correctly
// requires Administrator via AdminEndpoints' group-level policy.
[Fact]
public async Task Issuing_a_recovery_token_is_administrator_only()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
var targetEmail = "doelgebruiker-recovery@itworx.test";
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient,
"/api/v1/admin/users",
new CreateUserRequest("Doelgebruiker", targetEmail, "starting-strong-password", "Reader"));
var target = await createResponse.Content.ReadFromJsonAsync<UserSummaryResponse>();
Assert.NotNull(target);
const string editorEmail = "redacteur-recovery@itworx.test";
const string editorPassword = "correct-horse-battery-staple";
await SetupHelper.PostWithCsrfAsync(
adminClient,
"/api/v1/admin/users",
new CreateUserRequest("Redacteur", editorEmail, editorPassword, "Editor"));
using var editorClient = Factory.CreateClient();
await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword);
var response = await SetupHelper.PostWithCsrfAsync<object?>(
editorClient, $"/api/v1/admin/users/{target.Id}/recovery-tokens", null);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
@@ -0,0 +1,36 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Auth;
/// <summary>
/// Configures a deliberately short, deterministic cookie lifetime for this test class
/// only (see docs/22-testing-strategy.md — avoid sleeps except for a documented,
/// bounded, real timing behavior; session expiry inherently requires elapsed time).
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class SessionExpiryTests : OwnerSetupTestBase
{
public SessionExpiryTests(PostgresContainerFixture postgres)
: base(postgres, new Dictionary<string, string?> { ["KnowledgeHub:Auth:CookieExpireSeconds"] = "2" })
{
}
[Fact]
public async Task Session_cookie_expires_after_the_configured_lifetime()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var immediately = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(immediately);
Assert.True(immediately.Authenticated);
await Task.Delay(TimeSpan.FromSeconds(3));
var afterExpiry = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(afterExpiry);
Assert.False(afterExpiry.Authenticated);
}
}
@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Identity;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Auth;
/// <summary>
/// Backs the accessible owner picker (docs/adr/ADR-029-v2-r5-final-hardening.md, brief
/// Phase 7), replacing manual `OwnerId`/GUID entry. Any Reader may search, results are
/// scoped to the current workspace's enabled members only, and a disabled account is
/// never disclosed.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class WorkspaceMemberSearchTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task A_reader_can_search_workspace_members_by_name_or_email()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/admin/users",
new CreateUserRequest("Zoekbare Collega", "zoekbare-collega@itworx.test", "reader-strong-password", "Reader"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
using var reader = Factory.CreateClient();
await SetupHelper.LoginAsync(reader, "zoekbare-collega@itworx.test", "reader-strong-password");
var byName = await reader.GetFromJsonAsync<WorkspaceMemberListResponse>("/api/v1/workspace/members?q=Zoekbare");
Assert.NotNull(byName);
Assert.Contains(byName.Items, item => item.Email == "zoekbare-collega@itworx.test");
var byEmail = await reader.GetFromJsonAsync<WorkspaceMemberListResponse>(
"/api/v1/workspace/members?q=zoekbare-collega@itworx.test");
Assert.NotNull(byEmail);
Assert.Contains(byEmail.Items, item => item.DisplayName == "Zoekbare Collega");
var noMatch = await reader.GetFromJsonAsync<WorkspaceMemberListResponse>(
"/api/v1/workspace/members?q=geen-enkele-overeenkomst-xyz");
Assert.NotNull(noMatch);
Assert.Empty(noMatch.Items);
}
[Fact]
public async Task An_empty_query_returns_an_initial_list_rather_than_an_error()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var response = await owner.GetFromJsonAsync<WorkspaceMemberListResponse>("/api/v1/workspace/members");
Assert.NotNull(response);
Assert.Contains(response.Items, item => item.Email == OwnerEmail);
}
[Fact]
public async Task A_disabled_account_is_never_returned()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/admin/users",
new CreateUserRequest("Uitgeschakelde Collega", "uitgeschakeld@itworx.test", "reader-strong-password", "Reader"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<UserSummaryResponse>();
Assert.NotNull(created);
var disableResponse = await SetupHelper.PatchWithCsrfAsync(
owner, $"/api/v1/admin/users/{created.Id}", new { enabled = false });
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
var result = await owner.GetFromJsonAsync<WorkspaceMemberListResponse>("/api/v1/workspace/members?q=Uitgeschakelde");
Assert.NotNull(result);
Assert.Empty(result.Items);
}
[Fact]
public async Task An_unauthenticated_caller_is_rejected()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync("/api/v1/workspace/members");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -0,0 +1,134 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Authorization;
[Collection(PostgresTestGroup.Name)]
public sealed class RoleAuthorizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Unauthenticated_requests_to_admin_endpoints_are_rejected()
{
using var client = Factory.CreateClient();
using var response = await client.GetAsync("/api/v1/admin/users");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Reader_role_cannot_reach_administrator_only_endpoints()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
const string readerEmail = "lezer@itworx.test";
const string readerPassword = "reader-strong-password";
await CreateUserAsync(adminClient, readerEmail, readerPassword, "Reader");
using var readerClient = Factory.CreateClient();
await SetupHelper.LoginAsync(readerClient, readerEmail, readerPassword);
using var response = await readerClient.GetAsync("/api/v1/admin/users");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Editor_role_cannot_reach_administrator_only_endpoints()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
const string editorEmail = "redacteur@itworx.test";
const string editorPassword = "editor-strong-password";
await CreateUserAsync(adminClient, editorEmail, editorPassword, "Editor");
using var editorClient = Factory.CreateClient();
await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword);
using var response = await editorClient.GetAsync("/api/v1/admin/users");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Administrator_role_can_reach_administrator_only_endpoints()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
using var response = await adminClient.GetAsync("/api/v1/admin/users");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Disabling_a_user_immediately_invalidates_their_existing_session_not_only_future_logins()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
const string email = "tijdelijk@itworx.test";
const string password = "temporary-strong-password";
var created = await CreateUserAsync(adminClient, email, password, "Editor");
using var targetClient = Factory.CreateClient();
await SetupHelper.LoginAsync(targetClient, email, password);
var whileEnabled = await targetClient.GetAsync("/api/v1/auth/session");
Assert.Equal(HttpStatusCode.OK, whileEnabled.StatusCode);
var disableResponse = await SetupHelper.PatchWithCsrfAsync(
adminClient,
$"/api/v1/admin/users/{created.Id}",
new UpdateUserRequest(Role: null, Enabled: false));
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
// The already-issued session cookie must stop working immediately — this is
// checked on the very next request, not after any polling delay, because the
// security-stamp validation happens on every request (ValidationInterval =
// TimeSpan.Zero).
var afterDisable = await targetClient.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(afterDisable);
Assert.False(afterDisable.Authenticated);
}
[Fact]
public async Task Administrator_cannot_demote_the_last_enabled_administrator()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
var session = await adminClient.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(session);
var response = await SetupHelper.PatchWithCsrfAsync(
adminClient,
$"/api/v1/admin/users/{session.UserId}",
new UpdateUserRequest(Role: "Reader", Enabled: null));
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
private static async Task<UserSummaryResponse> CreateUserAsync(
HttpClient adminClient,
string email,
string password,
string role)
{
var response = await SetupHelper.PostWithCsrfAsync(
adminClient,
"/api/v1/admin/users",
new CreateUserRequest($"Test {role}", email, password, role));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<UserSummaryResponse>();
Assert.NotNull(payload);
return payload;
}
}
@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
/// <summary>A retried create/append request carrying the same
/// <c>Idempotency-Key</c> must never execute the mutation twice
/// (docs/09-api-contract.md).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class IdempotencyTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Retrying_a_project_update_with_the_same_Idempotency_Key_does_not_create_a_second_update()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects",
new CreateProjectRequest("Idempotentieproject", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var idempotencyKey = Guid.NewGuid().ToString();
var request = new CreateProjectUpdateRequest(
"Progress", "Idempotente update", "Samenvatting.", "Reported", null, null, null, null, null, null, null, null, null, null);
var first = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", request, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var firstBody = await first.Content.ReadFromJsonAsync<ProjectUpdateSummary>();
var second = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", request, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
var secondBody = await second.Content.ReadFromJsonAsync<ProjectUpdateSummary>();
// The replayed response is the exact cached original — same update ID — not a
// freshly created second row.
Assert.Equal(firstBody!.Id, secondBody!.Id);
var timeline = await client.GetFromJsonAsync<ProjectUpdatesEnvelope>($"/api/v1/projects/{project.Id}/updates");
Assert.NotNull(timeline);
Assert.Single(timeline.Items, update => update.Title == "Idempotente update");
}
[Fact]
public async Task Reusing_an_Idempotency_Key_with_different_request_content_is_a_conflict()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects",
new CreateProjectRequest("Conflictproject idempotentie", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var idempotencyKey = Guid.NewGuid().ToString();
var first = new CreateProjectUpdateRequest(
"Progress", "Eerste inhoud", "S.", "Reported", null, null, null, null, null, null, null, null, null, null);
var different = new CreateProjectUpdateRequest(
"Progress", "Andere inhoud", "S.", "Reported", null, null, null, null, null, null, null, null, null, null);
var firstResponse = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", first, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
var conflictResponse = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", different, idempotencyKey);
Assert.Equal(HttpStatusCode.Conflict, conflictResponse.StatusCode);
}
private static async Task<HttpResponseMessage> PostWithIdempotencyKeyAsync<TBody>(
HttpClient client, string path, TBody body, string idempotencyKey)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("Idempotency-Key", idempotencyKey);
return await client.SendAsync(request);
}
private sealed record ProjectUpdatesEnvelope(IReadOnlyList<ProjectUpdateSummary> Items, string? NextCursor);
}
@@ -0,0 +1,88 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
/// <summary>Every mutation this milestone wires into the outbox must produce a
/// durable, correctly-typed row in the same transaction as the mutation itself
/// (docs/24-mcp-readiness.md "Outbox events", M6-A06) — proven here by reading the
/// table directly, the same "no consumer exists yet, but the record must be there and
/// correct for one later" contract the outbox is designed around.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class OutboxTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Creating_a_page_records_a_page_created_outbox_event_in_the_same_transaction()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Outbox-testpagina", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var messages = await db.OutboxMessages
.Where(message => message.EventType == "page.created.v1" && message.AggregateId == page.Id.ToString())
.ToListAsync();
var message = Assert.Single(messages);
Assert.False(message.IsProcessed);
Assert.Contains(page.Id.ToString(), message.PayloadJson, StringComparison.Ordinal);
}
[Fact]
public async Task Publishing_a_page_edit_records_a_page_updated_outbox_event()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Bij te werken pagina", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Bijgewerkte titel", "Nieuwe inhoud", "WikiPage", "Active", null), page.ETag);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var messages = await db.OutboxMessages
.Where(message => message.EventType == "page.updated.v1" && message.AggregateId == page.Id.ToString())
.ToListAsync();
Assert.Single(messages);
}
[Fact]
public async Task Unprocessed_outbox_messages_are_queryable_oldest_first_a_future_dispatcher_could_replay_them()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Herstel-test pagina 1", "WikiPage", "I.", null));
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Herstel-test pagina 2", "WikiPage", "I.", null));
using var scope = Factory.Services.CreateScope();
var outbox = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Application.Automation.IOutboxRepository>();
var unprocessed = await outbox.GetUnprocessedAsync(500, CancellationToken.None);
Assert.True(unprocessed.Count >= 2);
Assert.All(unprocessed, message => Assert.False(message.IsProcessed));
// Oldest first: monotonically non-decreasing AvailableAt.
for (var i = 1; i < unprocessed.Count; i++)
{
Assert.True(unprocessed[i].AvailableAt >= unprocessed[i - 1].AvailableAt);
}
}
}
@@ -0,0 +1,101 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
/// <summary>The bounded project context bundle (M6-A04): explicit maximum item
/// counts, deterministic ordering, and truncation metadata rather than silently
/// dropping items (docs/09-api-contract.md "Project context").</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class ProjectContextTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Project_context_reports_no_truncation_when_everything_fits_within_the_bounds()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects", new CreateProjectRequest("Contextproject", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/updates",
new CreateProjectUpdateRequest("Progress", "Update 1", "S.", "Reported", null, null, null, null, null, null, null, null, null, null));
var context = await client.GetFromJsonAsync<ProjectContextResponse>($"/api/v1/projects/{project.Id}/context");
Assert.NotNull(context);
Assert.Equal(project.Id, context.Project.Id);
Assert.False(context.Truncation.Truncated);
Assert.Empty(context.Truncation.OmittedCounts);
Assert.Single(context.RecentUpdates);
}
[Fact]
public async Task Project_context_truncates_updates_beyond_the_requested_bound_and_reports_the_omitted_count()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects", new CreateProjectRequest("Afgekapt project", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
for (var i = 0; i < 3; i++)
{
await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/updates",
new CreateProjectUpdateRequest("Progress", $"Update {i}", "S.", "Reported", null, null, null, null, null, null, null, null, null, null));
}
var context = await client.GetFromJsonAsync<ProjectContextResponse>($"/api/v1/projects/{project.Id}/context?updates=2");
Assert.NotNull(context);
Assert.Equal(2, context.RecentUpdates.Count);
Assert.True(context.Truncation.Truncated);
Assert.Equal(1, context.Truncation.OmittedCounts["updates"]);
}
[Fact]
public async Task Project_context_carries_stable_citations_for_every_included_document()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects", new CreateProjectRequest("Citatieproject", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var pageResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages",
new ItWorx.KnowledgeHub.Contracts.Knowledge.CreatePageRequest(null, "Architectuurpagina", "WikiPage", "# A", null));
var page = await pageResponse.Content.ReadFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Knowledge.PageDetail>();
Assert.NotNull(page);
await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/documents", new LinkProjectDocumentRequest(page.Id, "Architecture"));
var context = await client.GetFromJsonAsync<ProjectContextResponse>($"/api/v1/projects/{project.Id}/context");
Assert.NotNull(context);
var citation = Assert.Single(context.Citations, item => item.Id == page.Id);
Assert.Equal("page", citation.Kind);
Assert.Equal(page.Title, citation.Title);
}
[Fact]
public async Task Project_context_is_not_reachable_without_authentication()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync($"/api/v1/projects/{Guid.NewGuid()}/context");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -0,0 +1,374 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
[Collection(PostgresTestGroup.Name)]
public sealed class ServiceAccountTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<HttpClient> AdminClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static async Task<CreatedServiceAccountToken> CreateServiceAccountAsync(
HttpClient adminClient, string name, params string[] scopes)
{
var response = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts", new CreateServiceAccountRequest(name, "Testaccount.", scopes));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
return created;
}
private static HttpClient BearerClient(KnowledgeHubIntegrationFactory factory, string rawToken)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
return client;
}
[Fact]
public async Task Creating_a_service_account_returns_the_raw_token_exactly_once_and_it_is_never_persisted_in_the_clear()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "CI-bot", Scopes.KnowledgeSearch);
Assert.False(string.IsNullOrWhiteSpace(created.RawToken));
Assert.StartsWith("ikh_", created.RawToken, StringComparison.Ordinal);
var list = await adminClient.GetFromJsonAsync<ServiceAccountListResponse>("/api/v1/service-accounts");
Assert.NotNull(list);
var account = Assert.Single(list.Items, item => item.Id == created.ServiceAccountId);
var token = Assert.Single(account.Tokens);
// The listing never re-exposes the secret — only the safe public ID and
// metadata (docs/09-api-contract.md: "The API never returns or logs the
// secret."). ikh_<public-id>_<secret> where <public-id> is always exactly 12
// base64url characters (ServiceAccountTokenIssuer.PublicIdLength) — parsed by
// fixed length, not by scanning for '_', because the base64url alphabet itself
// can contain '_' (see ADR-015 and ServiceAccountTokenIssuer's own comment for
// the real, reproducing bug this fixed length boundary replaced).
var secret = created.RawToken["ikh_".Length..][13..];
Assert.NotEqual(created.RawToken, token.PublicId);
Assert.DoesNotContain(secret, token.PublicId, StringComparison.Ordinal);
}
[Fact]
public async Task A_valid_bearer_token_with_the_correct_scope_can_call_a_scoped_endpoint()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Search-bot", Scopes.KnowledgeSearch);
using var bearerClient = BearerClient(Factory, created.RawToken);
var response = await bearerClient.GetAsync("/api/v1/search?q=test");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task A_bearer_token_without_the_required_scope_is_forbidden_vertical_privilege_escalation()
{
using var adminClient = await AdminClientAsync();
// Deliberately only a read scope for an unrelated resource — never pages:write.
var created = await CreateServiceAccountAsync(adminClient, "Read-only-bot", Scopes.KnowledgeSearch);
using var bearerClient = BearerClient(Factory, created.RawToken);
var response = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, "Ongeautoriseerde pagina", "WikiPage", "Inhoud.", null));
// The mutation must never be allowed to proceed — that is the actual security
// property M6-A02 requires. ASP.NET Core's combined cookie+ServiceToken policy
// evaluation reports this specific case (authenticated via ServiceToken, but
// missing the required scope, with no cookie present at all) as 401 rather
// than the arguably more precise 403; both correctly deny the request, and
// this is a documented framework nuance (ADR-015), not a security gap — no
// page is ever created either way (asserted below).
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the escalation attempt to be denied, but got {response.StatusCode}.");
var list = await adminClient.GetFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Knowledge.PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(list);
Assert.DoesNotContain(list.Items, page => page.Title == "Ongeautoriseerde pagina");
}
[Fact]
public async Task A_bearer_token_with_pages_write_can_create_a_page_and_its_scope_does_not_grant_project_access_horizontal_escalation()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Pages-bot", Scopes.PagesWrite, Scopes.PagesRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, "Via service-account aangemaakt", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var readResponse = await bearerClient.GetAsync($"/api/v1/pages/{page.Id}");
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
// pages:write/pages:read never implicitly grant projects:read — scopes are
// narrow and additive, never bundled (M6-A02).
var projectsResponse = await bearerClient.GetAsync("/api/v1/projects");
Assert.Equal(HttpStatusCode.Forbidden, projectsResponse.StatusCode);
}
[Fact]
public async Task A_bearer_token_with_projects_write_can_create_a_project_and_it_is_attributed_to_the_service_account()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Infra-bot", Scopes.ProjectsWrite, Scopes.ProjectsRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/projects",
new CreateProjectRequest("Via serviceaccount aangemaakt", "Samenvatting.", "Medium", "Startsituatie.", Guid.NewGuid()));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var project = await createResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var readResponse = await bearerClient.GetAsync($"/api/v1/projects/{project.Id}");
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
// projects:write/projects:read never implicitly grant pages:read — scopes are
// narrow and additive, never bundled (M6-A02), same property as the mirrored
// pages:write test above, now checked from the other direction too.
var pagesResponse = await bearerClient.GetAsync("/api/v1/pages/roots");
Assert.Equal(HttpStatusCode.Forbidden, pagesResponse.StatusCode);
}
[Fact]
public async Task A_bearer_token_with_projects_write_can_update_a_project_it_created_with_concurrency_and_idempotency()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Infra-update-bot", Scopes.ProjectsWrite, Scopes.ProjectsRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/projects",
new CreateProjectRequest("Update via serviceaccount", "Samenvatting.", "Medium", "Startsituatie.", Guid.NewGuid()));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var project = await createResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var updateRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/projects/{project.Id}")
{
Content = JsonContent.Create(new UpdateProjectRequest(
project.Name, project.Summary, project.Priority, project.CurrentStateMarkdown, project.OwnerId,
null, null, null, "https://gitea.example/owner/repo", "https://example.test", null, null, ["dotnet", "react"])),
};
updateRequest.Headers.IfMatch.ParseAdd($"\"{project.ETag}\"");
var updateResponse = await bearerClient.SendAsync(updateRequest);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(updated);
Assert.Equal("https://gitea.example/owner/repo", updated.RepositoryUrl);
Assert.Equal(["dotnet", "react"], updated.Technologies);
}
[Fact]
public async Task A_bearer_token_without_projects_write_cannot_create_a_project_vertical_privilege_escalation()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Read-only-project-bot", Scopes.ProjectsRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var response = await bearerClient.PostAsJsonAsync(
"/api/v1/projects",
new CreateProjectRequest("Ongeautoriseerd project", "S", "Medium", "S", Guid.NewGuid()));
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the escalation attempt to be denied, but got {response.StatusCode}.");
var list = await adminClient.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects");
Assert.NotNull(list);
Assert.DoesNotContain(list.Items, project => project.Name == "Ongeautoriseerd project");
}
[Fact]
public async Task A_scoped_curation_token_can_inventory_update_and_move_pages_with_concurrency_and_idempotency()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Curation-bot", Scopes.PagesWrite, Scopes.PagesRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var parentResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, $"Curatie {Guid.NewGuid():N}", "WikiPage", "Bovenliggend.", null));
parentResponse.EnsureSuccessStatusCode();
var parent = await parentResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(parent);
var childResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, $"Bron {Guid.NewGuid():N}", "WikiPage", "Broninhoud.", null));
childResponse.EnsureSuccessStatusCode();
var child = await childResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(child);
var roots = await bearerClient.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots?limit=100");
Assert.NotNull(roots);
Assert.Contains(roots.Items, item => item.Id == child.Id);
using var update = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/pages/{child.Id}")
{
Content = JsonContent.Create(new UpdatePageRequest(
child.Title, child.Markdown, child.DocumentType, "Active", "Bron gevalideerd.", child.Tags))
};
update.Headers.TryAddWithoutValidation("If-Match", child.ETag);
update.Headers.TryAddWithoutValidation("Idempotency-Key", $"curation-update-{child.Id:N}");
var updateResponse = await bearerClient.SendAsync(update);
updateResponse.EnsureSuccessStatusCode();
var updated = await updateResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(updated);
using var move = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/pages/{child.Id}/move")
{
Content = JsonContent.Create(new MovePageRequest(parent.Id, null))
};
move.Headers.TryAddWithoutValidation("If-Match", updated.ETag);
move.Headers.TryAddWithoutValidation("Idempotency-Key", $"curation-move-{child.Id:N}");
var moveResponse = await bearerClient.SendAsync(move);
moveResponse.EnsureSuccessStatusCode();
var children = await bearerClient.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(children);
Assert.Contains(children.Items, item => item.Id == child.Id);
var revisions = await bearerClient.GetAsync($"/api/v1/pages/{child.Id}/revisions");
Assert.Equal(HttpStatusCode.OK, revisions.StatusCode);
var backlinks = await bearerClient.GetAsync($"/api/v1/pages/{child.Id}/backlinks");
Assert.Equal(HttpStatusCode.OK, backlinks.StatusCode);
}
[Fact]
public async Task A_scoped_ingestion_token_can_upload_list_and_read_attachments_then_trash_its_page_for_rollback()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(
adminClient, "Ingestion-bot", Scopes.PagesRead, Scopes.PagesWrite,
Scopes.AttachmentsRead, Scopes.AttachmentsWrite);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, $"Importbatch {Guid.NewGuid():N}", "WikiPage", "Broninhoud.", null));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.NotNull(createResponse.Headers.ETag);
var etag = createResponse.Headers.ETag.ToString();
using var multipart = new MultipartFormDataContent();
var file = new ByteArrayContent("veilige broninhoud"u8.ToArray());
file.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
multipart.Add(file, "file", "bron.txt");
var uploadResponse = await bearerClient.PostAsync($"/api/v1/pages/{page.Id}/attachments", multipart);
Assert.Equal(HttpStatusCode.OK, uploadResponse.StatusCode);
var attachment = await uploadResponse.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
var listed = await bearerClient.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{page.Id}/attachments");
Assert.NotNull(listed);
Assert.Contains(listed.Items, item => item.Id == attachment.Id);
var downloaded = await bearerClient.GetByteArrayAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal("veilige broninhoud"u8.ToArray(), downloaded);
using var trashRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/pages/{page.Id}/trash");
trashRequest.Headers.TryAddWithoutValidation("If-Match", etag);
var trashResponse = await bearerClient.SendAsync(trashRequest);
Assert.Equal(HttpStatusCode.OK, trashResponse.StatusCode);
}
[Fact]
public async Task An_ingestion_token_without_attachments_write_cannot_upload()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(
adminClient, "Attachment-read-only-bot", Scopes.PagesWrite, Scopes.AttachmentsRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, $"Scopeproef {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
using var multipart = new MultipartFormDataContent();
multipart.Add(new ByteArrayContent("niet toegestaan"u8.ToArray()), "file", "verboden.txt");
var uploadResponse = await bearerClient.PostAsync($"/api/v1/pages/{page.Id}/attachments", multipart);
Assert.True(uploadResponse.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized);
}
[Fact]
public async Task A_revoked_token_is_rejected_on_the_very_next_request()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Kort-levend-bot", Scopes.KnowledgeSearch);
using var bearerClient = BearerClient(Factory, created.RawToken);
var beforeRevoke = await bearerClient.GetAsync("/api/v1/search?q=test");
Assert.Equal(HttpStatusCode.OK, beforeRevoke.StatusCode);
var revokeResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, $"/api/v1/service-accounts/tokens/{created.TokenId}/revoke", (object?)null);
Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
var afterRevoke = await bearerClient.GetAsync("/api/v1/search?q=test");
Assert.Equal(HttpStatusCode.Unauthorized, afterRevoke.StatusCode);
}
[Fact]
public async Task An_invalid_bearer_token_is_rejected()
{
using var bearerClient = BearerClient(Factory, "ikh_bogus_notarealtoken");
var response = await bearerClient.GetAsync("/api/v1/search?q=test");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Only_an_administrator_can_create_service_accounts()
{
using var adminClient = await AdminClientAsync();
const string editorEmail = "beheerder-test@itworx.test";
const string editorPassword = "editor-strong-password";
var createUserResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users", new CreateUserRequest("Redacteur", editorEmail, editorPassword, "Editor"));
Assert.Equal(HttpStatusCode.OK, createUserResponse.StatusCode);
using var editorClient = Factory.CreateClient();
await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword);
var response = await SetupHelper.PostWithCsrfAsync(
editorClient, "/api/v1/service-accounts", new CreateServiceAccountRequest("Poging", "S.", [Scopes.KnowledgeSearch]));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Creating_a_service_account_with_an_unknown_scope_is_rejected()
{
using var adminClient = await AdminClientAsync();
var response = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts", new CreateServiceAccountRequest("Ongeldig", "S.", ["not-a-real-scope"]));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
}
@@ -0,0 +1,61 @@
using ItWorx.KnowledgeHub.Infrastructure.Automation;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
/// <summary>
/// Pure, deterministic parsing tests for <see cref="ServiceAccountTokenIssuer"/> — no
/// database or container required. Regression coverage for a real defect: the
/// base64url alphabet used for the public ID can itself contain '_', the same
/// character used to delimit it from the secret. A naive `Split('_', 3)` mis-parsed
/// roughly 1 in 6 randomly generated tokens; this was found via a flaky integration
/// test (<see cref="ServiceAccountTests"/>) failing its very first bearer-authenticated
/// call, not by inspection. See ADR-015.
/// </summary>
public sealed class ServiceAccountTokenIssuerTests
{
private readonly ServiceAccountTokenIssuer _issuer = new();
[Fact]
public void Round_trips_a_freshly_issued_token()
{
var issued = _issuer.IssueNewToken();
var parsed = _issuer.TryParse(issued.RawToken, out var publicId, out var secret);
Assert.True(parsed);
Assert.Equal(issued.PublicId, publicId);
Assert.Equal(_issuer.HashSecret(secret), issued.SecretHash);
}
[Fact]
public void A_public_id_that_itself_contains_an_underscore_is_still_parsed_correctly()
{
// A synthetic token shaped exactly like a real one, deliberately constructed so
// the 12-character public ID contains '_' characters — the exact case that
// broke a delimiter-scanning parser.
const string publicIdWithUnderscores = "a_b_c_d_e_f_";
const string secret = "the-real-secret-value";
var rawToken = $"ikh_{publicIdWithUnderscores}_{secret}";
var parsed = _issuer.TryParse(rawToken, out var publicId, out var parsedSecret);
Assert.True(parsed);
Assert.Equal(publicIdWithUnderscores, publicId);
Assert.Equal(secret, parsedSecret);
}
[Theory]
[InlineData("")]
[InlineData("not-a-token")]
[InlineData("ikh_")]
[InlineData("ikh_short")]
[InlineData("ikh_123456789012")]
[InlineData("ikh_123456789012_")]
[InlineData("bearer_123456789012_secret")]
public void Malformed_or_incomplete_tokens_are_rejected(string presented)
{
var parsed = _issuer.TryParse(presented, out _, out _);
Assert.False(parsed);
}
}
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,157 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
/// <summary>
/// One isolated logical database per test class, on the shared
/// <see cref="PostgresContainerFixture"/> server. Real EF Core
/// migrations run at startup exactly as production does — this is not a mocked or
/// SQLite-backed substitute (docs/22-testing-strategy.md).
/// </summary>
public sealed class KnowledgeHubIntegrationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgresContainerFixture _postgres;
private readonly IReadOnlyDictionary<string, string?> _additionalConfiguration;
private readonly string _databaseName = $"test_{Guid.NewGuid():N}";
private readonly string _attachmentsDirectory =
Path.Combine(Path.GetTempPath(), "ikh-tests-attachments", Guid.NewGuid().ToString("N"));
private readonly string _importsDirectory =
Path.Combine(Path.GetTempPath(), "ikh-tests-imports", Guid.NewGuid().ToString("N"));
private readonly string _backupsDirectory =
Path.Combine(Path.GetTempPath(), "ikh-tests-backups", Guid.NewGuid().ToString("N"));
public KnowledgeHubIntegrationFactory(
PostgresContainerFixture postgres,
IReadOnlyDictionary<string, string?>? additionalConfiguration = null)
{
_postgres = postgres;
_additionalConfiguration = additionalConfiguration ?? new Dictionary<string, string?>();
}
public async Task InitializeAsync()
{
await using var connection = new NpgsqlConnection(_postgres.AdminConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE \"{_databaseName}\"";
await command.ExecuteNonQueryAsync();
}
async Task IAsyncLifetime.DisposeAsync()
{
// Dispose the hosted TestServer/HttpClient first (base WebApplicationFactory
// behavior), then drop this test class's isolated database.
await base.DisposeAsync();
if (Directory.Exists(_attachmentsDirectory))
{
Directory.Delete(_attachmentsDirectory, recursive: true);
}
if (Directory.Exists(_importsDirectory))
{
Directory.Delete(_importsDirectory, recursive: true);
}
if (Directory.Exists(_backupsDirectory))
{
Directory.Delete(_backupsDirectory, recursive: true);
}
try
{
await using var connection = new NpgsqlConnection(_postgres.AdminConnectionString);
await connection.OpenAsync();
await using (var terminate = connection.CreateCommand())
{
terminate.CommandText =
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity " +
"WHERE datname = @name AND pid <> pg_backend_pid()";
terminate.Parameters.AddWithValue("name", _databaseName);
await terminate.ExecuteNonQueryAsync();
}
await using var drop = connection.CreateCommand();
drop.CommandText = $"DROP DATABASE IF EXISTS \"{_databaseName}\"";
await drop.ExecuteNonQueryAsync();
}
catch (NpgsqlException)
{
// Best-effort cleanup: the whole container is disposed at the end of the
// run regardless, so a failure to drop one logical database here is not a
// correctness problem for the test suite.
}
}
/// <summary>The isolated content-addressed attachment storage root for this test
/// class, so parallel test classes never share (or race on) blob files.</summary>
public string AttachmentsDirectory => _attachmentsDirectory;
/// <summary>The isolated LeafWiki imports mount root for this test class. Tests
/// write synthetic fixture files here directly (never a real snapshot).</summary>
public string ImportsDirectory => _importsDirectory;
/// <summary>The isolated backups root for this test class, so parallel/sequential
/// test classes never share (or have retention-prune) each other's backups.</summary>
public string BackupsDirectory => _backupsDirectory;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseContentRoot(
Path.Combine(
FindRepositoryRoot().FullName,
"src",
"backend",
"ItWorx.KnowledgeHub.Api"));
builder.UseEnvironment("IntegrationTest");
builder.ConfigureAppConfiguration((_, configuration) =>
{
var values = new Dictionary<string, string?>
{
["KnowledgeHub:Database:Host"] = _postgres.Host,
["KnowledgeHub:Database:Port"] = _postgres.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
["KnowledgeHub:Database:Name"] = _databaseName,
["KnowledgeHub:Database:Username"] = PostgresContainerFixture.Username,
["KnowledgeHub:Database:Password"] = PostgresContainerFixture.Password,
["KnowledgeHub:Paths:Attachments"] = _attachmentsDirectory,
["KnowledgeHub:Paths:Imports"] = _importsDirectory,
["KnowledgeHub:Paths:Backups"] = _backupsDirectory
};
foreach (var (key, value) in _additionalConfiguration)
{
values[key] = value;
}
configuration.AddInMemoryCollection(values);
});
}
private static DirectoryInfo FindRepositoryRoot()
{
DirectoryInfo? current = new(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "global.json"))
&& (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|| File.Exists(Path.Combine(current.FullName, "PUBLIC_SOURCE_EXPORT.md"))))
{
return current;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate the repository root.");
}
}
@@ -0,0 +1,47 @@
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
/// <summary>
/// Base for test classes that need an already-completed first-run setup (an owner
/// account, in an isolated database, ready to log in). Each test class gets its own
/// database and its own owner — never a shared mutable fixture between test classes.
/// </summary>
public abstract class OwnerSetupTestBase : IAsyncLifetime
{
protected OwnerSetupTestBase(
PostgresContainerFixture postgres,
IReadOnlyDictionary<string, string?>? additionalConfiguration = null)
{
Factory = new KnowledgeHubIntegrationFactory(postgres, additionalConfiguration);
}
protected KnowledgeHubIntegrationFactory Factory { get; }
protected string OwnerEmail { get; } = "eigenaar@itworx.test";
protected string OwnerPassword { get; } = "correct-horse-battery-staple";
public async Task InitializeAsync()
{
await ((IAsyncLifetime)Factory).InitializeAsync();
using var client = Factory.CreateClient();
var response = await SetupHelper.CompleteFirstRunSetupAsync(
client,
"ItWorx Testomgeving",
"Eigenaar Testpersoon",
OwnerEmail,
OwnerPassword);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"First-run setup failed ({response.StatusCode}): {body}");
}
}
public async Task DisposeAsync()
{
await ((IAsyncLifetime)Factory).DisposeAsync();
}
}
@@ -0,0 +1,59 @@
using Npgsql;
using Testcontainers.PostgreSql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
/// <summary>
/// One PostgreSQL Testcontainer for the whole assembly run (docs/22-testing-strategy.md
/// — "One PostgreSQL Testcontainer per test collection/suite"). Individual test classes
/// each get an isolated logical database on this shared server instance via
/// <see cref="KnowledgeHubIntegrationFactory"/>, so tests never share mutable rows.
/// </summary>
public sealed class PostgresContainerFixture : IAsyncLifetime
{
private const string SharedNetworkAlias = "knowledge-hub-postgres";
private static readonly string? SharedNetworkName =
Environment.GetEnvironmentVariable("TESTCONTAINERS_NETWORK");
// Pinned to the same PostgreSQL major/minor family as production
// (docker-compose.yml / docs/23-deployment-unraid.md).
private readonly PostgreSqlContainer _container = BuildContainer();
public string Host => UsesSharedNetwork ? SharedNetworkAlias : _container.Hostname;
public int Port => UsesSharedNetwork ? 5432 : _container.GetMappedPublicPort(5432);
public string AdminConnectionString => new NpgsqlConnectionStringBuilder(_container.GetConnectionString())
{
Host = Host,
Port = Port
}.ConnectionString;
public static string Username => "knowledge_hub";
public static string Password => "integration-test-only";
public Task InitializeAsync() => _container.StartAsync();
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
private static bool UsesSharedNetwork => !string.IsNullOrWhiteSpace(SharedNetworkName);
private static PostgreSqlContainer BuildContainer()
{
var builder = new PostgreSqlBuilder(
"postgres:18.4-bookworm@sha256:882236b897e39051d2368c5ccc6cda944904723506b2dfc97f2a8f5bc9afa382")
.WithDatabase("knowledge_hub_admin")
.WithUsername("knowledge_hub")
.WithPassword("integration-test-only");
if (UsesSharedNetwork)
{
builder = builder
.WithNetwork(SharedNetworkName!)
.WithNetworkAliases(SharedNetworkAlias);
}
return builder.Build();
}
}
@@ -0,0 +1,7 @@
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
[CollectionDefinition(Name)]
public sealed class PostgresTestGroup : ICollectionFixture<PostgresContainerFixture>
{
public const string Name = "Postgres";
}
@@ -0,0 +1,156 @@
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Setup;
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
public sealed record CsrfToken(string Value, string HeaderName);
/// <summary>Shared helpers so every test class does not re-implement the first-run
/// setup and CSRF handshake used to reach an authenticated state.</summary>
public static class SetupHelper
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static async Task<CsrfToken> GetCsrfTokenAsync(HttpClient client)
{
var response = await client.GetFromJsonAsync<CsrfTokenResponse>("/api/v1/auth/csrf", JsonOptions)
?? throw new InvalidOperationException("No CSRF token returned.");
return new CsrfToken(response.Token, response.HeaderName);
}
public static async Task<HttpResponseMessage> CompleteFirstRunSetupAsync(
HttpClient client,
string workspaceName,
string displayName,
string email,
string password)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/setup/complete")
{
Content = JsonContent.Create(
new CompleteSetupRequest(workspaceName, displayName, email, password),
options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> LoginAsync(HttpClient client, string email, string password)
{
return await client.PostAsJsonAsync(
"/api/v1/auth/login",
new LoginRequest(email, password),
JsonOptions);
}
public static async Task<HttpResponseMessage> PostWithCsrfAsync<TBody>(
HttpClient client,
string path,
TBody body)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = JsonContent.Create(body, options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> PatchWithCsrfAsync<TBody>(
HttpClient client,
string path,
TBody body)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Patch, path)
{
Content = JsonContent.Create(body, options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> PutWithCsrfAsync<TBody>(
HttpClient client,
string path,
TBody body,
string? ifMatch = null)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Put, path)
{
Content = JsonContent.Create(body, options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
if (ifMatch is not null)
{
request.Headers.Add("If-Match", $"\"{ifMatch}\"");
}
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> PostWithCsrfAndIfMatchAsync<TBody>(
HttpClient client,
string path,
TBody body,
string ifMatch)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = JsonContent.Create(body, options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("If-Match", $"\"{ifMatch}\"");
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> DeleteWithCsrfAsync(HttpClient client, string path)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
public static string? GetETag(HttpResponseMessage response) =>
response.Headers.ETag?.Tag.Trim('"');
public static async Task<HttpResponseMessage> PostMultipartWithCsrfAsync(
HttpClient client,
string path,
byte[] fileBytes,
string fileName,
string contentType)
{
var csrf = await GetCsrfTokenAsync(client);
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
content.Add(fileContent, "file", fileName);
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content };
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
}
@@ -0,0 +1,249 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Domain.Projects;
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.InfrastructureKnowledge;
/// <summary>
/// Server-side validation for infrastructure editorial profiles
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H2): invalid
/// input must always come back as a specific RFC 7807 422 with a Dutch, actionable
/// message — never a database exception, never a silent 500.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class EditorialProfileValidationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<(HttpClient Client, Guid ServiceId)> AuthenticatedClientWithServiceAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", MinimalSnapshot($"hash-{Guid.NewGuid():N}"));
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
var service = Assert.Single(services!.Items);
return (client, service.Id);
}
private static UpdateEditorialProfileRequest ValidRequest(
string? displayName = "Naam", string? summary = "Samenvatting", string? category = "Other",
string? repositoryUrl = null, string? liveUrl = null, string? backupStatus = null, string? monitoringUrl = null,
Guid? ownerId = null, Guid? projectId = null, IReadOnlyList<string>? tags = null) =>
new(displayName, summary, category, "Doel", ownerId, projectId, repositoryUrl, liveUrl, backupStatus,
monitoringUrl, null, null, null, null, tags ?? [], false, false, false);
[Fact]
public async Task A_display_name_over_the_maximum_length_is_rejected_as_422_not_a_database_error()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var tooLong = new string('a', 301);
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(displayName: tooLong), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(ProblemTypes.Validation, problem!.Type);
}
[Theory]
[InlineData("javascript:alert(1)")]
[InlineData("data:text/html,<script>alert(1)</script>")]
[InlineData("file:///etc/passwd")]
[InlineData("ftp://example.test/repo")]
[InlineData("not a url")]
public async Task An_unsafe_or_malformed_repository_url_scheme_is_rejected(string unsafeUrl)
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(repositoryUrl: unsafeUrl), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_plain_http_and_https_repository_url_is_accepted()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(repositoryUrl: "https://example.test/repo", liveUrl: "http://192.0.2.10:1235"), "0");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task An_unknown_category_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(category: "NietBestaandeCategorie"), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_unknown_backup_status_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(backupStatus: "OngeldigeStatus"), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Theory]
[InlineData("Unknown")]
[InlineData("Manual")]
[InlineData("Automated")]
[InlineData("AutomatedTested")]
[InlineData("None")]
public async Task Every_allowed_backup_status_value_is_accepted(string backupStatus)
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(backupStatus: backupStatus), "0");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task More_than_the_maximum_number_of_tags_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var tooManyTags = Enumerable.Range(0, 26).Select(i => $"label-{i}").ToArray();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(tags: tooManyTags), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Duplicate_tags_are_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(tags: ["productie", "Productie"]), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_owner_id_that_is_not_a_workspace_member_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(ownerId: Guid.NewGuid()), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_owner_id_that_is_a_real_workspace_member_is_accepted()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var createUser = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/users", new CreateUserRequest("Eigenaar Kandidaat", "eigenaar-kandidaat@itworx.test", "owner-strong-password", "Editor"));
Assert.Equal(HttpStatusCode.OK, createUser.StatusCode);
var user = await createUser.Content.ReadFromJsonAsync<UserSummaryResponse>();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(ownerId: user!.Id), "0");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task A_project_id_that_does_not_exist_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(projectId: Guid.NewGuid()), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_project_id_belonging_to_a_different_workspace_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
// Cross-workspace linkage is not reachable through this single-workspace-per-
// installation product's ordinary HTTP surface (there is no "create a second
// workspace" endpoint) — seeded directly, matching this test suite's
// established pattern for otherwise-unreachable setup states.
Guid foreignProjectId;
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var foreignWorkspace = Workspace.Create(WorkspaceName.Create("Andere workspace"), DateTimeOffset.UtcNow);
db.Workspaces.Add(foreignWorkspace);
var project = Project.Create(
foreignWorkspace.Id, "Project in andere workspace", "S.", ProjectPriority.Medium, "Start.",
Guid.NewGuid(), Guid.NewGuid(), DateTimeOffset.UtcNow);
db.Projects.Add(project);
await db.SaveChangesAsync(CancellationToken.None);
foreignProjectId = project.Id;
}
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(projectId: foreignProjectId), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_project_id_belonging_to_this_workspace_is_accepted()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var createUser = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/users", new CreateUserRequest("Projecteigenaar", "project-eigenaar@itworx.test", "owner-strong-password", "Editor"));
var user = await createUser.Content.ReadFromJsonAsync<UserSummaryResponse>();
var createProject = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects", new CreateProjectRequest("Gekoppeld project", "S.", "Medium", "Start.", user!.Id));
Assert.Equal(HttpStatusCode.OK, createProject.StatusCode);
var project = await createProject.Content.ReadFromJsonAsync<ProjectDetail>();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(projectId: project!.Id), "0");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
private static SynchronizeInventoryRequest MinimalSnapshot(string hash) =>
new(
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
[new InventoryServiceRequest(
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", "running",
"Healthy", "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
[], [], [], [], [], [], [], [], [])]);
}
@@ -0,0 +1,174 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>
/// V2-R5 (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 6): the manual-
/// override mechanism was already fully generic at the use-case/repository layer
/// (<c>SetManualOverrideUseCase</c>, <c>IInfrastructureRepository.TargetExistsAsync</c>)
/// — only the field catalog (previously an explicit, deliberately empty stub for
/// every kind but Service) and a reachable endpoint were missing. Proves the field
/// catalog is now real for every non-Service kind the domain models, and that the new
/// generic <c>POST /infrastructure/overrides</c> endpoint actually works end to end
/// for several of them.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class ExpandedManualOverrideCatalogTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Theory]
[InlineData("Host", "hostname")]
[InlineData("Component", "name")]
[InlineData("Endpoint", "scheme")]
[InlineData("Network", "name")]
[InlineData("Volume", "name")]
[InlineData("Database", "name")]
[InlineData("Deployment", "repositoryUrl")]
[InlineData("Backup", "method")]
public async Task Every_non_service_object_kind_now_has_a_real_non_empty_override_catalog(string targetKind, string expectedField)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var catalog = await client.GetFromJsonAsync<ManualOverrideFieldCatalogResponse>(
$"/api/v1/infrastructure/overrides/catalog?targetKind={targetKind}");
Assert.NotNull(catalog);
Assert.NotEmpty(catalog.Fields);
Assert.Contains(catalog.Fields, field => field.FieldName == expectedField);
}
[Fact]
public async Task A_host_fact_can_be_corrected_through_the_generic_endpoint()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (hostId, _, _, _, _, _, _, _) = await SeedFullServiceGraphAsync(client);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest("Host", hostId, "hostname", JsonSerializer.SerializeToElement("gecorrigeerde-hostnaam"), "Reden."));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Theory]
[InlineData("Network", "internal")]
[InlineData("Volume", "readOnly")]
public async Task A_boolean_shaped_fact_is_overridable_as_a_true_false_enum(string targetKind, string fieldName)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (_, _, networkId, volumeId, _, _, _, _) = await SeedFullServiceGraphAsync(client);
var targetId = targetKind == "Network" ? networkId : volumeId;
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest(targetKind, targetId, fieldName, JsonSerializer.SerializeToElement("true"), "Reden."));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var invalid = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest(targetKind, targetId, fieldName, JsonSerializer.SerializeToElement("misschien"), "Ongeldig."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, invalid.StatusCode);
}
[Fact]
public async Task An_unknown_target_kind_string_is_rejected_not_a_crash()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (hostId, _, _, _, _, _, _, _) = await SeedFullServiceGraphAsync(client);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest("NietBestaandType", hostId, "naam", JsonSerializer.SerializeToElement("x"), null));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_target_id_that_does_not_exist_for_the_given_kind_is_not_found()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SeedFullServiceGraphAsync(client);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest("Host", Guid.NewGuid(), "hostname", JsonSerializer.SerializeToElement("x"), null));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task An_override_set_through_the_generic_endpoint_can_be_removed_through_the_existing_generic_delete()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (hostId, _, _, _, _, _, _, _) = await SeedFullServiceGraphAsync(client);
var setResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest("Host", hostId, "operatingSystem", JsonSerializer.SerializeToElement("Custom OS"), "Reden."));
Assert.Equal(HttpStatusCode.OK, setResponse.StatusCode);
var created = await setResponse.Content.ReadFromJsonAsync<JsonElement>();
var overrideId = created.GetProperty("id").GetGuid();
var removeResponse = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/infrastructure/overrides/{overrideId}");
Assert.Equal(HttpStatusCode.NoContent, removeResponse.StatusCode);
}
/// <summary>Seeds one service with one of every nested fact kind via a single real
/// inventory sync, then resolves each fact's real generated ID through the normal
/// service-detail endpoint — never a raw SQL insert, so this also incidentally
/// re-proves the sync path itself creates every fact kind correctly.</summary>
private static async Task<(Guid HostId, Guid ServiceId, Guid NetworkId, Guid VolumeId, Guid DatabaseId, Guid EndpointId, Guid DeploymentId, Guid BackupId)>
SeedFullServiceGraphAsync(HttpClient client)
{
var hash = $"hash-{Guid.NewGuid():N}";
var request = new SynchronizeInventoryRequest(
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
[new InventoryServiceRequest(
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", "running",
"Healthy", "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
[new InventoryComponentRequest("component:app", "app", "container", "rt-1", "running", "itworx/app:v2", "High")],
[new InventoryEndpointRequest("endpoint:main", "https", "wiki.example.com", 1235, 8080, "https://wiki.example.com", true, "High")],
[new InventoryNetworkRequest("network:main", "knowledge-net", "bridge", "local", false, "High")],
[new InventoryVolumeRequest("volume:data", "knowledge-data", "/data", "local", false, 1024, "High")],
[new InventoryDatabaseRequest("database:main", "knowledge_hub", "postgresql", "18", "db", 5432, false, "High")],
[],
[],
[new InventoryDeploymentRequest("deployment:main", "https://gitea.example.com/itworx/knowledge-hub", "abc123", "itworx/knowledge-hub:v2", "sha256:example", DateTimeOffset.UtcNow, "High")],
[new InventoryBackupRequest("backup:main", "pg_dump", "daily", DateTimeOffset.UtcNow.AddDays(-1), true, "High")])]);
var syncResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/infrastructure/inventory/snapshots", request);
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
var overview = await client.GetFromJsonAsync<InfrastructureOverviewResponse>("/api/v1/infrastructure/overview");
var hostId = Assert.Single(overview!.Hosts).Id;
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>("/api/v1/infrastructure/services?includeHidden=true");
var serviceId = Assert.Single(services!.Items).Id;
var detail = await client.GetFromJsonAsync<InfrastructureServiceDetail>($"/api/v1/infrastructure/services/{serviceId}");
Assert.NotNull(detail);
return (
hostId,
serviceId,
Assert.Single(detail.Networks).Id,
Assert.Single(detail.Volumes).Id,
Assert.Single(detail.Databases).Id,
Assert.Single(detail.Endpoints).Id,
Assert.Single(detail.Deployments).Id,
Assert.Single(detail.Backups).Id);
}
}
@@ -0,0 +1,258 @@
using System.Diagnostics;
using System.Globalization;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>
/// The full-scale proof for infrastructure tables that
/// <c>docs/v2/V2_ACCEPTANCE_MATRIX.md</c>'s "Search/query-plan proof at scale extended
/// to infrastructure tables" row (previously `Planned`) asked for — the infrastructure
/// equivalent of <c>SearchFullScaleTests</c> (V1 M8), closing V2-R4's open release
/// risk 4 with real evidence rather than only the lighter-weight, routine-suite
/// <c>InfrastructureCatalogScaleTests</c> (V2-R2, 3,000 services). Tagged
/// "ReleaseScale" and excluded from the routine test run (matching
/// <c>SearchFullScaleTests</c>'s own precedent) — seeding and measuring at this scale
/// is a deliberate, occasional release-gate exercise. Run explicitly via:
/// <c>dotnet test --filter "Category=ReleaseScale"</c>.
/// </summary>
[Collection(PostgresTestGroup.Name)]
[Trait("Category", "ReleaseScale")]
public sealed class InfrastructureCatalogReleaseScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int ServiceCount = 50_000;
private const int SampleCount = 30;
[Fact]
public async Task Infrastructure_catalog_reads_stay_correct_and_bounded_at_50000_services()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var report = new StringBuilder();
report.AppendLine(CultureInfo.InvariantCulture, $"V2-R4 infrastructure-catalog release-scale report — {DateTimeOffset.UtcNow:O}");
report.AppendLine(CultureInfo.InvariantCulture, $"Target: {ServiceCount:N0} services (docs/v2/V2_ACCEPTANCE_MATRIX.md, previously 'Planned').");
Guid workspaceId;
var seedStopwatch = Stopwatch.StartNew();
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
workspaceId = workspace.Id;
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
await SeedServicesAsync(connection, workspaceId);
await SeedFactsAsync(connection, workspaceId);
await PrepareReadMeasurementAsync(connection);
}
seedStopwatch.Stop();
report.AppendLine(CultureInfo.InvariantCulture, $"Seed time: {seedStopwatch.Elapsed}.");
using (var scope = Factory.Services.CreateScope())
{
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
await using var countCommand = new NpgsqlCommand(
"SELECT (SELECT count(*) FROM infrastructure_services), (SELECT count(*) FROM service_components)",
connection)
{
CommandTimeout = 300
};
await using var reader = await countCommand.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync(), "Release-scale row-count query returned no row.");
var actualServices = reader.GetInt64(0);
var actualComponents = reader.GetInt64(1);
report.AppendLine(CultureInfo.InvariantCulture, $"Actual seeded rows: infrastructure_services={actualServices:N0}, service_components={actualComponents:N0}.");
Assert.True(actualServices >= ServiceCount, $"Expected at least {ServiceCount:N0} services, found {actualServices:N0}.");
}
// --- correctness: bounded, correctly-cursored pagination still holds at this scale ---
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>("/api/v1/infrastructure/services?limit=50");
Assert.NotNull(firstPage);
Assert.Equal(50, firstPage.Items.Count);
Assert.False(string.IsNullOrEmpty(firstPage.NextCursor));
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
$"/api/v1/infrastructure/services?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
Assert.NotNull(secondPage);
Assert.Equal(50, secondPage.Items.Count);
Assert.Empty(firstPage.Items.Select(item => item.Id).Intersect(secondPage.Items.Select(item => item.Id)));
report.AppendLine("Correctness: bounded first/second page, disjoint, correct size. PASS.");
// --- latency: p50/p95/p99 for the catalog's first page and a deep-cursor page ---
report.AppendLine();
report.AppendLine("Latency (ms), 30 samples per query shape:");
var measurements = new[]
{
await MeasureAsync(client, report, "first page (limit=50)", "/api/v1/infrastructure/services?limit=50"),
await MeasureAsync(client, report, "deep cursor page (after page 1)", $"/api/v1/infrastructure/services?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}")
};
// --- query plan + index sizes ---
using (var scope = Factory.Services.CreateScope())
{
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the catalog's first-page query:");
report.AppendLine(await ExplainCatalogPageAsync(connection, workspaceId));
report.AppendLine();
report.AppendLine("Index sizes:");
report.AppendLine(await IndexSizesAsync(connection, "infrastructure_services"));
}
var reportPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "artifacts", "v2-r4-infrastructure-scale-report.txt");
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
await File.WriteAllTextAsync(reportPath, report.ToString());
foreach (var measurement in measurements)
{
// Generous release-gate bound, matching SearchFullScaleTests' own — proving
// the paginated, counts-only query stays sub-second at true release scale,
// not a tight micro-benchmark.
Assert.True(
measurement.P95 < 1000,
$"{measurement.Label}: p95 was {measurement.P95:F1}ms, expected well under 1000ms at {ServiceCount:N0}-service scale.");
}
}
private static async Task SeedServicesAsync(NpgsqlConnection connection, Guid workspaceId)
{
const string sql = """
INSERT INTO infrastructure_services (
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
SELECT
gen_random_uuid(), @workspaceId, NULL,
'docker:release-scale-service-' || n, 'release-scale-service-' || lpad(n::text, 6, '0'),
'container', 'running', 1, 'itworx/release-scale:' || n, '', '', false, false, false,
1, 2, gen_random_uuid(), now(), now()
FROM generate_series(1, @serviceCount) AS n
""";
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("serviceCount", ServiceCount);
await command.ExecuteNonQueryAsync();
}
private static async Task SeedFactsAsync(NpgsqlConnection connection, Guid workspaceId)
{
const string sql = """
INSERT INTO service_components (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, kind, runtime_id, status, image_reference)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'container:' || c.n, 2, gen_random_uuid(), now(),
'component-' || c.n, 'container', 'rt-' || c.n, 'running', ''
FROM infrastructure_services s
CROSS JOIN generate_series(1, 2) AS c(n)
WHERE s.workspace_id = @workspaceId
""";
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
command.Parameters.AddWithValue("workspaceId", workspaceId);
await command.ExecuteNonQueryAsync();
}
private static async Task PrepareReadMeasurementAsync(NpgsqlConnection connection)
{
foreach (var statement in new[]
{
"VACUUM (ANALYZE) infrastructure_services;",
"VACUUM (ANALYZE) service_components;",
"CHECKPOINT;"
})
{
await using var command = new NpgsqlCommand(statement, connection) { CommandTimeout = 300 };
await command.ExecuteNonQueryAsync();
}
}
private static async Task<LatencyMeasurement> MeasureAsync(HttpClient client, StringBuilder report, string label, string path)
{
var samples = new List<double>(SampleCount);
for (var i = 0; i < SampleCount; i++)
{
var stopwatch = Stopwatch.StartNew();
using var response = await client.GetAsync(path);
stopwatch.Stop();
response.EnsureSuccessStatusCode();
samples.Add(stopwatch.Elapsed.TotalMilliseconds);
}
samples.Sort();
var p50 = Percentile(samples, 0.50);
var p95 = Percentile(samples, 0.95);
var p99 = Percentile(samples, 0.99);
report.AppendLine(CultureInfo.InvariantCulture, $" {label}: p50={p50:F1}ms p95={p95:F1}ms p99={p99:F1}ms (min={samples[0]:F1}ms max={samples[^1]:F1}ms)");
return new LatencyMeasurement(label, p95);
}
private static double Percentile(IReadOnlyList<double> sortedSamples, double percentile)
{
var index = (int)Math.Ceiling(percentile * sortedSamples.Count) - 1;
return sortedSamples[Math.Clamp(index, 0, sortedSamples.Count - 1)];
}
private static async Task<string> ExplainCatalogPageAsync(NpgsqlConnection connection, Guid workspaceId)
{
const string sql = """
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, detected_name
FROM infrastructure_services
WHERE workspace_id = @workspaceId
ORDER BY detected_name, id
LIMIT 51
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("workspaceId", workspaceId);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(" " + reader.GetString(0));
}
return lines.ToString();
}
private static async Task<string> IndexSizesAsync(NpgsqlConnection connection, string tableName)
{
const string sql = """
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass))
FROM pg_indexes
WHERE tablename = @tableName
ORDER BY indexname
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tableName", tableName);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(CultureInfo.InvariantCulture, $" {reader.GetString(0)}: {reader.GetString(1)}");
}
return lines.ToString();
}
private sealed record LatencyMeasurement(string Label, double P95);
}
@@ -0,0 +1,173 @@
using System.Diagnostics;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>A large workspace's service catalog stays bounded and fast to page through
/// (V2-R2, docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md, Finding
/// A): before this milestone, listing services always loaded all nine related fact
/// tables for every service in the workspace, unpaginated. This proves the catalog
/// endpoint now serves one bounded, correctly-cursored page regardless of how many
/// services (and related facts) exist in total — mirroring
/// <c>PageTreeScaleTests</c>'s established "bounded and fast, not a precise
/// benchmark" pattern for the knowledge tree.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class InfrastructureCatalogScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int ServiceCount = 3_000;
[Fact]
public async Task Fetching_one_page_of_a_3000_service_catalog_stays_fast_and_bounded()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var workspaceId = await SeedServicesDirectlyAsync();
var stopwatch = Stopwatch.StartNew();
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?limit=50");
stopwatch.Stop();
Assert.NotNull(firstPage);
Assert.Equal(50, firstPage.Items.Count);
Assert.False(string.IsNullOrEmpty(firstPage.NextCursor));
// Every summary row must still carry the fact counts the catalog card renders,
// even though the query behind it now fetches only counts, not full related rows.
Assert.All(firstPage.Items, item =>
{
Assert.Equal(2, item.ComponentCount);
Assert.Equal(1, item.EndpointCount);
Assert.Equal(1, item.VolumeCount);
Assert.Equal(1, item.DatabaseCount);
});
// Generous bound for a CI/dev machine: the point is "bounded and fast",
// regardless of the {ServiceCount} total services and their ~5x related facts.
Assert.True(
stopwatch.Elapsed < TimeSpan.FromSeconds(5),
$"Fetching one bounded page of services took {stopwatch.Elapsed}, expected well under 5s at {ServiceCount} total services.");
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
$"/api/v1/infrastructure/services?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
Assert.NotNull(secondPage);
Assert.Equal(50, secondPage.Items.Count);
Assert.Empty(firstPage.Items.Select(item => item.Id).Intersect(secondPage.Items.Select(item => item.Id)));
_ = workspaceId;
}
private async Task<Guid> SeedServicesDirectlyAsync()
{
// Seeded through a handful of server-side INSERT...SELECT statements (one round
// trip per table, not one per row or per fact) — the same generate_series
// pattern SearchFullScaleTests already established for bulk-seeding at scale.
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
var workspaceId = workspace.Id;
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
var snapshotId = Guid.NewGuid();
const string servicesSql = """
INSERT INTO infrastructure_services (
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
SELECT
gen_random_uuid(), @workspaceId, NULL,
'docker:schaal-service-' || n, 'schaal-service-' || lpad(n::text, 5, '0'),
'container', 'running', 1, 'itworx/schaal:' || n, '', '', false, false, false,
1, 2, @snapshotId, now(), now()
FROM generate_series(1, @serviceCount) AS n
""";
await using (var command = new NpgsqlCommand(servicesSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
command.Parameters.AddWithValue("serviceCount", ServiceCount);
await command.ExecuteNonQueryAsync();
}
const string componentsSql = """
INSERT INTO service_components (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, kind, runtime_id, status, image_reference)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'container:' || c.n, 2, @snapshotId, now(),
'component-' || c.n, 'container', 'rt-' || c.n, 'running', ''
FROM infrastructure_services s
CROSS JOIN generate_series(1, 2) AS c(n)
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(componentsSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
const string endpointsSql = """
INSERT INTO infrastructure_endpoints (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
scheme, host, published_port, target_port, url, is_public)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'port:main', 2, @snapshotId, now(),
'http', '', NULL, NULL, '', false
FROM infrastructure_services s
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(endpointsSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
const string volumesSql = """
INSERT INTO infrastructure_volumes (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, mount_path, storage_class, read_only, size_bytes)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'volume:main', 2, @snapshotId, now(),
'data', '/data', '', false, NULL
FROM infrastructure_services s
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(volumesSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
const string databasesSql = """
INSERT INTO infrastructure_databases (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, engine, version, host, port, host_port_published)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'database:main', 2, @snapshotId, now(),
'main', 'postgresql', '17', '', NULL, false
FROM infrastructure_services s
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(databasesSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
return workspaceId;
}
}
@@ -0,0 +1,248 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>
/// V2-R5 (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 4): `GET
/// /infrastructure/storage` was a fully unbounded, unpaginated listing, and
/// `GET /infrastructure/services` had no server-side project filter — the
/// Project workspace fetched up to 200 services and filtered by project client-side.
/// Proves the fix: real server-side cursor pagination (no duplicates, no gaps, stable
/// across pages), a server-side text search, and a server-side project filter.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class InfrastructurePaginationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int VolumeCount = 130;
[Fact]
public async Task Storage_pages_through_every_volume_exactly_once_with_no_duplicates_or_gaps()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SeedVolumesDirectlyAsync();
var seenIds = new HashSet<Guid>();
string? cursor = null;
var pageCount = 0;
do
{
var url = "/api/v1/infrastructure/storage?limit=20" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
var page = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(url);
Assert.NotNull(page);
Assert.True(page.Items.Count <= 20);
foreach (var item in page.Items)
{
Assert.True(seenIds.Add(item.Id), $"Volume {item.Id} appeared on more than one page.");
}
cursor = page.NextCursor;
pageCount++;
Assert.True(pageCount < 50, "Too many pages — likely a pagination bug (cursor never advances / never terminates).");
}
while (cursor is not null);
Assert.Equal(VolumeCount, seenIds.Count);
Assert.True(pageCount > 1, "Expected more than one page at this seeded volume count.");
}
[Fact]
public async Task Storage_search_filters_server_side_not_only_within_the_first_loaded_page()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SeedVolumesDirectlyAsync();
// "schaal-service-00099" (a service near the end of the seeded range, unlikely
// to appear on a single default-sized first page) must still be found — proving
// the search runs server-side across the whole table, not client-side over
// whatever page happened to load first.
var result = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(
"/api/v1/infrastructure/storage?search=schaal-service-00099&limit=20");
Assert.NotNull(result);
Assert.Single(result.Items);
Assert.Contains("schaal-service-00099", result.Items[0].ServiceName);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task Services_search_filters_server_side_not_only_within_the_first_loaded_page()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SeedVolumesDirectlyAsync();
var result = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true&search=schaal-service-00099&limit=20");
Assert.NotNull(result);
Assert.Single(result.Items);
Assert.Contains("schaal-service-00099", result.Items[0].Name);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task Services_can_be_filtered_by_project_entirely_server_side()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (workspaceId, allServiceIds) = await SeedVolumesDirectlyAsync();
var session = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(session?.UserId);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects",
new CreateProjectRequest(
$"Paginatie-project {Guid.NewGuid():N}", "Testproject voor paginatiedekking.", "Medium", "# Status", session.UserId.Value));
Assert.Equal(System.Net.HttpStatusCode.OK, projectResponse.StatusCode);
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var projectId = project.Id;
// Link exactly 3 of the 130 seeded services to this project through the real
// editorial-profile endpoint — proving the filter is a real server-side WHERE
// against genuine domain state, not a client-side slice of an unfiltered page.
var linkedIds = allServiceIds.Take(3).ToArray();
foreach (var serviceId in linkedIds)
{
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
new { isFavorite = false, isHidden = false, isArchived = false, projectId }, "0");
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
}
_ = workspaceId;
var filtered = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
$"/api/v1/infrastructure/services?includeHidden=true&projectId={projectId}&limit=200");
Assert.NotNull(filtered);
Assert.Equal(3, filtered.Items.Count);
Assert.Equal(linkedIds.ToHashSet(), filtered.Items.Select(item => item.Id).ToHashSet());
}
[Fact]
public async Task Services_status_filter_is_applied_before_cursor_pagination()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (_, serviceIds) = await SeedVolumesDirectlyAsync();
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}");
}
// The unhealthy service is deliberately the final seeded row. A server-side
// WHERE is required: a local filter over a first page could never find it.
var result = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=20");
Assert.NotNull(result);
Assert.Single(result.Items);
Assert.Equal(serviceIds[^1], result.Items[0].Id);
}
[Fact]
public async Task Services_report_the_filtered_total_not_the_size_of_the_returned_page()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (_, serviceIds) = await SeedVolumesDirectlyAsync();
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}");
}
// A deliberately small page over a deliberately large catalog: the operator
// must be told how many services exist behind the filter, not how many fitted
// on the first page.
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true&limit=10");
Assert.NotNull(firstPage);
Assert.Equal(10, firstPage.Items.Count);
Assert.NotNull(firstPage.NextCursor);
Assert.Equal(serviceIds.Count, firstPage.TotalCount);
// The total tracks the filter, and stays stable while paging through it.
var attention = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=10");
Assert.NotNull(attention);
Assert.Equal(1, attention.TotalCount);
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
$"/api/v1/infrastructure/services?includeHidden=true&limit=10&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
Assert.NotNull(secondPage);
Assert.Equal(serviceIds.Count, secondPage.TotalCount);
}
private async Task<(Guid WorkspaceId, IReadOnlyList<Guid> ServiceIds)> SeedVolumesDirectlyAsync()
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
var workspaceId = workspace.Id;
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
var snapshotId = Guid.NewGuid();
const string servicesSql = """
INSERT INTO infrastructure_services (
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
SELECT
gen_random_uuid(), @workspaceId, NULL,
'docker:schaal-service-' || n, 'schaal-service-' || lpad(n::text, 5, '0'),
'container', 'running', 1, 'itworx/schaal:' || n, '', '', false, false, false,
1, 2, @snapshotId, now(), now()
FROM generate_series(1, @count) AS n
RETURNING id
""";
var serviceIds = new List<Guid>();
await using (var command = new NpgsqlCommand(servicesSql, connection) { CommandTimeout = 60 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
command.Parameters.AddWithValue("count", VolumeCount);
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
serviceIds.Add(reader.GetGuid(0));
}
}
const string volumesSql = """
INSERT INTO infrastructure_volumes (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, mount_path, storage_class, read_only, size_bytes)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'volume:main', 2, @snapshotId, now(),
'data', '/data', '', false, NULL
FROM infrastructure_services s
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(volumesSql, connection) { CommandTimeout = 60 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
return (workspaceId, serviceIds);
}
}
@@ -0,0 +1,514 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
[Collection(PostgresTestGroup.Name)]
public sealed class InfrastructureSynchronizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Inventory_facts_are_typed_and_editorial_knowledge_survives_later_snapshots()
{
using var client = await AuthenticatedClientAsync();
var firstSyncResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-001", includeService: true, "running"));
Assert.Equal(HttpStatusCode.OK, firstSyncResponse.StatusCode);
var firstSync = await firstSyncResponse.Content.ReadFromJsonAsync<InventorySyncResponse>();
Assert.NotNull(firstSync);
Assert.Equal(1, firstSync.AddedServices);
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
var service = Assert.Single(services!.Items);
var detailResponse = await client.GetAsync($"/api/v1/infrastructure/services/{service.Id}");
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
Assert.Equal("0", SetupHelper.GetETag(detailResponse));
var detail = await detailResponse.Content.ReadFromJsonAsync<InfrastructureServiceDetail>();
Assert.NotNull(detail);
Assert.Single(detail.Components);
Assert.Single(detail.Endpoints);
Assert.Single(detail.Volumes);
Assert.Single(detail.Databases);
var storage = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(
"/api/v1/infrastructure/storage");
var storageRow = Assert.Single(storage!.Items);
Assert.Equal(service.Id, storageRow.ServiceId);
Assert.Equal(service.Name, storageRow.ServiceName);
Assert.Equal("/app/attachments", storageRow.MountPath);
var profileResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/infrastructure/services/{service.Id}/editorial-profile",
new UpdateEditorialProfileRequest(
"ItWorx Knowledge Hub", "Centrale technische kennisomgeving.", "Knowledge", "Documentatie",
null, null, "https://example.test/repository", "http://192.0.2.10:1235", "AutomatedTested",
string.Empty, "Onderhoudsnotitie", "Capaciteitsrisico", "Pull en deploy", "Herstel vorig image",
["knowledge", "production"], true, false, false),
"0");
Assert.Equal(HttpStatusCode.OK, profileResponse.StatusCode);
var profileETag = SetupHelper.GetETag(profileResponse);
Assert.False(string.IsNullOrWhiteSpace(profileETag));
// "detectedName" is a real InfrastructureService inventory fact and therefore a
// legitimate ManualOverrideCatalog field (docs/adr/ADR-025-v2-r1-data-integrity-
// remediation.md, audit finding H3) — "category" (used here before H3) was never
// actually one: it is an EditorialProfile field with its own dedicated write
// path (the editorial-profile PUT above), and the catalog now correctly rejects
// it as an override field name.
var overrideResponse = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/infrastructure/services/{service.Id}/overrides",
new SetManualOverrideRequest(
"detectedName", JsonSerializer.Deserialize<JsonElement>("\"itworx-knowledge-hub (gecorrigeerd)\""), "Automatisch gedetecteerde naam was onduidelijk."));
Assert.Equal(HttpStatusCode.OK, overrideResponse.StatusCode);
var secondSyncResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-002", includeService: true, "healthy"));
Assert.Equal(HttpStatusCode.OK, secondSyncResponse.StatusCode);
var afterSync = await client.GetFromJsonAsync<InfrastructureServiceDetail>(
$"/api/v1/infrastructure/services/{service.Id}");
Assert.NotNull(afterSync);
Assert.Equal("ItWorx Knowledge Hub", afterSync.EditorialProfile!.DisplayName);
Assert.Equal("Centrale technische kennisomgeving.", afterSync.EditorialProfile.Summary);
Assert.Single(afterSync.Overrides);
Assert.Equal("healthy", afterSync.Service.RuntimeStatus);
var changes = await client.GetFromJsonAsync<IReadOnlyList<InventoryChangeResponse>>(
"/api/v1/infrastructure/changes?limit=100");
var runtimeChange = Assert.Single(changes!, change =>
change.TargetKind == "Service" && change.ChangeKind == "Updated"
&& change.ChangedFields.Contains("runtimeStatus"));
Assert.Equal(service.Id, runtimeChange.TargetId);
Assert.Equal("docker:knowledge-hub", runtimeChange.SourceKey);
}
[Fact]
public async Task A_disappeared_service_is_marked_missing_and_an_identical_snapshot_is_idempotent()
{
using var client = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-missing-1", includeService: true, "running"));
var missingResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-missing-2", includeService: false, "stopped"));
var missing = await missingResponse.Content.ReadFromJsonAsync<InventorySyncResponse>();
Assert.NotNull(missing);
Assert.Equal(1, missing.MissingServices);
var retryResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-missing-2", includeService: false, "stopped"));
var retry = await retryResponse.Content.ReadFromJsonAsync<InventorySyncResponse>();
Assert.True(retry!.AlreadyImported);
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
Assert.Equal("Missing", Assert.Single(services!.Items).Presence);
}
[Fact]
public async Task Archiving_a_removed_service_removes_it_from_active_signals_and_totals_but_preserves_its_detail()
{
using var client = await AuthenticatedClientAsync();
var sync = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots",
Snapshot("hash-archive-signal", includeService: true, "stopped", health: "Unhealthy"));
Assert.Equal(HttpStatusCode.OK, sync.StatusCode);
var proposal = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
var accept = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{proposal.Id}/review",
new ReviewInfrastructureServiceRequest("Accept", "Bestaande service voor archiefregressie."));
Assert.Equal(HttpStatusCode.OK, accept.StatusCode);
var activeSignals = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?status=attention");
Assert.NotNull(activeSignals);
Assert.Equal(proposal.Id, Assert.Single(activeSignals.Items).Id);
var beforeArchive = await client.GetAsync($"/api/v1/infrastructure/services/{proposal.Id}");
Assert.Equal(HttpStatusCode.OK, beforeArchive.StatusCode);
var archive = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/infrastructure/services/{proposal.Id}/editorial-profile",
new UpdateEditorialProfileRequest(
"Verwijderde testcontainer", "", "Other", "", null, null, null, null, null, null,
null, null, null, null, [], false, false, true),
SetupHelper.GetETag(beforeArchive));
Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
// The ordinary catalogue is the dashboard's source for operational signals.
// Archiving suppresses the warning while preserving the detail record for audit
// and possible later unarchiving.
var signalsAfterArchive = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?status=attention");
Assert.NotNull(signalsAfterArchive);
Assert.Empty(signalsAfterArchive.Items);
Assert.Equal(0, signalsAfterArchive.TotalCount);
var overview = await client.GetFromJsonAsync<InfrastructureOverviewResponse>(
"/api/v1/infrastructure/overview");
Assert.NotNull(overview);
Assert.Equal(0, overview.ServiceCount);
Assert.Equal(0, overview.UnhealthyServiceCount);
var archivedDetail = await client.GetFromJsonAsync<InfrastructureServiceDetail>(
$"/api/v1/infrastructure/services/{proposal.Id}");
Assert.NotNull(archivedDetail);
Assert.True(archivedDetail.EditorialProfile!.IsArchived);
Assert.Equal(proposal.Id, Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items).Id);
}
// V2-R2, docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md, Finding
// B: two genuinely concurrent syncs of the *same* snapshot (same SourceHash) — e.g.
// a retried automation trigger firing twice — must never surface as an unhandled
// 500. The DB's own `ix_inventory_snapshots_workspace_id_source_hash` unique index
// is the real source of truth; this proves the loser of the race gets the same
// graceful "AlreadyImported" result the sequential retry path already returns
// (`A_disappeared_service_is_marked_missing_and_an_identical_snapshot_is_idempotent`
// above), not an exception, and that exactly one snapshot row is ever persisted.
[Fact]
public async Task Two_concurrent_syncs_of_the_identical_snapshot_never_500_and_only_one_snapshot_row_survives()
{
using var firstClient = Factory.CreateClient();
using var secondClient = Factory.CreateClient();
await SetupHelper.LoginAsync(firstClient, OwnerEmail, OwnerPassword);
await SetupHelper.LoginAsync(secondClient, OwnerEmail, OwnerPassword);
var snapshot = Snapshot("hash-concurrent-race", includeService: true, "running");
var responses = await Task.WhenAll(
SetupHelper.PostWithCsrfAsync(firstClient, "/api/v1/infrastructure/inventory/snapshots", snapshot),
SetupHelper.PostWithCsrfAsync(secondClient, "/api/v1/infrastructure/inventory/snapshots", snapshot));
Assert.All(responses, response => Assert.Equal(HttpStatusCode.OK, response.StatusCode));
var results = await Task.WhenAll(responses.Select(r => r.Content.ReadFromJsonAsync<InventorySyncResponse>()));
Assert.All(results, result => Assert.NotNull(result));
Assert.Single(results, result => !result!.AlreadyImported);
Assert.Single(results, result => result!.AlreadyImported);
// Both responses must reference the same real, persisted snapshot — the loser's
// graceful fallback resolves the winner's row rather than fabricating a result.
Assert.Equal(results[0]!.SnapshotId, results[1]!.SnapshotId);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var snapshotCount = await db.InventorySnapshots.CountAsync(row => row.SourceHash == "hash-concurrent-race");
Assert.Equal(1, snapshotCount);
}
[Fact]
public async Task A_stale_editorial_profile_ETag_is_rejected()
{
using var client = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-concurrency", includeService: true, "running"));
// includeHidden=true, matching every other test in this file that fetches a
// freshly-synced (and therefore still-Proposed, unreviewed) service: the
// ordinary, non-includeHidden catalog now correctly excludes Proposed/Archived
// services (fixed alongside this test as part of the V2-M4 audit — see
// InfrastructureRepository.ListServicesAsync's own comment). This test exercises
// editorial-profile concurrency, not catalog visibility, so it only needs a
// reliable way to find the one service that exists.
var service = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
var request = new UpdateEditorialProfileRequest(
"Naam", "Samenvatting", "Other", "Doel", null, null, null, null, null, null,
null, null, null, null, [], false, false, false);
var first = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", request, "0");
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var stale = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", request, "0");
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
}
[Fact]
public async Task An_administrator_can_accept_a_proposed_service_and_the_decision_survives_sync()
{
using var client = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-review-1", includeService: true, "running"));
var service = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
Assert.Equal("Proposed", service.Presence);
var review = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/infrastructure/services/{service.Id}/review",
new ReviewInfrastructureServiceRequest("Accept", "Herkenbare productiecomponent."));
Assert.Equal(HttpStatusCode.OK, review.StatusCode);
var reviewed = await review.Content.ReadFromJsonAsync<ReviewInfrastructureServiceResponse>();
Assert.Equal("Present", reviewed!.Presence);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-review-2", includeService: true, "healthy"));
var afterSync = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
Assert.Equal("Present", afterSync.Presence);
}
[Fact]
public async Task A_curated_package_creates_editorial_context_and_confirms_validated_services_idempotently()
{
using var client = await AuthenticatedClientAsync();
var inventory = Snapshot("hash-curated-package", includeService: true, "running");
var package = new ImportCuratedInventoryRequest(
inventory,
[new CuratedServiceProfileRequest(
"docker:knowledge-hub", "ItWorx Knowledge Hub", "Infrastructure & Project Knowledge OS",
"Knowledge OS", "Operationele kennis centraal beheren.",
"https://example.test/knowledge-hub", "http://192.0.2.10:1235",
["infrastructuur", "productie"], true, false)]);
var first = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/curated-package", package);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var imported = await first.Content.ReadFromJsonAsync<ImportCuratedInventoryResponse>();
Assert.NotNull(imported);
Assert.Equal(1, imported.CreatedEditorialProfiles);
Assert.Equal(1, imported.ConfirmedServices);
var service = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
Assert.Equal("Present", service.Presence);
Assert.Equal("ItWorx Knowledge Hub", service.Name);
var detail = await client.GetFromJsonAsync<InfrastructureServiceDetail>(
$"/api/v1/infrastructure/services/{service.Id}");
Assert.Equal("Infrastructure & Project Knowledge OS", detail!.EditorialProfile!.Summary);
var retry = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/curated-package", package);
Assert.Equal(HttpStatusCode.OK, retry.StatusCode);
var retried = await retry.Content.ReadFromJsonAsync<ImportCuratedInventoryResponse>();
Assert.True(retried!.Inventory.AlreadyImported);
Assert.Equal(0, retried.CreatedEditorialProfiles);
Assert.Equal(0, retried.ConfirmedServices);
}
// --- Authorization boundaries (V2-M4: "make source-managed facts, editorial
// profiles and manual overrides ... safely editable" implies the write paths must
// actually be gated, not just modeled). RequireContentAccess(role, scope) is
// already exercised generically elsewhere (M6's ServiceAccountTests,
// RoleAuthorizationTests), but per CLAUDE.md's testing standard ("add integration
// tests for every persistence or authorization boundary") and the V2-M4 test gate
// explicitly naming infrastructure:read/write, each protected route group here
// gets its own boundary proof rather than relying solely on the shared mechanism
// being correct in general.
[Fact]
public async Task Unauthenticated_requests_to_infrastructure_endpoints_are_rejected()
{
using var client = Factory.CreateClient();
using var readResponse = await client.GetAsync("/api/v1/infrastructure/overview");
Assert.Equal(HttpStatusCode.Unauthorized, readResponse.StatusCode);
using var writeResponse = await client.PostAsJsonAsync(
"/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-unauth", includeService: false, "running"));
Assert.Equal(HttpStatusCode.Unauthorized, writeResponse.StatusCode);
}
[Fact]
public async Task A_reader_can_read_infrastructure_but_cannot_write_facts_overrides_or_trigger_a_sync()
{
using var adminClient = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-reader", includeService: true, "running"));
var service = Assert.Single((await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
using var readerClient = await RoleClientAsync(adminClient, "lezer-infra@itworx.test", "reader-strong-password", "Reader");
using var overview = await readerClient.GetAsync("/api/v1/infrastructure/overview");
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
var profileRequest = new UpdateEditorialProfileRequest(
"Naam", "Samenvatting", "Other", "Doel", null, null, null, null, null, null,
null, null, null, null, [], false, false, true);
var profileWrite = await SetupHelper.PutWithCsrfAsync(
readerClient, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", profileRequest, "0");
Assert.Equal(HttpStatusCode.Forbidden, profileWrite.StatusCode);
var overrideWrite = await SetupHelper.PostWithCsrfAsync(
readerClient,
$"/api/v1/infrastructure/services/{service.Id}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("\"Onterecht\""), "Poging."));
Assert.Equal(HttpStatusCode.Forbidden, overrideWrite.StatusCode);
var syncWrite = await SetupHelper.PostWithCsrfAsync(
readerClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-reader-2", includeService: false, "running"));
Assert.Equal(HttpStatusCode.Forbidden, syncWrite.StatusCode);
// The Reader's denied attempts must never have taken effect.
var stillDefault = await adminClient.GetFromJsonAsync<InfrastructureServiceDetail>(
$"/api/v1/infrastructure/services/{service.Id}");
Assert.Null(stillDefault!.EditorialProfile);
Assert.Empty(stillDefault.Overrides);
}
[Fact]
public async Task An_editor_can_edit_facts_and_overrides_but_cannot_review_proposals_or_trigger_a_sync()
{
using var adminClient = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-editor", includeService: true, "running"));
var service = Assert.Single((await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
Assert.Equal("Proposed", service.Presence);
using var editorClient = await RoleClientAsync(adminClient, "redacteur-infra@itworx.test", "editor-strong-password", "Editor");
var profileRequest = new UpdateEditorialProfileRequest(
"Naam", "Samenvatting", "Other", "Doel", null, null, null, null, null, null,
null, null, null, null, [], false, false, false);
var profileWrite = await SetupHelper.PutWithCsrfAsync(
editorClient, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", profileRequest, "0");
Assert.Equal(HttpStatusCode.OK, profileWrite.StatusCode);
var overrideWrite = await SetupHelper.PostWithCsrfAsync(
editorClient,
$"/api/v1/infrastructure/services/{service.Id}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("\"itworx-knowledge-hub (redacteurcorrectie)\""), "Redacteurcorrectie."));
Assert.Equal(HttpStatusCode.OK, overrideWrite.StatusCode);
// Review (Administrator-only per InfrastructureEndpoints.cs) and triggering a
// sync (also Administrator-only) must still be denied to an Editor even though
// both are nominally "write" actions — the role check is independent of and
// stricter than the scope check.
var review = await SetupHelper.PostWithCsrfAsync(
editorClient,
$"/api/v1/infrastructure/services/{service.Id}/review",
new ReviewInfrastructureServiceRequest("Accept", "Poging door redacteur."));
Assert.Equal(HttpStatusCode.Forbidden, review.StatusCode);
var syncWrite = await SetupHelper.PostWithCsrfAsync(
editorClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-editor-2", includeService: false, "running"));
Assert.Equal(HttpStatusCode.Forbidden, syncWrite.StatusCode);
var stillProposed = await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
Assert.Equal("Proposed", Assert.Single(stillProposed!.Items).Presence);
}
[Fact]
public async Task A_bearer_token_with_infrastructure_read_can_read_but_not_write_horizontal_and_vertical_escalation()
{
using var adminClient = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-bearer-read", includeService: true, "running"));
var service = Assert.Single((await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
var created = await CreateServiceAccountAsync(adminClient, "Infra-read-bot", Scopes.InfrastructureRead);
using var bearerClient = BearerClient(created.RawToken);
using var readResponse = await bearerClient.GetAsync("/api/v1/infrastructure/overview");
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
// infrastructure:read must not implicitly grant infrastructure:write — a
// vertical privilege-escalation attempt, matching M6's established pattern for
// every other scoped resource in this API.
var overrideWrite = await bearerClient.PostAsJsonAsync(
$"/api/v1/infrastructure/services/{service.Id}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("\"Onterecht\""), "Bearer-poging."));
Assert.True(
overrideWrite.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the write attempt to be denied, but got {overrideWrite.StatusCode}.");
}
[Fact]
public async Task A_bearer_token_without_any_infrastructure_scope_cannot_read_or_write_horizontal_escalation()
{
using var adminClient = await AuthenticatedClientAsync();
// A token scoped to a completely unrelated resource — never infrastructure:read
// or infrastructure:write. Proves scopes for one resource never leak access to
// another (horizontal privilege escalation), matching ServiceAccountTests'
// "pages:write does not grant project access" precedent for this resource.
var created = await CreateServiceAccountAsync(adminClient, "Search-only-bot", Scopes.KnowledgeSearch);
using var bearerClient = BearerClient(created.RawToken);
using var readResponse = await bearerClient.GetAsync("/api/v1/infrastructure/overview");
Assert.True(
readResponse.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the read attempt to be denied, but got {readResponse.StatusCode}.");
}
private async Task<HttpClient> RoleClientAsync(HttpClient adminClient, string email, string password, string role)
{
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users", new CreateUserRequest($"Test {role}", email, password, role));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, email, password);
return client;
}
private static async Task<CreatedServiceAccountToken> CreateServiceAccountAsync(HttpClient adminClient, string name, params string[] scopes)
{
var response = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts", new CreateServiceAccountRequest(name, "Testaccount.", scopes));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
return created;
}
private HttpClient BearerClient(string rawToken)
{
var client = Factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
return client;
}
private async Task<HttpClient> AuthenticatedClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static SynchronizeInventoryRequest Snapshot(
string hash, bool includeService, string runtimeStatus, string health = "Healthy")
{
var services = includeService
? new[]
{
new InventoryServiceRequest(
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", runtimeStatus,
health, "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
[new InventoryComponentRequest("container:app", "app", "container", "abc123", runtimeStatus, "itworx/knowledge-hub:v2", "High")],
[new InventoryEndpointRequest("port:1235", "http", "192.0.2.10", 1235, 8080, "http://192.0.2.10:1235", false, "High")],
[new InventoryNetworkRequest("network:internal", "knowledge-internal", "bridge", "local", true, "High")],
[new InventoryVolumeRequest("volume:attachments", "attachments", "/app/attachments", "appdata", false, 1024, "High")],
[new InventoryDatabaseRequest("database:postgres", "knowledge_hub", "postgresql", "17", "db", 5432, false, "High")],
[], [new InventoryHealthRequest("health:app", health, "container", 5, "High")],
[new InventoryDeploymentRequest("deployment:app", "https://example.test/repo", "abc", "itworx/knowledge-hub:v2", "sha256:example", null, "High")],
[new InventoryBackupRequest("backup:daily", "pg_dump", "daily", DateTimeOffset.UtcNow.AddHours(-1), true, "High")])
}
: [];
return new SynchronizeInventoryRequest(
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
services);
}
}
@@ -0,0 +1,205 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>
/// The explicit per-<c>InfrastructureObjectKind</c> override field catalog
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H3):
/// <c>ManualOverride</c> must never accept an arbitrary field name or unconstrained
/// JSON.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class ManualOverrideCatalogTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<(HttpClient Client, Guid ServiceId)> AuthenticatedClientWithServiceAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", MinimalSnapshot($"hash-{Guid.NewGuid():N}"));
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
var service = Assert.Single(services!.Items);
return (client, service.Id);
}
[Fact]
public async Task The_catalog_endpoint_lists_exactly_the_fields_the_UI_may_offer_for_a_service()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var catalog = await client.GetFromJsonAsync<ManualOverrideFieldCatalogResponse>(
"/api/v1/infrastructure/overrides/catalog?targetKind=Service");
Assert.NotNull(catalog);
Assert.Equal("Service", catalog.TargetKind);
Assert.Equal(
new[] { "detectedName", "runtimeKind", "runtimeStatus", "imageReference", "health", "confidence" },
catalog.Fields.Select(field => field.FieldName));
var health = Assert.Single(catalog.Fields, field => field.FieldName == "health");
Assert.Equal("Enum", health.ValueKind);
Assert.Contains("Healthy", health.AllowedEnumValues!);
}
[Fact]
public async Task An_unknown_field_name_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("arbitraryNewField", JsonSerializer.Deserialize<JsonElement>("\"waarde\""), "Poging."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_non_string_JSON_value_is_rejected_even_for_a_text_field()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("12345"), "Verkeerd type."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_object_JSON_value_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("{\"nested\":true}"), "Verkeerd type."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_oversized_JSON_value_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var huge = new string('a', 5000);
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.SerializeToElement(huge), "Te groot."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_string_value_longer_than_the_field_specific_maximum_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
// Under the 4000-byte envelope cap but over runtimeKind's own 100-character
// field-specific bound — proves both limits are enforced independently.
var tooLongForField = new string('a', 200);
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("runtimeKind", JsonSerializer.SerializeToElement(tooLongForField), "Te lang voor dit veld."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_invalid_enum_value_for_health_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("health", JsonSerializer.Deserialize<JsonElement>("\"NietBestaand\""), "Ongeldige status."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Theory]
[InlineData("Unknown")]
[InlineData("Healthy")]
[InlineData("Degraded")]
[InlineData("Unhealthy")]
[InlineData("Stopped")]
public async Task Every_real_health_enum_value_is_accepted(string health)
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("health", JsonSerializer.SerializeToElement(health), "Geldige status."));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task An_invalid_confidence_enum_value_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("confidence", JsonSerializer.Deserialize<JsonElement>("\"Zeker\""), "Ongeldig."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Malformed_JSON_in_the_raw_request_never_produces_a_500()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/infrastructure/services/{serviceId}/overrides")
{
Content = new StringContent("{ not valid json", System.Text.Encoding.UTF8, "application/json")
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
var response = await client.SendAsync(request);
Assert.True(
(int)response.StatusCode < 500,
$"Malformed JSON must never reach a 5xx response, got {response.StatusCode}.");
}
[Fact]
public async Task A_valid_override_can_be_reset_by_removing_it_and_the_removal_is_idempotent_to_retry_by_id()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var setResponse = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("runtimeStatus", JsonSerializer.SerializeToElement("gecorrigeerd"), "Reden."));
Assert.Equal(HttpStatusCode.OK, setResponse.StatusCode);
var created = await setResponse.Content.ReadFromJsonAsync<JsonElement>();
var overrideId = created.GetProperty("id").GetGuid();
var removeResponse = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/infrastructure/overrides/{overrideId}");
Assert.Equal(HttpStatusCode.NoContent, removeResponse.StatusCode);
var detail = await client.GetFromJsonAsync<InfrastructureServiceDetail>($"/api/v1/infrastructure/services/{serviceId}");
Assert.Empty(detail!.Overrides);
// Removing an already-removed override is a clear not-found, never a crash.
var retryRemove = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/infrastructure/overrides/{overrideId}");
Assert.Equal(HttpStatusCode.NotFound, retryRemove.StatusCode);
}
private static SynchronizeInventoryRequest MinimalSnapshot(string hash) =>
new(
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
[new InventoryServiceRequest(
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", "running",
"Healthy", "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
[], [], [], [], [], [], [], [], [])]);
}
@@ -0,0 +1,184 @@
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Application.Ingestion;
using ItWorx.KnowledgeHub.Contracts.Ingestion;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion;
/// <summary>These metric contract tests are Docker-free. They exercise the production
/// metric surface directly, with synthetic dimensions only.</summary>
public sealed class IngestionMetricContractTests
{
[Fact]
public void Pilot_metrics_emit_only_bounded_labels_and_normalize_untrusted_dimensions()
{
using var meter = new Meter("ingestion-observability-test");
var measurements = new List<ObservedMeasurement>();
using var listener = new MeterListener
{
InstrumentPublished = (instrument, currentListener) =>
{
if (instrument.Meter == meter)
currentListener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
measurements.Add(Observe(instrument.Name, value, tags)));
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
measurements.Add(Observe(instrument.Name, value, tags)));
listener.Start();
var metrics = new IngestionMetrics(meter);
metrics.RecordProposal("success", "none");
metrics.RecordStageDuration("source_staging", "success", 1.25);
metrics.RecordReviewDuration("success", 2.5);
metrics.RecordPublication("success", "none");
const string sensitiveBody = "do not log me";
const string sensitiveFileName = "secret-plan.pdf";
const string sensitiveSourceUrl = "https://private.example/source?token=secret";
metrics.RecordProposal(sensitiveBody, sensitiveSourceUrl);
metrics.RecordStageDuration(sensitiveFileName, sensitiveBody, 3.75);
metrics.RecordReviewDuration(sensitiveBody, 4.5);
metrics.RecordPublication(sensitiveBody, sensitiveSourceUrl);
var expected = new[]
{
("ingestion_proposals_total", "outcome=other|problem_type=other|stage=proposal"),
("ingestion_proposals_total", "outcome=success|problem_type=none|stage=proposal"),
("ingestion_stage_duration_seconds", "outcome=other|problem_type=none|stage=other"),
("ingestion_stage_duration_seconds", "outcome=success|problem_type=none|stage=source_staging"),
("ingestion_review_duration_seconds", "outcome=other|problem_type=none|stage=review"),
("ingestion_review_duration_seconds", "outcome=success|problem_type=none|stage=review"),
("ingestion_publications_total", "outcome=other|problem_type=other|stage=publication"),
("ingestion_publications_total", "outcome=success|problem_type=none|stage=publication")
}
.OrderBy(measurement => measurement.Item1, StringComparer.Ordinal)
.ThenBy(measurement => measurement.Item2, StringComparer.Ordinal)
.ToArray();
var actual = measurements
.Select(measurement => (measurement.Name, Tags: SerializeTags(measurement.Tags)))
.OrderBy(measurement => measurement.Name, StringComparer.Ordinal)
.ThenBy(measurement => measurement.Tags, StringComparer.Ordinal)
.ToArray();
Assert.Equal(expected, actual);
var allowedLabelKeys = new HashSet<string>(["stage", "outcome", "problem_type"], StringComparer.Ordinal);
Assert.All(measurements, measurement => Assert.Subset(allowedLabelKeys, measurement.Tags.Keys.ToHashSet(StringComparer.Ordinal)));
Assert.All(measurements, measurement => Assert.True(measurement.Value > 0));
var serializedMeasurements = string.Join('\n', actual.Select(measurement => measurement.Tags));
Assert.DoesNotContain(sensitiveBody, serializedMeasurements, StringComparison.Ordinal);
Assert.DoesNotContain(sensitiveFileName, serializedMeasurements, StringComparison.Ordinal);
Assert.DoesNotContain(sensitiveSourceUrl, serializedMeasurements, StringComparison.Ordinal);
}
private static ObservedMeasurement Observe(string name, double value, ReadOnlySpan<KeyValuePair<string, object?>> tags) =>
new(name, value, tags.ToArray().ToDictionary(tag => tag.Key, tag => tag.Value?.ToString() ?? string.Empty, StringComparer.Ordinal));
private static string SerializeTags(IReadOnlyDictionary<string, string> tags) =>
string.Join('|', tags.OrderBy(tag => tag.Key, StringComparer.Ordinal).Select(tag => $"{tag.Key}={tag.Value}"));
private sealed record ObservedMeasurement(string Name, double Value, IReadOnlyDictionary<string, string> Tags);
}
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionObservabilityTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(
postgres, new Dictionary<string, string?> { ["IngestionProposal:Enabled"] = "true" })
{
[Fact]
public async Task Proposal_and_source_requests_never_write_source_content_or_file_metadata_to_logs()
{
const string sensitiveFileName = "secret-plan.pdf";
const string sensitiveSource = "source bytes must stay out of logs";
const string sensitiveUrl = "https://private.example/source?token=secret";
var sensitiveManifest = $"do not log me {sensitiveUrl}";
var captured = new ConcurrentQueue<string>();
using var loggingFactory = Factory.WithWebHostBuilder(builder =>
builder.ConfigureLogging(logging => logging.AddProvider(new CapturingLoggerProvider(captured))));
using var client = loggingFactory.CreateClient();
var login = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
login.EnsureSuccessStatusCode();
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{
Content = JsonContent.Create(new CreateIngestionProposalRequest(sensitiveManifest, "safe-observability-correlation"))
};
create.Headers.Add(csrf.HeaderName, csrf.Value);
create.Headers.Add("Idempotency-Key", "safe-observability-create");
create.Headers.Add("X-Correlation-ID", "safe-observability-correlation");
var created = await client.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
var sourceCsrf = await SetupHelper.GetCsrfTokenAsync(client);
using var multipart = new MultipartFormDataContent();
var sourceBody = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(sensitiveSource));
sourceBody.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
multipart.Add(sourceBody, "file", sensitiveFileName);
using var upload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources")
{
Content = multipart
};
upload.Headers.Add(sourceCsrf.HeaderName, sourceCsrf.Value);
upload.Headers.Add("Idempotency-Key", "safe-observability-source");
upload.Headers.Add("If-Match", created.Headers.ETag!.Tag);
upload.Headers.Add("X-Correlation-ID", "safe-observability-correlation");
var staged = await client.SendAsync(upload);
Assert.Equal(HttpStatusCode.OK, staged.StatusCode);
var logText = string.Join('\n', captured);
Assert.Contains("safe-observability-correlation", logText, StringComparison.Ordinal);
Assert.DoesNotContain(sensitiveManifest, logText, StringComparison.Ordinal);
Assert.DoesNotContain(sensitiveFileName, logText, StringComparison.Ordinal);
Assert.DoesNotContain(sensitiveSource, logText, StringComparison.Ordinal);
Assert.DoesNotContain(sensitiveUrl, logText, StringComparison.Ordinal);
}
private sealed class CapturingLoggerProvider(ConcurrentQueue<string> sink) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, sink);
public void Dispose()
{
}
private sealed class CapturingLogger(string categoryName, ConcurrentQueue<string> sink) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
sink.Enqueue($"[{categoryName}] scope: {Format(state)}");
return NoopDisposable.Instance;
}
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter) =>
sink.Enqueue($"[{categoryName}] {formatter(state, exception)} {Format(state)} {exception}");
private static string Format<TState>(TState state) => state is IEnumerable<KeyValuePair<string, object?>> fields
? string.Join(' ', fields.Select(field => $"{field.Key}={field.Value}"))
: state?.ToString() ?? string.Empty;
}
private sealed class NoopDisposable : IDisposable
{
public static NoopDisposable Instance { get; } = new();
public void Dispose() { }
}
}
}
@@ -0,0 +1,63 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Ingestion;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion;
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionPilotAvailabilityTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Disabled_intake_keeps_knowledge_routes_and_readiness_available_when_optional_mcp_is_unhealthy()
{
using var client = Factory.CreateClient();
var login = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
login.EnsureSuccessStatusCode();
var session = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(session);
Assert.False(session.Features?.IngestionProposals);
var intake = await client.GetAsync("/api/v1/ingestion-proposals");
Assert.Equal(HttpStatusCode.NotFound, intake.StatusCode);
var problem = await intake.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(IngestionProblemTypes.FeatureDisabled, problem?.Type);
var knowledge = await client.GetAsync("/api/v1/pages/roots");
Assert.Equal(HttpStatusCode.OK, knowledge.StatusCode);
// Model an unavailable optional external integration. The readiness endpoint
// must remain tied to the Hub's own database and storage requirements.
var healthChecks = Factory.Services
.GetRequiredService<IOptions<HealthCheckServiceOptions>>()
.Value;
healthChecks.Registrations.Add(new HealthCheckRegistration(
"optional-mcp",
_ => new UnavailableOptionalDependencyHealthCheck(),
HealthStatus.Unhealthy,
tags: null,
timeout: TimeSpan.FromSeconds(1)));
var readiness = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.OK, readiness.StatusCode);
Assert.Equal("Healthy", (await readiness.Content.ReadAsStringAsync()).Trim());
var liveness = await client.GetAsync("/health/live");
Assert.Equal(HttpStatusCode.OK, liveness.StatusCode);
}
private sealed class UnavailableOptionalDependencyHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default) =>
Task.FromResult(HealthCheckResult.Unhealthy("Optional MCP dependency is unavailable."));
}
}
@@ -0,0 +1,327 @@
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);
}
}
@@ -0,0 +1,767 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Ingestion;
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;
/// <summary>HTTP contract for the bounded trusted-intake route group.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionProposalApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Disabled_feature_returns_the_typed_ingestion_problem_instead_of_exposing_a_route()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/ingestion-proposals",
new CreateIngestionProposalRequest("safe manifest", "correlation-1"));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(IngestionProblemTypes.FeatureDisabled, problem?.Type);
var sourceCsrf = await SetupHelper.GetCsrfTokenAsync(client);
using var sourceContent = new MultipartFormDataContent();
var sourceBody = new ByteArrayContent("disabled source"u8.ToArray());
sourceBody.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
sourceContent.Add(sourceBody, "file", "disabled.txt");
using var sourceRequest = new HttpRequestMessage(
HttpMethod.Post,
$"/api/v1/ingestion-proposals/{Guid.NewGuid()}/sources")
{
Content = sourceContent
};
sourceRequest.Headers.Add(sourceCsrf.HeaderName, sourceCsrf.Value);
sourceRequest.Headers.Add("Idempotency-Key", "disabled-source-upload");
sourceRequest.Headers.TryAddWithoutValidation("If-Match", "\"unused\"");
var sourceResponse = await client.SendAsync(sourceRequest);
Assert.Equal(HttpStatusCode.NotFound, sourceResponse.StatusCode);
var sourceProblem = await sourceResponse.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(IngestionProblemTypes.FeatureDisabled, sourceProblem?.Type);
}
}
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionProposalSourceApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(
postgres, new Dictionary<string, string?> { ["IngestionProposal:Enabled"] = "true" })
{
[Fact]
public async Task Upload_retry_with_a_new_multipart_boundary_replays_once_and_changed_bytes_conflict()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{ Content = JsonContent.Create(new CreateIngestionProposalRequest("Retry document", "retry-source")) };
create.Headers.Add(csrf.HeaderName, csrf.Value);
create.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
var created = await client.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
var key = Guid.NewGuid().ToString();
async Task<HttpResponseMessage> UploadAsync(string content)
{
using var multipart = new MultipartFormDataContent();
var file = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(content));
file.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
multipart.Add(file, "file", "retry.txt");
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal.Id}/sources")
{ Content = multipart };
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("Idempotency-Key", key);
request.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
return await client.SendAsync(request);
}
using var first = await UploadAsync("same bytes");
using var replay = await UploadAsync("same bytes");
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, replay.StatusCode);
var firstDetail = await first.Content.ReadFromJsonAsync<IngestionProposalDetail>();
var replayDetail = await replay.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.Equal(Assert.Single(firstDetail!.Sources).AttachmentReferenceId, Assert.Single(replayDetail!.Sources).AttachmentReferenceId);
using var conflict = await UploadAsync("changed bytes");
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
}
[Fact]
public async Task Human_document_upload_enters_review_only_after_source_and_explicit_submission()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{
Content = JsonContent.Create(new CreateIngestionProposalRequest("Handmatig document", "manual-source",
new IngestionPublicationRequest("Handboek", "Origineel als bijlage", "Reference", "Draft", "CreatePage")))
};
create.Headers.Add(csrf.HeaderName, csrf.Value);
create.Headers.Add("Idempotency-Key", "manual-source-create");
var created = await client.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var detail = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(detail);
using var tooEarly = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{detail.Id}/submit")
{ Content = JsonContent.Create(new { }) };
tooEarly.Headers.Add(csrf.HeaderName, csrf.Value);
tooEarly.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.Conflict, (await client.SendAsync(tooEarly)).StatusCode);
using var multipart = new MultipartFormDataContent();
var file = new ByteArrayContent("Original document"u8.ToArray());
file.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
multipart.Add(file, "file", "document.txt");
using var upload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{detail.Id}/sources")
{ Content = multipart };
upload.Headers.Add(csrf.HeaderName, csrf.Value);
upload.Headers.Add("Idempotency-Key", "manual-source-upload");
upload.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
var uploaded = await client.SendAsync(upload);
Assert.Equal(HttpStatusCode.OK, uploaded.StatusCode);
using var submit = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{detail.Id}/submit")
{ Content = JsonContent.Create(new { }) };
submit.Headers.Add(csrf.HeaderName, csrf.Value);
submit.Headers.TryAddWithoutValidation("If-Match", uploaded.Headers.ETag!.Tag);
var submitted = await client.SendAsync(submit);
Assert.Equal(HttpStatusCode.OK, submitted.StatusCode);
var reviewed = await submitted.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.Equal("ReadyForReview", reviewed?.Status);
Assert.Null(reviewed?.Analysis);
Assert.Single(reviewed!.Sources);
Assert.Contains(reviewed.Revisions, revision => revision.Operation == "manual-review-submitted");
using var lateMultipart = new MultipartFormDataContent();
var lateFile = new ByteArrayContent("late content"u8.ToArray());
lateFile.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
lateMultipart.Add(lateFile, "file", "late.txt");
using var lateUpload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{detail.Id}/sources")
{ Content = lateMultipart };
lateUpload.Headers.Add(csrf.HeaderName, csrf.Value);
lateUpload.Headers.Add("Idempotency-Key", "manual-source-late-upload");
lateUpload.Headers.TryAddWithoutValidation("If-Match", submitted.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.Conflict, (await client.SendAsync(lateUpload)).StatusCode);
}
[Fact]
public async Task Service_account_cannot_use_the_human_manual_submission_route()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var token = await CreateServiceAccountAsync(owner, "manual-submit-denied");
using var service = BearerClient(token.RawToken);
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{Guid.NewGuid()}/submit")
{ Content = JsonContent.Create(new { }) };
request.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
var response = await service.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Source_upload_rate_limit_is_per_remote_ip()
{
using var clientA = Factory.CreateClient();
await SetupHelper.LoginAsync(clientA, OwnerEmail, OwnerPassword);
var created = await CreateProposalAsync(clientA, "rate-limited source", "source-rate-limit", "source-rate-create");
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var currentETag = created.Headers.ETag!.Tag;
clientA.DefaultRequestHeaders.Add("X-Test-Remote-Ip", "192.0.2.20");
for (var attempt = 0; attempt < 5; attempt++)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(clientA);
using var multipart = new MultipartFormDataContent();
var file = new ByteArrayContent("rate-limit source"u8.ToArray());
file.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
multipart.Add(file, "file", $"source-{attempt}.txt");
using var upload = new HttpRequestMessage(
HttpMethod.Post,
$"/api/v1/ingestion-proposals/{proposal!.Id}/sources")
{
Content = multipart
};
upload.Headers.Add(csrf.HeaderName, csrf.Value);
upload.Headers.Add("Idempotency-Key", $"source-rate-upload-a-{attempt}");
upload.Headers.TryAddWithoutValidation("If-Match", currentETag);
var response = await clientA.SendAsync(upload);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
currentETag = response.Headers.ETag!.Tag;
}
using var clientB = Factory.CreateClient();
await SetupHelper.LoginAsync(clientB, OwnerEmail, OwnerPassword);
clientB.DefaultRequestHeaders.Add("X-Test-Remote-Ip", "192.0.2.21");
var clientBCsrf = await SetupHelper.GetCsrfTokenAsync(clientB);
using var clientBMultipart = new MultipartFormDataContent();
var clientBFile = new ByteArrayContent("other source"u8.ToArray());
clientBFile.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
clientBMultipart.Add(clientBFile, "file", "source-client-b.txt");
using var clientBUpload = new HttpRequestMessage(
HttpMethod.Post,
$"/api/v1/ingestion-proposals/{proposal!.Id}/sources")
{
Content = clientBMultipart
};
clientBUpload.Headers.Add(clientBCsrf.HeaderName, clientBCsrf.Value);
clientBUpload.Headers.Add("Idempotency-Key", "source-rate-upload-b");
clientBUpload.Headers.TryAddWithoutValidation("If-Match", currentETag);
var clientBResponse = await clientB.SendAsync(clientBUpload);
Assert.Equal(HttpStatusCode.OK, clientBResponse.StatusCode);
currentETag = clientBResponse.Headers.ETag!.Tag;
var clientACsrf = await SetupHelper.GetCsrfTokenAsync(clientA);
using var exhaustedMultipart = new MultipartFormDataContent();
var exhaustedFile = new ByteArrayContent("blocked source"u8.ToArray());
exhaustedFile.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
exhaustedMultipart.Add(exhaustedFile, "file", "source-client-a-blocked.txt");
using var exhaustedUpload = new HttpRequestMessage(
HttpMethod.Post,
$"/api/v1/ingestion-proposals/{proposal.Id}/sources")
{
Content = exhaustedMultipart
};
exhaustedUpload.Headers.Add(clientACsrf.HeaderName, clientACsrf.Value);
exhaustedUpload.Headers.Add("Idempotency-Key", "source-rate-upload-a-exhausted");
exhaustedUpload.Headers.TryAddWithoutValidation("If-Match", currentETag);
var clientAResponse = await clientA.SendAsync(exhaustedUpload);
Assert.Equal(HttpStatusCode.TooManyRequests, clientAResponse.StatusCode);
}
[Fact]
public async Task Patch_requires_an_IfMatch_header_and_rejects_a_stale_ETag()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var created = await CreateProposalAsync(client, "etag manifest", "etag-correlation", "etag-create");
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
var missingIfMatch = await SetupHelper.PatchWithCsrfAsync(
client, $"/api/v1/ingestion-proposals/{proposal!.Id}",
new PatchIngestionProposalRequest("without concurrency precondition", null));
Assert.Equal((HttpStatusCode)428, missingIfMatch.StatusCode);
var firstCsrf = await SetupHelper.GetCsrfTokenAsync(client);
using var firstPatch = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}")
{
Content = JsonContent.Create(new PatchIngestionProposalRequest("updated manifest", null))
};
firstPatch.Headers.Add(firstCsrf.HeaderName, firstCsrf.Value);
firstPatch.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
var firstUpdated = await client.SendAsync(firstPatch);
Assert.Equal(HttpStatusCode.OK, firstUpdated.StatusCode);
var staleCsrf = await SetupHelper.GetCsrfTokenAsync(client);
using var stalePatch = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}")
{
Content = JsonContent.Create(new PatchIngestionProposalRequest("stale manifest", null))
};
stalePatch.Headers.Add(staleCsrf.HeaderName, staleCsrf.Value);
stalePatch.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag.Tag);
var stale = await client.SendAsync(stalePatch);
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
var problem = await stale.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(IngestionProblemTypes.ConcurrencyConflict, problem?.Type);
var current = await client.GetFromJsonAsync<IngestionProposalDetail>($"/api/v1/ingestion-proposals/{proposal.Id}");
Assert.Equal("updated manifest", current?.Manifest);
}
[Fact]
public async Task Reusing_an_idempotency_key_with_a_different_proposal_body_returns_conflict()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var first = await CreateProposalAsync(client, "original manifest", "idempotency-correlation", "same-key");
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
var second = await CreateProposalAsync(client, "different manifest", "idempotency-correlation", "same-key");
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
var problem = await second.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(ProblemTypes.IdempotencyKeyConflict, problem?.Type);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
Assert.Equal(1, await db.IngestionProposals.CountAsync());
}
[Fact]
public async Task List_filters_by_status_and_uses_cursor_pagination_without_duplicates()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var receivedOne = await CreateProposalAsync(owner, "received one", "queue-1", "queue-1");
var receivedTwo = await CreateProposalAsync(owner, "received two", "queue-2", "queue-2");
var receivedOneDetail = await receivedOne.Content.ReadFromJsonAsync<IngestionProposalDetail>();
var receivedTwoDetail = await receivedTwo.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(receivedOneDetail);
Assert.NotNull(receivedTwoDetail);
var service = await CreateServiceAccountAsync(owner, "queue-analyzer");
using var serviceClient = BearerClient(service.RawToken);
var ready = await CreateProposalAsync(serviceClient, "ready manifest", "queue-ready", "queue-ready");
var readyDetail = await ready.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(readyDetail);
using var analysis = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{readyDetail!.Id}")
{
Content = JsonContent.Create(new PatchIngestionProposalRequest(null, null, "safe queue analysis"))
};
analysis.Headers.TryAddWithoutValidation("If-Match", ready.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.OK, (await serviceClient.SendAsync(analysis)).StatusCode);
var receivedIds = new List<Guid>();
Guid? cursor = null;
do
{
var path = cursor is null
? "/api/v1/ingestion-proposals?status=Received&limit=1"
: $"/api/v1/ingestion-proposals?status=Received&limit=1&cursor={cursor}";
var page = await owner.GetFromJsonAsync<IngestionProposalListResponse>(path);
Assert.NotNull(page);
Assert.All(page.Items, item => Assert.Equal("Received", item.Status));
receivedIds.AddRange(page.Items.Select(item => item.Id));
cursor = page.NextCursor;
} while (cursor is not null);
Assert.Equal(2, receivedIds.Count);
Assert.Equal(2, receivedIds.Distinct().Count());
Assert.Contains(receivedOneDetail!.Id, receivedIds);
Assert.Contains(receivedTwoDetail!.Id, receivedIds);
var readyPage = await owner.GetFromJsonAsync<IngestionProposalListResponse>("/api/v1/ingestion-proposals?status=ReadyForReview");
var readySummary = Assert.Single(readyPage!.Items);
Assert.Equal(readyDetail.Id, readySummary.Id);
Assert.Equal("ReadyForReview", readySummary.Status);
}
[Fact]
public async Task Numbered_queue_pages_are_stable_for_equal_timestamps_and_unknown_cursors_do_not_change_order()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var olderResponse = await CreateProposalAsync(owner, "older queue item", "queue-older", "queue-older");
var newerResponse = await CreateProposalAsync(owner, "newer queue item", "queue-newer", "queue-newer");
var tiedResponse = await CreateProposalAsync(owner, "same-time queue item", "queue-tied", "queue-tied");
var older = await olderResponse.Content.ReadFromJsonAsync<IngestionProposalDetail>();
var newer = await newerResponse.Content.ReadFromJsonAsync<IngestionProposalDetail>();
var tied = await tiedResponse.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(older);
Assert.NotNull(newer);
Assert.NotNull(tied);
// Pin the immutable creation time so this regression is deterministic even
// on a host whose clock has coarse timestamp precision.
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var createdAt = DateTimeOffset.UtcNow;
await db.IngestionProposalRevisions
.Where(revision => EF.Property<Guid>(revision, "IngestionProposalId") == older!.Id)
.ExecuteUpdateAsync(update => update.SetProperty(revision => revision.OccurredAt, createdAt.AddMinutes(-5)));
await db.IngestionProposalRevisions
.Where(revision => EF.Property<Guid>(revision, "IngestionProposalId") == newer!.Id)
.ExecuteUpdateAsync(update => update.SetProperty(revision => revision.OccurredAt, createdAt));
await db.IngestionProposalRevisions
.Where(revision => EF.Property<Guid>(revision, "IngestionProposalId") == tied!.Id)
.ExecuteUpdateAsync(update => update.SetProperty(revision => revision.OccurredAt, createdAt));
}
var expectedIds = new[] { newer!.Id, tied!.Id }
.OrderByDescending(id => id)
.Append(older!.Id)
.ToArray();
var actualIds = new List<Guid>();
Guid? cursor = null;
do
{
var path = cursor is null
? "/api/v1/ingestion-proposals?page=1&pageSize=1"
: $"/api/v1/ingestion-proposals?cursor={cursor}&limit=1";
var page = await owner.GetFromJsonAsync<IngestionProposalListResponse>(path);
Assert.NotNull(page);
actualIds.AddRange(page.Items.Select(item => item.Id));
cursor = page.NextCursor;
} while (cursor is not null);
Assert.Equal(expectedIds, actualIds);
var unknownCursor = await owner.GetFromJsonAsync<IngestionProposalListResponse>(
$"/api/v1/ingestion-proposals?cursor={Guid.NewGuid()}&limit=1");
Assert.NotNull(unknownCursor);
Assert.Empty(unknownCursor.Items);
Assert.Null(unknownCursor.NextCursor);
}
[Fact]
public async Task Queue_returns_a_bounded_page_total_and_truthful_publication_dossier()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var created = await CreateProposalAsync(client, "paged", "queue-contract", "queue-contract-key");
var session = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(session?.UserId);
var response = await client.GetFromJsonAsync<IngestionProposalListResponse>("/api/v1/ingestion-proposals?page=1&pageSize=25&submitterId=" + session.UserId);
Assert.NotNull(response);
Assert.Equal(1, response.Page);
Assert.Equal(25, response.PageSize);
Assert.True(response.Total >= 1);
var item = Assert.Single(response.Items);
Assert.Equal(session.UserId, item.SubmitterId);
Assert.Null(item.Destination);
var createdDetail = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(createdDetail);
Assert.NotNull(createdDetail.Permissions);
Assert.Empty(createdDetail.Warnings);
Assert.Empty(createdDetail.Citations);
}
[Fact]
public async Task Create_persists_its_correlation_and_echoes_the_request_correlation_header()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
const string requestCorrelation = "ingestion-http-correlation";
var response = await CreateProposalAsync(client, "correlated manifest", "proposal-correlation", "correlation-key", requestCorrelation);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Equal(requestCorrelation, Assert.Single(response.Headers.GetValues("X-Correlation-ID")));
var detail = await response.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.Equal("proposal-correlation", detail?.CorrelationId);
Assert.NotNull(response.Headers.ETag);
Assert.Equal($"\"{detail!.ETag}\"", response.Headers.ETag!.Tag);
}
[Fact]
public async Task Accepted_source_uses_the_stored_digest_and_a_proposal_only_attachment_usage()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{
Content = JsonContent.Create(new CreateIngestionProposalRequest("safe manifest", "source-correlation"))
};
create.Headers.Add(csrf.HeaderName, csrf.Value);
create.Headers.Add("Idempotency-Key", "source-create-1");
var created = await client.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
var sourceCsrf = await SetupHelper.GetCsrfTokenAsync(client);
using var multipart = new MultipartFormDataContent();
var sourceBody = new ByteArrayContent("trusted source bytes"u8.ToArray());
sourceBody.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
multipart.Add(sourceBody, "file", "evidence.pdf");
using var sourceRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal.Id}/sources") { Content = multipart };
sourceRequest.Headers.Add(sourceCsrf.HeaderName, sourceCsrf.Value);
sourceRequest.Headers.Add("Idempotency-Key", "source-upload-1");
sourceRequest.Headers.Add("If-Match", created.Headers.ETag!.Tag);
var response = await client.SendAsync(sourceRequest);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var detail = await response.Content.ReadFromJsonAsync<IngestionProposalDetail>();
var staged = Assert.Single(detail!.Sources);
Assert.Equal("evidence.pdf", staged.FileName);
Assert.NotEqual(new string('0', 64), staged.DigestSha256);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var usage = await db.AttachmentUsages.SingleAsync(item => item.AttachmentReferenceId == staged.AttachmentReferenceId);
var reference = await db.AttachmentReferences.SingleAsync(item => item.Id == staged.AttachmentReferenceId);
Assert.Equal(proposal.Id, usage.IngestionProposalId);
Assert.Null(usage.PageId);
Assert.Null(usage.StagingSessionId);
Assert.Equal(reference.BlobSha256, staged.DigestSha256);
var audit = await db.AuditEvents.SingleAsync(item => item.Action == "ingestion-proposal.source-staged" && item.TargetId == proposal.Id.ToString());
Assert.Equal("User", audit.ActorKind);
Assert.Equal("web", audit.Source);
Assert.DoesNotContain("trusted source bytes", audit.Summary, StringComparison.Ordinal);
}
[Fact]
public async Task Rejected_source_does_not_create_a_proposal_attachment_usage_or_source_descriptor()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{
Content = JsonContent.Create(new CreateIngestionProposalRequest("safe manifest", "rejected-source"))
};
create.Headers.Add(csrf.HeaderName, csrf.Value);
create.Headers.Add("Idempotency-Key", "rejected-source-create");
var created = await client.SendAsync(create);
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
var sourceCsrf = await SetupHelper.GetCsrfTokenAsync(client);
using var multipart = new MultipartFormDataContent();
var executable = new ByteArrayContent([0x4D, 0x5A, 0x90, 0x00]);
executable.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
multipart.Add(executable, "file", "disguised.pdf");
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources") { Content = multipart };
request.Headers.Add(sourceCsrf.HeaderName, sourceCsrf.Value);
request.Headers.Add("Idempotency-Key", "rejected-source-upload");
request.Headers.Add("If-Match", created.Headers.ETag!.Tag);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
Assert.Empty(await db.IngestionProposalSources.Where(source => EF.Property<Guid>(source, "IngestionProposalId") == proposal.Id).ToListAsync());
Assert.Empty(await db.AttachmentUsages.Where(usage => usage.IngestionProposalId == proposal.Id).ToListAsync());
Assert.Empty(await db.AttachmentReferences.ToListAsync());
Assert.Empty(await db.AttachmentBlobs.ToListAsync());
}
[Fact]
public async Task Service_account_can_complete_only_its_own_analysis_and_read_its_metadata_only_status()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var first = await CreateServiceAccountAsync(owner, "ingestion-first");
var second = await CreateServiceAccountAsync(owner, "ingestion-second");
using var firstClient = BearerClient(first.RawToken);
using var secondClient = BearerClient(second.RawToken);
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{
Content = JsonContent.Create(new CreateIngestionProposalRequest("service manifest", "service-correlation"))
};
create.Headers.Add("Idempotency-Key", "service-proposal");
var created = await firstClient.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
var foreignDetail = await secondClient.GetAsync($"/api/v1/ingestion-proposals/{proposal!.Id}");
Assert.Equal(HttpStatusCode.NotFound, foreignDetail.StatusCode);
using var foreignAnalysis = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}")
{
Content = JsonContent.Create(new PatchIngestionProposalRequest(null, null, "foreign analysis"))
};
foreignAnalysis.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.NotFound, (await secondClient.SendAsync(foreignAnalysis)).StatusCode);
var ownStatus = await firstClient.GetAsync($"/api/v1/ingestion-proposals/{proposal.Id}");
Assert.Equal(HttpStatusCode.OK, ownStatus.StatusCode);
var safeStatus = await ownStatus.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.Equal("service manifest", safeStatus?.Manifest);
Assert.Empty(safeStatus!.Sources);
using var forbiddenPatch = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}")
{
Content = JsonContent.Create(new PatchIngestionProposalRequest("forbidden", null, null))
};
forbiddenPatch.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.Forbidden, (await firstClient.SendAsync(forbiddenPatch)).StatusCode);
using var analysisPatch = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal.Id}")
{
Content = JsonContent.Create(new PatchIngestionProposalRequest(null, null, "Safe analysis outcome."))
};
analysisPatch.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
var analyzed = await firstClient.SendAsync(analysisPatch);
Assert.Equal(HttpStatusCode.OK, analyzed.StatusCode);
var detail = await analyzed.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(detail);
Assert.Equal("ReadyForReview", detail.Status);
Assert.Equal("service manifest", detail.Manifest);
Assert.Equal("Safe analysis outcome.", detail.Analysis);
Assert.Null(detail.ReviewerComment);
Assert.Null(detail.FailureMessage);
}
[Fact]
public async Task Service_account_with_ingestion_write_cannot_make_a_human_review_decision()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var service = await CreateServiceAccountAsync(owner, "review-denied");
using var serviceClient = BearerClient(service.RawToken);
var created = await CreateProposalAsync(serviceClient, "review-bound manifest", "review-bound", "review-bound-key");
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
Assert.Equal(HttpStatusCode.Forbidden, (await serviceClient.GetAsync("/api/v1/ingestion-proposals")).StatusCode);
foreach (var (route, request) in new[]
{
("request-changes", new IngestionProposalDecisionRequest("review requested")),
("approve", new IngestionProposalDecisionRequest(null)),
("reject", new IngestionProposalDecisionRequest("review rejected"))
})
{
using var decision = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/{route}")
{
Content = JsonContent.Create(request)
};
decision.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.Forbidden, (await serviceClient.SendAsync(decision)).StatusCode);
}
}
[Fact]
public async Task Cookie_reviewer_can_approve_a_ready_for_review_proposal()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var service = await CreateServiceAccountAsync(owner, "review-positive");
using var serviceClient = BearerClient(service.RawToken);
var created = await CreateProposalAsync(serviceClient, "reviewable manifest", "review-positive", "review-positive-key");
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
using var analysis = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposal!.Id}")
{
Content = JsonContent.Create(new PatchIngestionProposalRequest(null, null, "safe review analysis"))
};
analysis.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
var analyzed = await serviceClient.SendAsync(analysis);
var ready = await analyzed.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.Equal(HttpStatusCode.OK, analyzed.StatusCode);
Assert.NotNull(ready);
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", $"\"{ready!.ETag}\"");
var approved = await owner.SendAsync(approve);
var responseBody = await approved.Content.ReadAsStringAsync();
Assert.True(approved.IsSuccessStatusCode,
$"Expected approval to succeed, received {(int)approved.StatusCode} {approved.StatusCode}: {responseBody}");
using var responseJson = JsonDocument.Parse(responseBody);
var status = responseJson.RootElement.GetProperty("status");
Assert.Equal(JsonValueKind.String, status.ValueKind);
Assert.Equal("Approved", status.GetString());
}
[Fact]
public async Task Service_source_upload_audits_the_service_account_with_api_source()
{
using var owner = Factory.CreateClient();
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
var service = await CreateServiceAccountAsync(owner, "service-source-audit");
using var serviceClient = BearerClient(service.RawToken);
var created = await CreateProposalAsync(serviceClient, "source audit manifest", "service-source-audit", "service-source-create");
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
using var multipart = new MultipartFormDataContent();
var sourceBody = new ByteArrayContent("service evidence"u8.ToArray());
sourceBody.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
multipart.Add(sourceBody, "file", "service-evidence.txt");
using var upload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources") { Content = multipart };
upload.Headers.Add("Idempotency-Key", "service-source-upload");
upload.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.OK, (await serviceClient.SendAsync(upload)).StatusCode);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var audit = await db.AuditEvents.SingleAsync(item => item.Action == "ingestion-proposal.source-staged" && item.TargetId == proposal.Id.ToString());
Assert.Equal("ServiceAccount", audit.ActorKind);
Assert.Equal("api", audit.Source);
}
private static async Task<HttpResponseMessage> CreateProposalAsync(HttpClient client, string manifest, string correlationId, string idempotencyKey,
string? requestCorrelation = null)
{
var csrf = client.DefaultRequestHeaders.Authorization is null ? await SetupHelper.GetCsrfTokenAsync(client) : null;
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{
Content = JsonContent.Create(new CreateIngestionProposalRequest(manifest, correlationId))
};
if (csrf is not null) request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("Idempotency-Key", idempotencyKey);
if (requestCorrelation is not null) request.Headers.Add("X-Correlation-ID", requestCorrelation);
return await client.SendAsync(request);
}
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 test", [Scopes.IngestionWrite]));
response.EnsureSuccessStatusCode();
var token = await response.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
return Assert.IsType<CreatedServiceAccountToken>(token);
}
private HttpClient BearerClient(string token)
{
var client = Factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
}
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionProposalSourceLimitApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(
postgres, new Dictionary<string, string?>
{
["IngestionProposal:Enabled"] = "true",
["IngestionProposal:MaximumSourceBytes"] = "4"
})
{
[Fact]
public async Task Source_larger_than_the_configured_limit_is_rejected_before_staging()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var created = await CreateProposalAsync(client, "limit manifest", "limit-correlation", "limit-create");
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var multipart = new MultipartFormDataContent();
var sourceBody = new ByteArrayContent("12345"u8.ToArray());
sourceBody.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
multipart.Add(sourceBody, "file", "too-large.txt");
using var upload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources") { Content = multipart };
upload.Headers.Add(csrf.HeaderName, csrf.Value);
upload.Headers.Add("Idempotency-Key", "limit-source");
upload.Headers.TryAddWithoutValidation("If-Match", created.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await client.SendAsync(upload)).StatusCode);
}
private static async Task<HttpResponseMessage> CreateProposalAsync(HttpClient client, string manifest, string correlationId, string idempotencyKey)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{
Content = JsonContent.Create(new CreateIngestionProposalRequest(manifest, correlationId))
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("Idempotency-Key", idempotencyKey);
return await client.SendAsync(request);
}
}
@@ -0,0 +1,218 @@
using System.Net.Http.Json;
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Application.Ingestion;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion;
/// <summary>
/// Covers the bounded Task 3 surface without registering proposal routes that Task 4
/// owns. The policy probe invokes the application's registered authorization policies
/// against the same claims emitted by the service-token handler.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionProposalAuthorizationTests : OwnerSetupTestBase
{
private readonly PostgresContainerFixture _postgres;
public IngestionProposalAuthorizationTests(PostgresContainerFixture postgres) : base(postgres)
{
_postgres = postgres;
}
[Fact]
public async Task Ingestion_write_scope_satisfies_submission_but_not_human_review()
{
using var scope = Factory.Services.CreateScope();
var authorization = scope.ServiceProvider.GetRequiredService<IAuthorizationService>();
var ingestingService = ServicePrincipal(Scopes.IngestionWrite);
Assert.True((await authorization.AuthorizeAsync(
ingestingService, resource: null, AuthorizationPolicies.IngestionWrite)).Succeeded);
Assert.False((await authorization.AuthorizeAsync(
ingestingService, resource: null, AuthorizationPolicies.IngestionReview)).Succeeded);
}
[Fact]
public async Task Pages_write_scope_cannot_satisfy_ingestion_submission()
{
using var scope = Factory.Services.CreateScope();
var authorization = scope.ServiceProvider.GetRequiredService<IAuthorizationService>();
var pagesService = ServicePrincipal(Scopes.PagesWrite);
Assert.False((await authorization.AuthorizeAsync(
pagesService, resource: null, AuthorizationPolicies.IngestionWrite)).Succeeded);
}
[Fact]
public async Task A_delegated_principal_with_ingestion_write_can_submit_but_cannot_review()
{
using var scope = Factory.Services.CreateScope();
var authorization = scope.ServiceProvider.GetRequiredService<IAuthorizationService>();
var delegatedIdentity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.Name, "delegated-human")
], authenticationType: "delegated-cookie");
var serviceIdentity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new Claim(ServiceTokenAuthenticationHandler.ServiceAccountClaimType, Guid.NewGuid().ToString()),
new Claim(ServiceTokenAuthenticationHandler.ScopeClaimType, Scopes.IngestionWrite)
], ServiceTokenAuthenticationDefaults.Scheme);
var delegatedService = new ClaimsPrincipal([delegatedIdentity, serviceIdentity]);
Assert.True((await authorization.AuthorizeAsync(
delegatedService, resource: null, AuthorizationPolicies.IngestionWrite)).Succeeded);
Assert.False((await authorization.AuthorizeAsync(
delegatedService, resource: null, AuthorizationPolicies.IngestionReview)).Succeeded);
}
[Fact]
public async Task Default_options_disable_ingestion_and_anonymous_session_has_no_features_or_workspace()
{
using var scope = Factory.Services.CreateScope();
var options = scope.ServiceProvider.GetRequiredService<IOptions<IngestionProposalOptions>>().Value;
Assert.False(options.Enabled);
using var client = Factory.CreateClient();
var session = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(session);
Assert.False(session.Authenticated);
Assert.Null(session.Features);
Assert.Null(session.WorkspaceId);
Assert.Null(session.Role);
}
[Fact]
public async Task Authenticated_default_configuration_session_exposes_disabled_ingestion_feature()
{
using var client = Factory.CreateClient();
var login = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
login.EnsureSuccessStatusCode();
var session = await login.Content.ReadFromJsonAsync<SessionResponse>();
Assert.NotNull(session);
Assert.True(session.Authenticated);
Assert.NotNull(session.Features);
Assert.False(session.Features.IngestionProposals);
}
[Fact]
public async Task An_authenticated_human_editor_can_submit_and_review()
{
using var administratorClient = Factory.CreateClient();
var administratorLogin = await SetupHelper.LoginAsync(administratorClient, OwnerEmail, OwnerPassword);
administratorLogin.EnsureSuccessStatusCode();
const string editorEmail = "ingestion-editor@itworx.test";
const string editorPassword = "editor-strong-password";
var createEditor = await SetupHelper.PostWithCsrfAsync(
administratorClient,
"/api/v1/admin/users",
new CreateUserRequest("Ingestion Editor", editorEmail, editorPassword, "Editor"));
createEditor.EnsureSuccessStatusCode();
using var editorClient = Factory.CreateClient();
var editorLogin = await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword);
editorLogin.EnsureSuccessStatusCode();
var session = await editorLogin.Content.ReadFromJsonAsync<SessionResponse>();
Assert.NotNull(session?.UserId);
Assert.Equal("Editor", session.Role);
using var scope = Factory.Services.CreateScope();
var accessor = scope.ServiceProvider.GetRequiredService<IHttpContextAccessor>();
accessor.HttpContext = new DefaultHttpContext { User = HumanPrincipal(session.UserId.Value) };
try
{
var authorization = scope.ServiceProvider.GetRequiredService<IAuthorizationService>();
var human = HumanPrincipal(session.UserId.Value);
Assert.True((await authorization.AuthorizeAsync(
human, resource: null, AuthorizationPolicies.IngestionWrite)).Succeeded);
Assert.True((await authorization.AuthorizeAsync(
human, resource: null, AuthorizationPolicies.IngestionReview)).Succeeded);
}
finally
{
accessor.HttpContext = null;
}
}
[Fact]
public async Task Authenticated_session_exposes_only_the_server_configured_ingestion_feature()
{
var configuredFactory = new KnowledgeHubIntegrationFactory(_postgres,
new Dictionary<string, string?> { ["IngestionProposal:Enabled"] = "true" });
await ((IAsyncLifetime)configuredFactory).InitializeAsync();
try
{
using var client = configuredFactory.CreateClient();
var setup = await SetupHelper.CompleteFirstRunSetupAsync(
client, "Ingestion test", "Owner", "ingestion-owner@itworx.test", "correct-horse-battery-staple");
setup.EnsureSuccessStatusCode();
var login = await SetupHelper.LoginAsync(client, "ingestion-owner@itworx.test", "correct-horse-battery-staple");
login.EnsureSuccessStatusCode();
var session = await login.Content.ReadFromJsonAsync<SessionResponse>();
Assert.NotNull(session);
Assert.True(session.Authenticated);
Assert.NotNull(session.Features);
Assert.True(session.Features.IngestionProposals);
}
finally
{
await ((IAsyncLifetime)configuredFactory).DisposeAsync();
}
}
[Fact]
public async Task Ingestion_write_is_an_accepted_service_account_scope()
{
using var client = Factory.CreateClient();
var login = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
login.EnsureSuccessStatusCode();
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/service-accounts",
new CreateServiceAccountRequest("ingestion", "trusted intake", [Scopes.IngestionWrite]));
response.EnsureSuccessStatusCode();
}
private static ClaimsPrincipal ServicePrincipal(string scope)
{
var identity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new Claim(ServiceTokenAuthenticationHandler.ServiceAccountClaimType, Guid.NewGuid().ToString()),
new Claim(ServiceTokenAuthenticationHandler.ScopeClaimType, scope)
], ServiceTokenAuthenticationDefaults.Scheme);
return new ClaimsPrincipal(identity);
}
private static ClaimsPrincipal HumanPrincipal(Guid userId) => new(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim(ClaimTypes.Name, "human-editor")
], authenticationType: "cookie"));
}
@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Ingestion;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion;
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionProposalEtagContractTests(PostgresContainerFixture postgres)
: OwnerSetupTestBase(postgres, new Dictionary<string, string?> { ["IngestionProposal:Enabled"] = "true" })
{
[Fact]
public async Task Manual_create_upload_submit_uses_the_lowercase_etag_json_field()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createCsrf = await SetupHelper.GetCsrfTokenAsync(client);
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{
Content = JsonContent.Create(new CreateIngestionProposalRequest(
"JSON ETag document", Guid.NewGuid().ToString(),
new IngestionPublicationRequest("ETag contract document", "Original document in attachment", "Reference", "Draft", "CreatePage")))
};
create.Headers.Add(createCsrf.HeaderName, createCsrf.Value);
create.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
using var created = await client.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var proposalId = await ReadProposalIdAsync(created);
var createETag = await ReadFrontendETagAsync(created);
var uploadCsrf = await SetupHelper.GetCsrfTokenAsync(client);
using var multipart = new MultipartFormDataContent();
var file = new ByteArrayContent("Actual uploaded document bytes"u8.ToArray());
file.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
multipart.Add(file, "file", "etag-document.txt");
using var upload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposalId}/sources")
{
Content = multipart
};
upload.Headers.Add(uploadCsrf.HeaderName, uploadCsrf.Value);
upload.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
upload.Headers.TryAddWithoutValidation("If-Match", $"\"{createETag.Value}\"");
// The browser reads `etag` from JSON and sends that value as If-Match. If
// the API only emits `eTag`, this request reproduces the observed 412.
using var uploaded = await client.SendAsync(upload);
Assert.Equal(HttpStatusCode.OK, uploaded.StatusCode);
Assert.True(createETag.Found, "Create JSON must expose the frontend contract field `etag`.");
var uploadETag = await ReadFrontendETagAsync(uploaded);
Assert.True(uploadETag.Found, "Upload JSON must expose the frontend contract field `etag`.");
var submitCsrf = await SetupHelper.GetCsrfTokenAsync(client);
using var submit = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposalId}/submit")
{
Content = JsonContent.Create(new { })
};
submit.Headers.Add(submitCsrf.HeaderName, submitCsrf.Value);
submit.Headers.TryAddWithoutValidation("If-Match", $"\"{uploadETag.Value}\"");
using var submitted = await client.SendAsync(submit);
Assert.Equal(HttpStatusCode.OK, submitted.StatusCode);
Assert.True((await ReadFrontendETagAsync(submitted)).Found, "Submit JSON must expose the frontend contract field `etag`.");
using var submittedJson = JsonDocument.Parse(await submitted.Content.ReadAsStringAsync());
Assert.Equal("ReadyForReview", submittedJson.RootElement.GetProperty("status").GetString());
}
private static async Task<(bool Found, string Value)> ReadFrontendETagAsync(HttpResponseMessage response)
{
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
if (json.RootElement.TryGetProperty("etag", out var etag) && etag.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(etag.GetString()))
{
return (true, etag.GetString()!);
}
return (false, "undefined");
}
private static async Task<Guid> ReadProposalIdAsync(HttpResponseMessage response)
{
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return json.RootElement.GetProperty("id").GetGuid();
}
}
@@ -0,0 +1,160 @@
using ItWorx.KnowledgeHub.Application.Common;
using ItWorx.KnowledgeHub.Application.Ingestion;
using ItWorx.KnowledgeHub.Domain.Ingestion;
using ItWorx.KnowledgeHub.Infrastructure.Ingestion;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionProposalPersistenceTests(PostgresContainerFixture postgres)
{
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid ActorId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private const string Manifest = "# Proposed knowledge\n\nSafe manifest metadata.";
private const string Digest = "c8c86d360bfb7bb0d036b7352bf89714946fb527c4074340752259109371f429";
[Fact]
public async Task Proposal_preserves_digest_and_unique_key()
{
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(target.ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
await using (var db = new KnowledgeHubDbContext(options))
{
var repository = new IngestionProposalRepository(db);
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "correlation-2", Manifest);
proposal.AddSource("proposal.pdf", "application/pdf", 42, Digest, "clean", Guid.NewGuid());
proposal.AssignIdempotencyKey("key-2");
repository.Add(proposal);
await repository.SaveChangesAsync(CancellationToken.None);
}
await using (var db = new KnowledgeHubDbContext(options))
{
var repository = new IngestionProposalRepository(db);
var roundTripped = await repository.FindByIdempotencyKeyAsync(WorkspaceId, "key-2", CancellationToken.None);
Assert.NotNull(roundTripped);
var source = Assert.Single(roundTripped.Sources);
Assert.Equal(Digest, source.DigestSha256);
Assert.Equal("proposal.pdf", source.FileName);
Assert.Equal("application/pdf", source.MimeType);
Assert.Equal(42, source.ByteSize);
Assert.Equal("clean", source.ScanResult);
Assert.Single(roundTripped.Revisions);
}
await Assert.ThrowsAsync<DbUpdateException>(async () =>
{
await using var db = new KnowledgeHubDbContext(options);
var repository = new IngestionProposalRepository(db);
var duplicate = IngestionProposal.Create(WorkspaceId, ActorId, "correlation-duplicate", Manifest);
duplicate.AssignIdempotencyKey("key-2");
repository.Add(duplicate);
await repository.SaveChangesAsync(CancellationToken.None);
});
}
[Fact]
public async Task Repository_scopes_reads_etags_updates_and_bounded_lists_to_workspace()
{
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(target.ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
var otherWorkspaceId = Guid.NewGuid();
Guid proposalId;
await using (var db = new KnowledgeHubDbContext(options))
{
var repository = new IngestionProposalRepository(db);
var first = IngestionProposal.Create(WorkspaceId, ActorId, "first", Manifest);
first.AssignIdempotencyKey("queue-1");
repository.Add(first);
var second = IngestionProposal.Create(WorkspaceId, ActorId, "second", Manifest);
second.AssignIdempotencyKey("queue-2");
repository.Add(second);
var other = IngestionProposal.Create(otherWorkspaceId, ActorId, "other", Manifest);
other.AssignIdempotencyKey("other-1");
repository.Add(other);
await repository.SaveChangesAsync(CancellationToken.None);
proposalId = first.Id;
}
await using (var db = new KnowledgeHubDbContext(options))
{
var repository = new IngestionProposalRepository(db);
var page = await repository.ListAsync(new IngestionProposalQuery(WorkspaceId, Limit: 1), CancellationToken.None);
Assert.Single(page.Items);
Assert.NotNull(page.NextCursor);
Assert.Null(await repository.FindAsync(otherWorkspaceId, proposalId, CancellationToken.None));
Assert.Null(await repository.GetETagAsync(otherWorkspaceId, proposalId, CancellationToken.None));
Assert.Null(await repository.FindForUpdateAsync(otherWorkspaceId, proposalId, "0", CancellationToken.None));
Assert.DoesNotContain((await repository.ListAsync(new IngestionProposalQuery(otherWorkspaceId, Limit: 100), CancellationToken.None)).Items,
proposal => proposal.WorkspaceId == WorkspaceId);
}
}
[Fact]
public async Task Repository_translates_a_stale_xmin_to_an_application_concurrency_conflict()
{
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(target.ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
Guid proposalId;
await using (var db = new KnowledgeHubDbContext(options))
{
var repository = new IngestionProposalRepository(db);
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "stale-xmin", Manifest);
proposal.AssignIdempotencyKey("stale-xmin-key");
repository.Add(proposal);
await repository.SaveChangesAsync(CancellationToken.None);
proposalId = proposal.Id;
}
await using var staleDb = new KnowledgeHubDbContext(options);
var staleRepository = new IngestionProposalRepository(staleDb);
var staleEtag = await staleRepository.GetETagAsync(WorkspaceId, proposalId, CancellationToken.None);
await using (var currentDb = new KnowledgeHubDbContext(options))
{
var currentRepository = new IngestionProposalRepository(currentDb);
var currentEtag = await currentRepository.GetETagAsync(WorkspaceId, proposalId, CancellationToken.None);
var current = await currentRepository.FindForUpdateAsync(WorkspaceId, proposalId, currentEtag!, CancellationToken.None);
Assert.NotNull(current);
current.AddSource("current-source.pdf", "application/pdf", 42, Digest, "clean", Guid.NewGuid());
current.BeginAnalysis(ActorId, DateTimeOffset.UtcNow);
await currentRepository.SaveChangesAsync(CancellationToken.None);
}
await using (var verificationDb = new KnowledgeHubDbContext(options))
{
var verificationRepository = new IngestionProposalRepository(verificationDb);
var updated = await verificationRepository.FindAsync(WorkspaceId, proposalId, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal(IngestionProposalStatus.Analyzing, updated.Status);
Assert.Contains(updated.Revisions, revision => revision.Operation == "analysis-started");
Assert.Contains(updated.Sources, source => source.FileName == "current-source.pdf");
}
var stale = await staleRepository.FindForUpdateAsync(WorkspaceId, proposalId, staleEtag!, CancellationToken.None);
Assert.NotNull(stale);
stale.ApplyAnalysis("stale analysis", ActorId, DateTimeOffset.UtcNow);
var conflict = await Assert.ThrowsAsync<ConcurrencyConflictException>(
() => staleRepository.SaveChangesAsync(CancellationToken.None));
Assert.Equal(proposalId, conflict.EntityId);
Assert.Empty(staleDb.ChangeTracker.Entries());
}
}
@@ -0,0 +1,184 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Ingestion;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion;
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionProposalPrivacyTests(PostgresContainerFixture postgres)
: OwnerSetupTestBase(postgres, new Dictionary<string, string?> { ["IngestionProposal:Enabled"] = "true" })
{
private const string EditorPassword = "strong-editor-password";
private const string ReaderPassword = "strong-reader-password";
[Fact]
public async Task Proposal_details_changes_and_source_bytes_follow_submitter_and_review_roles()
{
using var administrator = Factory.CreateClient();
await SetupHelper.LoginAsync(administrator, OwnerEmail, OwnerPassword);
using var submitter = await CreateUserAsync(administrator, "indiener@itworx.test", "Editor", EditorPassword);
using var otherEditor = await CreateUserAsync(administrator, "reviewer@itworx.test", "Editor", EditorPassword);
using var reader = await CreateUserAsync(administrator, "lezer@itworx.test", "Reader", ReaderPassword);
var created = await CreateProposalAsync(submitter);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var proposal = await created.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.NotNull(proposal);
Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(submitter, proposal.Id)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(administrator, proposal.Id)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await GetProposalAsync(otherEditor, proposal.Id)).StatusCode);
var submitterReceivedQueue = await submitter.GetFromJsonAsync<IngestionProposalListResponse>("/api/v1/ingestion-proposals?status=Received");
Assert.Equal(proposal.Id, Assert.Single(submitterReceivedQueue!.Items).Id);
var administratorReceivedQueue = await administrator.GetFromJsonAsync<IngestionProposalListResponse>("/api/v1/ingestion-proposals?status=Received");
Assert.Equal(proposal.Id, Assert.Single(administratorReceivedQueue!.Items).Id);
var foreignReceivedQueue = await otherEditor.GetFromJsonAsync<IngestionProposalListResponse>("/api/v1/ingestion-proposals?status=Received");
Assert.Empty(foreignReceivedQueue!.Items);
Assert.Equal(0, foreignReceivedQueue.Total);
using (var foreignPatch = await PatchProposalAsync(otherEditor, proposal.Id, created.Headers.ETag!.Tag, "not allowed"))
Assert.Equal(HttpStatusCode.NotFound, foreignPatch.StatusCode);
using var ownerPatch = await PatchProposalAsync(submitter, proposal.Id, created.Headers.ETag.Tag, "owner edit");
Assert.Equal(HttpStatusCode.OK, ownerPatch.StatusCode);
using var adminPatch = await PatchProposalAsync(administrator, proposal.Id, ownerPatch.Headers.ETag!.Tag, "admin edit");
Assert.Equal(HttpStatusCode.OK, adminPatch.StatusCode);
var staged = await UploadSourceAsync(submitter, proposal.Id, adminPatch.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.OK, staged.StatusCode);
var stagedDetail = await staged.Content.ReadFromJsonAsync<IngestionProposalDetail>();
var source = Assert.Single(stagedDetail!.Sources);
var sourceUrl = $"/api/v1/attachments/{source.AttachmentReferenceId}/content";
// A received proposal and its original bytes remain private to the submitter
// and administrators, even from another workspace Editor.
Assert.Equal(HttpStatusCode.OK, (await submitter.GetAsync(sourceUrl)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await administrator.GetAsync(sourceUrl)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await otherEditor.GetAsync(sourceUrl)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await reader.GetAsync(sourceUrl)).StatusCode);
using var submitted = await SubmitAsync(submitter, proposal.Id, staged.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.OK, submitted.StatusCode);
var ready = await submitted.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.Equal("ReadyForReview", ready?.Status);
// During review, workspace Editors retain the established reviewer access.
Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(otherEditor, proposal.Id)).StatusCode);
var reviewerQueue = await otherEditor.GetFromJsonAsync<IngestionProposalListResponse>("/api/v1/ingestion-proposals?status=ReadyForReview");
Assert.Contains(reviewerQueue!.Items, item => item.Id == proposal.Id);
Assert.Equal(HttpStatusCode.OK, (await otherEditor.GetAsync(sourceUrl)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await reader.GetAsync(sourceUrl)).StatusCode);
using var reviewerPatch = await PatchProposalAsync(otherEditor, proposal.Id, submitted.Headers.ETag!.Tag, "reviewer edit");
Assert.Equal(HttpStatusCode.OK, reviewerPatch.StatusCode);
using var changesRequested = await RequestChangesAsync(otherEditor, proposal.Id, reviewerPatch.Headers.ETag!.Tag);
Assert.Equal(HttpStatusCode.OK, changesRequested.StatusCode);
var changed = await changesRequested.Content.ReadFromJsonAsync<IngestionProposalDetail>();
Assert.Equal("ChangesRequested", changed?.Status);
// Once returned for changes, proposal metadata and source bytes are private
// to the submitter and administrators again.
Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(submitter, proposal.Id)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(administrator, proposal.Id)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await GetProposalAsync(otherEditor, proposal.Id)).StatusCode);
var submitterChangesQueue = await submitter.GetFromJsonAsync<IngestionProposalListResponse>("/api/v1/ingestion-proposals?status=ChangesRequested");
Assert.Contains(submitterChangesQueue!.Items, item => item.Id == proposal.Id);
var administratorChangesQueue = await administrator.GetFromJsonAsync<IngestionProposalListResponse>("/api/v1/ingestion-proposals?status=ChangesRequested");
Assert.Contains(administratorChangesQueue!.Items, item => item.Id == proposal.Id);
var foreignChangesQueue = await otherEditor.GetFromJsonAsync<IngestionProposalListResponse>("/api/v1/ingestion-proposals?status=ChangesRequested");
Assert.DoesNotContain(foreignChangesQueue!.Items, item => item.Id == proposal.Id);
Assert.Equal(0, foreignChangesQueue.Total);
Assert.Equal(HttpStatusCode.NotFound, (await otherEditor.GetAsync(sourceUrl)).StatusCode);
using (var foreignChangesPatch = await PatchProposalAsync(otherEditor, proposal.Id, changesRequested.Headers.ETag!.Tag, "not allowed"))
Assert.Equal(HttpStatusCode.NotFound, foreignChangesPatch.StatusCode);
using var submitterChangesPatch = await PatchProposalAsync(submitter, proposal.Id, changesRequested.Headers.ETag.Tag, "submitter update");
Assert.Equal(HttpStatusCode.OK, submitterChangesPatch.StatusCode);
using var adminChangesPatch = await PatchProposalAsync(administrator, proposal.Id, submitterChangesPatch.Headers.ETag!.Tag, "admin update");
Assert.Equal(HttpStatusCode.OK, adminChangesPatch.StatusCode);
Assert.Equal(HttpStatusCode.OK, (await administrator.GetAsync(sourceUrl)).StatusCode);
}
private async Task<HttpClient> CreateUserAsync(HttpClient administrator, string email, string role, string password)
{
var response = await SetupHelper.PostWithCsrfAsync(administrator, "/api/v1/admin/users",
new CreateUserRequest(email, email, password, role));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, email, password);
return client;
}
private static async Task<HttpResponseMessage> CreateProposalAsync(HttpClient client)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
{
Content = JsonContent.Create(new CreateIngestionProposalRequest(
"Private source manifest", Guid.NewGuid().ToString(),
new IngestionPublicationRequest("Private source", "Source summary", "Reference", "Draft", "CreatePage")))
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
return await client.SendAsync(request);
}
private static Task<HttpResponseMessage> GetProposalAsync(HttpClient client, Guid proposalId) =>
client.GetAsync($"/api/v1/ingestion-proposals/{proposalId}");
private static async Task<HttpResponseMessage> PatchProposalAsync(HttpClient client, Guid proposalId, string etag, string manifest)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposalId}")
{
Content = JsonContent.Create(new PatchIngestionProposalRequest(manifest, null))
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.TryAddWithoutValidation("If-Match", etag);
return await client.SendAsync(request);
}
private static async Task<HttpResponseMessage> UploadSourceAsync(HttpClient client, Guid proposalId, string etag)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var form = new MultipartFormDataContent();
var source = new ByteArrayContent("unpublished document bytes"u8.ToArray());
source.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(source, "file", "private-source.txt");
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposalId}/sources")
{
Content = form
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
request.Headers.TryAddWithoutValidation("If-Match", etag);
return await client.SendAsync(request);
}
private static async Task<HttpResponseMessage> SubmitAsync(HttpClient client, Guid proposalId, string etag)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposalId}/submit")
{
Content = JsonContent.Create(new { })
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.TryAddWithoutValidation("If-Match", etag);
return await client.SendAsync(request);
}
private static async Task<HttpResponseMessage> RequestChangesAsync(HttpClient client, Guid proposalId, string etag)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposalId}/request-changes")
{
Content = JsonContent.Create(new IngestionProposalDecisionRequest("Please revise."))
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.TryAddWithoutValidation("If-Match", etag);
return await client.SendAsync(request);
}
}
@@ -0,0 +1,259 @@
using System.Security.Cryptography;
using ItWorx.KnowledgeHub.Application.Attachments;
using ItWorx.KnowledgeHub.Application.Attachments.UseCases;
using ItWorx.KnowledgeHub.Application.Audit;
using ItWorx.KnowledgeHub.Application.Ingestion;
using ItWorx.KnowledgeHub.Application.Ingestion.UseCases;
using ItWorx.KnowledgeHub.Domain.Attachments;
using ItWorx.KnowledgeHub.Domain.Ingestion;
namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion;
/// <summary>
/// Does not use the PostgreSQL fixture: the repository port deliberately provides a
/// controllable save-failure seam around the real upload pipeline. It models the
/// database rollback that happens when the shared DbContext save fails after the
/// physical blob commit.
/// </summary>
public sealed class IngestionProposalSourceCompensationTests
{
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 Failed_shared_database_save_after_a_new_blob_commit_compensates_every_staged_artifact()
{
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "compensation", "safe manifest");
var attachments = new TransactionalAttachmentRepository();
var audits = new TransactionalAuditRecorder();
var proposals = new FailingSaveIngestionProposalRepository(proposal, attachments, audits);
var blobs = new MemoryBlobStore();
var uploads = new UploadAttachmentUseCase(
pages: null!,
stagingSessions: null!,
ingestionProposals: proposals,
projects: null!,
attachments,
blobs,
new PlainTextSignatureDetector(),
audits,
new AttachmentContentLock(),
TimeProvider.System);
var useCase = new StageIngestionProposalSourceUseCase(
proposals,
attachments,
blobs,
uploads,
audits,
new AttachmentContentLock(),
TimeProvider.System);
var content = "post-blob save failure"u8.ToArray();
var digest = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
await using var stream = new MemoryStream(content);
await Assert.ThrowsAsync<InvalidOperationException>(() => useCase.ExecuteAsync(
new StageIngestionProposalSourceCommand(
WorkspaceId,
proposal.Id,
ActorId,
"evidence.txt",
"text/plain",
stream,
"compensation-test",
MaximumSourceCount: 5,
ExpectedETag: "1",
AuditActorKind.ServiceAccount,
AuditSource: "api"),
CancellationToken.None));
Assert.Equal(1, proposals.SaveAttempts);
Assert.Equal(1, proposals.PendingSourceCountAtFailedSave);
Assert.Equal(0, proposals.PersistedSourceCount);
Assert.Empty(attachments.Blobs);
Assert.Empty(attachments.References);
Assert.Empty(attachments.Usages);
Assert.Empty(audits.Events);
Assert.False(blobs.Exists(digest));
Assert.Equal([digest], blobs.DeletedHashes);
}
private sealed class FailingSaveIngestionProposalRepository(
IngestionProposal proposal,
TransactionalAttachmentRepository attachments,
TransactionalAuditRecorder audits) : IIngestionProposalRepository
{
public int SaveAttempts { get; private set; }
public int PendingSourceCountAtFailedSave { get; private set; }
// This is the durable snapshot; it changes only after a successful shared
// save, exactly like a fresh DbContext query after the database rollback.
public int PersistedSourceCount { 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 ? "1" : null);
public Task<IngestionProposal?> FindForUpdateAsync(Guid workspaceId, Guid proposalId, string expectedETag, CancellationToken cancellationToken) =>
Task.FromResult<IngestionProposal?>(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id && expectedETag == "1" ? proposal : null);
public void Add(IngestionProposal item) => throw new NotSupportedException();
public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
{
SaveAttempts += 1;
PendingSourceCountAtFailedSave = proposal.Sources.Count;
attachments.RollBack();
audits.RollBack();
return Task.FromException<int>(new InvalidOperationException("Forced shared DbContext save failure."));
}
}
private sealed class TransactionalAttachmentRepository : IAttachmentRepository
{
private readonly List<AttachmentBlob> blobs = [];
private readonly List<AttachmentReference> references = [];
private readonly List<AttachmentUsage> usages = [];
public IReadOnlyList<AttachmentBlob> Blobs => blobs;
public IReadOnlyList<AttachmentReference> References => references;
public IReadOnlyList<AttachmentUsage> Usages => usages;
public Task<AttachmentBlob?> FindBlobAsync(string sha256, CancellationToken cancellationToken) =>
Task.FromResult(blobs.SingleOrDefault(blob => blob.Sha256 == sha256));
public void AddBlob(AttachmentBlob blob) => blobs.Add(blob);
public Task<AttachmentReference?> FindReferenceAsync(Guid referenceId, CancellationToken cancellationToken) =>
Task.FromResult(references.SingleOrDefault(reference => reference.Id == referenceId));
public void AddReference(AttachmentReference reference) => references.Add(reference);
public void AddUsage(AttachmentUsage usage) => usages.Add(usage);
public Task<IReadOnlyList<AttachmentReference>> GetReferencesForPageAsync(Guid pageId, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<AttachmentReference>>([]);
public Task<IReadOnlyDictionary<string, AttachmentBlob>> FindBlobsAsync(IReadOnlyCollection<string> sha256Hashes, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyDictionary<string, AttachmentBlob>>(new Dictionary<string, AttachmentBlob>());
public Task<AttachmentUsage?> FindUsageForReferenceAsync(Guid attachmentReferenceId, CancellationToken cancellationToken) =>
Task.FromResult(usages.SingleOrDefault(usage => usage.AttachmentReferenceId == attachmentReferenceId));
public Task<IReadOnlyList<string>> FindUnreferencedBlobHashesAsync(IReadOnlyCollection<string> candidateHashes, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<string>>(candidateHashes.Where(hash => references.All(reference => reference.BlobSha256 != hash)).ToArray());
public Task<int> RemoveBlobRecordsAsync(IReadOnlyCollection<string> sha256Hashes, CancellationToken cancellationToken) =>
Task.FromResult(0);
public Task<IReadOnlyList<AttachmentUsage>> GetUsagesForStagingSessionAsync(Guid stagingSessionId, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<AttachmentUsage>>([]);
public Task TransferProposalUsagesToPageAsync(Guid ingestionProposalId, Guid pageId, CancellationToken cancellationToken)
{
foreach (var usage in usages.Where(usage => usage.IngestionProposalId == ingestionProposalId))
{
usage.TransferProposalToPage(pageId);
}
return Task.CompletedTask;
}
public Task<StagingAttachmentCleanupResult> RemoveOrphanedStagingAttachmentsAsync(Guid stagingSessionId, CancellationToken cancellationToken) =>
Task.FromResult(StagingAttachmentCleanupResult.None);
public Task SaveChangesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void RollBack()
{
blobs.Clear();
references.Clear();
usages.Clear();
}
}
private sealed class TransactionalAuditRecorder : IAuditEventRecorder
{
private readonly List<AuditEventRecord> events = [];
public IReadOnlyList<AuditEventRecord> Events => events;
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 MemoryBlobStore : IAttachmentBlobStore
{
private readonly Dictionary<string, byte[]> blobs = new(StringComparer.Ordinal);
private readonly Dictionary<string, byte[]> quarantined = new(StringComparer.Ordinal);
public IReadOnlyList<string> DeletedHashes => deletedHashes;
private readonly List<string> deletedHashes = [];
public bool Exists(string sha256) => blobs.ContainsKey(sha256);
public async Task<QuarantinedUpload> WriteToQuarantineAsync(Stream content, CancellationToken cancellationToken)
{
using var bytes = new MemoryStream();
await content.CopyToAsync(bytes, cancellationToken);
var value = bytes.ToArray();
var sha256 = Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
var token = Guid.NewGuid().ToString("N");
quarantined.Add(token, value);
return new QuarantinedUpload(token, sha256, value.LongLength);
}
public Task<byte[]> ReadQuarantinePrefixAsync(string quarantineToken, int maxBytes, CancellationToken cancellationToken) =>
Task.FromResult(quarantined[quarantineToken].Take(maxBytes).ToArray());
public Task<bool> BlobExistsAsync(string sha256, CancellationToken cancellationToken) => Task.FromResult(Exists(sha256));
public Task CommitAsync(string quarantineToken, string sha256, CancellationToken cancellationToken)
{
blobs.Add(sha256, quarantined[quarantineToken]);
quarantined.Remove(quarantineToken);
return Task.CompletedTask;
}
public Task DiscardQuarantineAsync(string quarantineToken, CancellationToken cancellationToken)
{
quarantined.Remove(quarantineToken);
return Task.CompletedTask;
}
public Task<Stream> OpenReadAsync(string sha256, CancellationToken cancellationToken) =>
Task.FromResult<Stream>(new MemoryStream(blobs[sha256], writable: false));
public Task<bool> DeleteVerifiedAsync(string sha256, CancellationToken cancellationToken)
{
var removed = blobs.Remove(sha256);
if (removed)
{
deletedHashes.Add(sha256);
}
return Task.FromResult(removed);
}
}
private sealed class PlainTextSignatureDetector : IContentSignatureDetector
{
public SignatureDetectionResult Detect(ReadOnlySpan<byte> prefix, string? declaredContentType, string? extension) =>
new("text/plain", AttachmentDeliveryPolicy.DownloadOnly, DeclaredTypeMismatch: false);
}
}
@@ -0,0 +1,304 @@
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>());
}
}
@@ -0,0 +1,179 @@
using System.Diagnostics.Metrics;
using ItWorx.KnowledgeHub.Application.Attachments;
using ItWorx.KnowledgeHub.Application.Audit;
using ItWorx.KnowledgeHub.Application.Ingestion;
using ItWorx.KnowledgeHub.Application.Ingestion.UseCases;
using ItWorx.KnowledgeHub.Domain.Attachments;
using ItWorx.KnowledgeHub.Domain.Ingestion;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Attachments;
using ItWorx.KnowledgeHub.Infrastructure.Ingestion;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using ItWorx.KnowledgeHub.IntegrationTests.Operations;
using Microsoft.EntityFrameworkCore;
namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion;
/// <summary>
/// PostgreSQL persistence coverage for source retention. These tests deliberately use
/// separate contexts around the candidate query, so the repository's deletion-time
/// ownership recheck is proved against committed state rather than a port fake.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class IngestionSourceRetentionPersistenceTests(PostgresContainerFixture postgres)
{
private static readonly DateTimeOffset Now = new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero);
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid ActorId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private const string Digest = "c8c86d360bfb7bb0d036b7352bf89714946fb527c4074340752259109371f429";
[Fact]
public async Task Cleanup_removes_persisted_expired_usage_and_reference_but_retains_the_physical_blob_record()
{
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
var options = CreateOptions(target.ConnectionString);
var seeded = await SeedExpiredSourceAsync(options);
await using (var db = new KnowledgeHubDbContext(options))
{
var cleanup = CreateCleanup(new IngestionProposalRepository(db), new AttachmentRepository(db));
var result = await cleanup.ExecuteAsync(CancellationToken.None);
Assert.Equal(new IngestionSourceRetentionCleanupResult(1, 1), result);
}
await using (var db = new KnowledgeHubDbContext(options))
{
Assert.Null(await db.AttachmentUsages.SingleOrDefaultAsync(usage => usage.Id == seeded.UsageId));
Assert.Null(await db.AttachmentReferences.SingleOrDefaultAsync(reference => reference.Id == seeded.ReferenceId));
Assert.NotNull(await db.AttachmentBlobs.SingleOrDefaultAsync(blob => blob.Sha256 == Digest));
}
}
[Fact]
public async Task Cleanup_staged_usage_and_reference_removal_rolls_back_without_a_committed_transaction()
{
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
var options = CreateOptions(target.ConnectionString);
var seeded = await SeedExpiredSourceAsync(options);
await using (var db = new KnowledgeHubDbContext(options))
{
var retention = new IngestionProposalRepository(db);
var candidate = Assert.Single(await retention.FindExpiredProposalSourcesAsync(Now.AddDays(-30), 100, CancellationToken.None));
var attachments = new AttachmentRepository(db);
await using var transaction = await db.Database.BeginTransactionAsync(CancellationToken.None);
Assert.Equal(new ProposalSourceAttachmentCleanupResult(true, true),
await attachments.RemoveExpiredProposalSourceUsageAsync(candidate, CancellationToken.None));
await db.SaveChangesAsync(CancellationToken.None);
await transaction.RollbackAsync(CancellationToken.None);
}
await using (var db = new KnowledgeHubDbContext(options))
{
Assert.NotNull(await db.AttachmentUsages.SingleOrDefaultAsync(usage => usage.Id == seeded.UsageId));
Assert.NotNull(await db.AttachmentReferences.SingleOrDefaultAsync(reference => reference.Id == seeded.ReferenceId));
Assert.NotNull(await db.AttachmentBlobs.SingleOrDefaultAsync(blob => blob.Sha256 == Digest));
}
}
[Fact]
public async Task Cleanup_rechecks_committed_ownership_and_preserves_a_candidate_published_and_transferred_to_a_page()
{
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
var options = CreateOptions(target.ConnectionString);
var seeded = await SeedExpiredSourceAsync(options);
IngestionSourceRetentionCandidate candidate;
Guid publishedPageId;
await using (var db = new KnowledgeHubDbContext(options))
{
var retention = new IngestionProposalRepository(db);
candidate = Assert.Single(await retention.FindExpiredProposalSourcesAsync(Now.AddDays(-30), 100, CancellationToken.None));
}
await using (var db = new KnowledgeHubDbContext(options))
{
var proposal = await db.IngestionProposals.SingleAsync(item => item.Id == seeded.ProposalId);
var page = Page.Create(WorkspaceId, null, SortKey.First(), PageTitle.Create("Published evidence"),
DocumentType.WikiPage, DocumentStatus.Active, "# Published evidence", ActorId, Now);
var usage = await db.AttachmentUsages.SingleAsync(item => item.Id == seeded.UsageId);
proposal.SetPublication(IngestionPublication.Create(IngestionMutationKind.CreatePage, "Published evidence",
"# Published evidence", "WikiPage", "Active"), ActorId, Now);
proposal.BeginAnalysis(ActorId, Now);
proposal.ApplyAnalysis("Reviewed analysis", ActorId, Now);
proposal.Approve(ActorId, Now);
proposal.BeginPublishing(ActorId, Now);
proposal.MarkPublished(ActorId, page.Id, page.CurrentRevisionNumber, "retention-race", Now);
usage.TransferProposalToPage(page.Id);
db.Pages.Add(page);
await db.SaveChangesAsync(CancellationToken.None);
publishedPageId = page.Id;
}
await using (var db = new KnowledgeHubDbContext(options))
{
var cleanup = new AttachmentRepository(db);
var result = await cleanup.RemoveExpiredProposalSourceUsageAsync(candidate, CancellationToken.None);
Assert.Equal(ProposalSourceAttachmentCleanupResult.None, result);
await db.SaveChangesAsync(CancellationToken.None);
}
await using (var db = new KnowledgeHubDbContext(options))
{
var usage = await db.AttachmentUsages.SingleAsync(item => item.Id == seeded.UsageId);
Assert.Equal(publishedPageId, usage.PageId);
Assert.Null(usage.IngestionProposalId);
Assert.NotNull(await db.AttachmentReferences.SingleOrDefaultAsync(reference => reference.Id == seeded.ReferenceId));
Assert.NotNull(await db.AttachmentBlobs.SingleOrDefaultAsync(blob => blob.Sha256 == Digest));
}
}
private static DbContextOptions<KnowledgeHubDbContext> CreateOptions(string connectionString) =>
new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
private static CleanupExpiredIngestionSourcesUseCase CreateCleanup(
IIngestionSourceRetentionRepository retention,
IAttachmentRepository attachments) =>
new(retention, attachments, new AuditRecorder(), new IngestionMetrics(new Meter("ingestion-retention-persistence")),
new FixedTimeProvider(Now));
private static async Task<SeededSource> SeedExpiredSourceAsync(DbContextOptions<KnowledgeHubDbContext> options)
{
await using var db = new KnowledgeHubDbContext(options);
var reference = AttachmentReference.Create(WorkspaceId, Digest, "evidence.txt", ActorId, Now.AddDays(-31));
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "retention-persistence", "safe manifest");
proposal.AddSource("evidence.txt", "text/plain", 42, Digest, "clean", reference.Id);
var usage = AttachmentUsage.ForIngestionProposal(reference.Id, proposal.Id, Now.AddDays(-31));
db.AttachmentBlobs.Add(AttachmentBlob.Create(Digest, 42, "text/plain", AttachmentDeliveryPolicy.DownloadOnly, Now.AddDays(-31)));
db.AttachmentReferences.Add(reference);
db.IngestionProposals.Add(proposal);
db.AttachmentUsages.Add(usage);
await db.SaveChangesAsync(CancellationToken.None);
return new SeededSource(proposal.Id, proposal.Sources.Single().Id, reference.Id, usage.Id);
}
private sealed record SeededSource(Guid ProposalId, Guid SourceId, Guid ReferenceId, Guid UsageId);
private sealed class AuditRecorder : IAuditEventRecorder
{
public void Add(AuditEventRecord auditEvent) { }
public Task RecordAsync(AuditEventRecord auditEvent, CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
}
@@ -0,0 +1,168 @@
using System.Diagnostics.Metrics;
using ItWorx.KnowledgeHub.Application.Attachments;
using ItWorx.KnowledgeHub.Application.Audit;
using ItWorx.KnowledgeHub.Application.Ingestion;
using ItWorx.KnowledgeHub.Application.Ingestion.UseCases;
using ItWorx.KnowledgeHub.Domain.Attachments;
namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion;
/// <summary>Docker-free retention tests. The in-memory ports model the same shared
/// scoped unit of work used by the EF repositories, while making the safety boundary
/// and audit/metric payloads independently observable.</summary>
public sealed class IngestionSourceRetentionTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Cleanup_removes_only_expired_proposal_source_usage_and_keeps_safe_audit_metadata()
{
var expired = Source(Now.AddDays(-31));
var active = Source(Now);
var published = Source(Now.AddDays(-31));
var retained = Source(Now.AddDays(-31));
var retention = new RetentionRepository([expired, active, published, retained]);
var attachments = new Attachments([
new(expired, isProposalOwned: true, hasOtherUsage: false),
new(active, isProposalOwned: true, hasOtherUsage: false),
new(published, isProposalOwned: false, hasOtherUsage: true),
new(retained, isProposalOwned: true, hasOtherUsage: true)
]);
var audits = new Audits();
var metrics = new IngestionMetrics(new Meter("retention-test-metrics"));
var cleanup = new CleanupExpiredIngestionSourcesUseCase(retention, attachments, audits, metrics, new FixedTimeProvider(Now));
var result = await cleanup.ExecuteAsync(CancellationToken.None);
Assert.Equal(2, result.SourceUsagesRemoved);
Assert.Equal(1, result.AttachmentReferencesRemoved);
Assert.False(attachments.Exists(expired.AttachmentReferenceId));
Assert.True(attachments.Exists(active.AttachmentReferenceId));
Assert.True(attachments.Exists(published.AttachmentReferenceId));
Assert.True(attachments.Exists(retained.AttachmentReferenceId));
Assert.Equal(2, audits.Events.Count);
Assert.All(audits.Events, audit =>
{
Assert.Equal("ingestion-source.retained-reference-removed", audit.Action);
Assert.Equal("Expired proposal source reference removed.", audit.Summary);
var serializedAudit = string.Join(' ', audit.Action, audit.TargetType, audit.TargetId, audit.Summary, audit.Source, audit.CorrelationId);
Assert.DoesNotContain("source-body", serializedAudit, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("C:\\private\\intake", serializedAudit, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("https://private.example", serializedAudit, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("token=secret", serializedAudit, StringComparison.OrdinalIgnoreCase);
});
}
[Fact]
public async Task Cleanup_emits_only_bounded_ingestion_metric_labels()
{
var expired = Source(Now.AddDays(-31));
var retention = new RetentionRepository([expired]);
var attachments = new Attachments([new(expired, isProposalOwned: true, hasOtherUsage: false)]);
var audits = new Audits();
var meter = new Meter("retention-metrics-contract");
var metrics = new IngestionMetrics(meter);
var observedTags = new List<KeyValuePair<string, object?>>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, currentListener) =>
{
if (instrument.Meter == meter)
currentListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, _, tags, _) =>
{
if (instrument.Meter == meter)
observedTags.AddRange(tags.ToArray());
});
listener.Start();
var cleanup = new CleanupExpiredIngestionSourcesUseCase(retention, attachments, audits, metrics, new FixedTimeProvider(Now));
await cleanup.ExecuteAsync(CancellationToken.None);
Assert.Equal(["outcome", "problem_type", "stage"], observedTags.Select(tag => tag.Key).OrderBy(key => key).ToArray());
Assert.All(observedTags, tag => Assert.DoesNotContain("source-body-and-token=secret", tag.Value?.ToString() ?? string.Empty, StringComparison.Ordinal));
Assert.Contains(observedTags, tag => tag.Key == "stage" && Equals(tag.Value, "retention"));
Assert.Contains(observedTags, tag => tag.Key == "outcome" && Equals(tag.Value, "success"));
Assert.Contains(observedTags, tag => tag.Key == "problem_type" && Equals(tag.Value, "none"));
}
private static IngestionSourceRetentionCandidate Source(DateTimeOffset createdAt) => new(
Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), createdAt);
private sealed class RetentionRepository(IReadOnlyList<IngestionSourceRetentionCandidate> sources) : IIngestionSourceRetentionRepository
{
public int SaveCalls { get; private set; }
public Task<IReadOnlyList<IngestionSourceRetentionCandidate>> FindExpiredProposalSourcesAsync(
DateTimeOffset createdOnOrBefore, int batchSize, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<IngestionSourceRetentionCandidate>>(
sources.Where(source => source.CreatedAt <= createdOnOrBefore).Take(batchSize).ToArray());
public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
{
SaveCalls++;
return Task.FromResult(1);
}
}
private sealed class Attachments(IEnumerable<Row> rows) : IAttachmentRepository
{
private readonly List<Row> rows = rows.ToList();
public bool Exists(Guid referenceId) => rows.Any(row => row.Source.AttachmentReferenceId == referenceId && row.ReferenceExists);
public Task<ProposalSourceAttachmentCleanupResult> RemoveExpiredProposalSourceUsageAsync(
IngestionSourceRetentionCandidate source, CancellationToken cancellationToken)
{
var row = rows.Single(row => row.Source.SourceId == source.SourceId);
if (!row.IsProposalOwned || !row.UsageExists)
return Task.FromResult(ProposalSourceAttachmentCleanupResult.None);
row.UsageExists = false;
if (!row.HasOtherUsage)
{
row.ReferenceExists = false;
return Task.FromResult(new ProposalSourceAttachmentCleanupResult(true, true));
}
return Task.FromResult(new ProposalSourceAttachmentCleanupResult(true, false));
}
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) => throw new NotSupportedException();
public Task<StagingAttachmentCleanupResult> RemoveOrphanedStagingAttachmentsAsync(Guid stagingSessionId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task SaveChangesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
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) => Task.CompletedTask;
}
private sealed class Row(IngestionSourceRetentionCandidate source, bool isProposalOwned, bool hasOtherUsage)
{
public IngestionSourceRetentionCandidate Source { get; } = source;
public bool IsProposalOwned { get; } = isProposalOwned;
public bool HasOtherUsage { get; } = hasOtherUsage;
public bool UsageExists { get; set; } = true;
public bool ReferenceExists { get; set; } = true;
}
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
}
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<!-- Explicit direct reference to force NuGet to resolve the patched
version instead of Testcontainers.PostgreSql's transitive
SSH.NET 2025.1.0 (CVE-2026-48798) - see Directory.Packages.props. -->
<PackageReference Include="SSH.NET" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj" />
<ProjectReference Include="../../../src/backend/ItWorx.KnowledgeHub.Application/ItWorx.KnowledgeHub.Application.csproj" />
<ProjectReference Include="../../../src/backend/ItWorx.KnowledgeHub.Contracts/ItWorx.KnowledgeHub.Contracts.csproj" />
<ProjectReference Include="../../../src/backend/ItWorx.KnowledgeHub.Domain/ItWorx.KnowledgeHub.Domain.csproj" />
<ProjectReference Include="../../../src/backend/ItWorx.KnowledgeHub.Infrastructure/ItWorx.KnowledgeHub.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,72 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Backlinks ("what links here") update correctly after link, title, slug and
/// tree changes (docs/27-acceptance-matrix.md, M3-A03).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class BacklinksTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task A_page_appears_in_another_pages_backlinks_once_it_links_there_and_disappears_once_the_link_is_removed()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var targetResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Doelpagina", "WikiPage", "De doelpagina.", null));
var target = await targetResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(target);
var sourceResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Bronpagina", "WikiPage", "Zie [[Doelpagina]].", null));
var source = await sourceResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(source);
var backlinks = await client.GetFromJsonAsync<BacklinksResponse>($"/api/v1/pages/{target.Id}/backlinks");
Assert.NotNull(backlinks);
Assert.Contains(backlinks.Items, entry => entry.Id == source.Id);
// Renaming the target must not break the already-resolved backlink relationship.
var renamedTargetResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{target.Id}",
new UpdatePageRequest("Doelpagina (hernoemd)", "De doelpagina.", "WikiPage", "Active", null),
target.ETag);
Assert.True(renamedTargetResponse.IsSuccessStatusCode);
var backlinksAfterRename = await client.GetFromJsonAsync<BacklinksResponse>($"/api/v1/pages/{target.Id}/backlinks");
Assert.NotNull(backlinksAfterRename);
Assert.Contains(backlinksAfterRename.Items, entry => entry.Id == source.Id);
// Removing the link from the source page removes the backlink.
var unlinkedSourceResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{source.Id}",
new UpdatePageRequest("Bronpagina", "Geen verwijzing meer.", "WikiPage", "Active", null),
source.ETag);
Assert.True(unlinkedSourceResponse.IsSuccessStatusCode);
var backlinksAfterUnlink = await client.GetFromJsonAsync<BacklinksResponse>($"/api/v1/pages/{target.Id}/backlinks");
Assert.NotNull(backlinksAfterUnlink);
Assert.DoesNotContain(backlinksAfterUnlink.Items, entry => entry.Id == source.Id);
}
[Fact]
public async Task A_page_with_no_incoming_links_has_an_empty_backlinks_list()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Eenzame pagina", "WikiPage", "Niemand linkt hierheen.", null));
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var backlinks = await client.GetFromJsonAsync<BacklinksResponse>($"/api/v1/pages/{page.Id}/backlinks");
Assert.NotNull(backlinks);
Assert.Empty(backlinks.Items);
}
}
@@ -0,0 +1,92 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Autosaved drafts survive navigation/restart without overwriting a newer
/// server revision (docs/27-acceptance-matrix.md, M2-A04).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class DraftAutosaveTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task A_saved_draft_can_be_retrieved_later_as_if_after_a_browser_restart()
{
using var writer = Factory.CreateClient();
await SetupHelper.LoginAsync(writer, OwnerEmail, OwnerPassword);
var draftKey = $"new-page-{Guid.NewGuid():N}";
var saveResponse = await SetupHelper.PutWithCsrfAsync(
writer, $"/api/v1/drafts/{draftKey}", new SaveDraftRequest(null, null, "Concepttitel", "Conceptinhoud"));
Assert.Equal(HttpStatusCode.OK, saveResponse.StatusCode);
// A brand new HttpClient with its own (empty) cookie jar, logging in again —
// simulating the browser having fully restarted.
using var afterRestart = Factory.CreateClient();
await SetupHelper.LoginAsync(afterRestart, OwnerEmail, OwnerPassword);
var fetched = await afterRestart.GetFromJsonAsync<DraftResponse>($"/api/v1/drafts/{draftKey}");
Assert.NotNull(fetched);
Assert.Equal("Concepttitel", fetched.Title);
Assert.Equal("Conceptinhoud", fetched.Markdown);
}
[Fact]
public async Task Saving_a_draft_against_an_outdated_base_revision_is_flagged_as_stale_not_silently_accepted()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Concepttest pagina", "WikiPage", "Origineel", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
// A draft is started against revision 1...
var draftKey = page.Id.ToString();
await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/drafts/{draftKey}", new SaveDraftRequest(page.Id, page.CurrentRevisionNumber, page.Title, "Conceptwijziging"));
// ...but someone else (or another tab) publishes revision 2 in the meantime.
await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Gepubliceerde titel", "Gepubliceerde inhoud", "WikiPage", "Active", null),
page.ETag);
// Saving the draft again must surface that its base revision is now stale,
// rather than silently proceeding as if nothing happened.
var staleSave = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/drafts/{draftKey}", new SaveDraftRequest(page.Id, page.CurrentRevisionNumber, page.Title, "Nog een conceptwijziging"));
var staleDraft = await staleSave.Content.ReadFromJsonAsync<DraftResponse>();
Assert.NotNull(staleDraft);
Assert.True(staleDraft.BaseRevisionIsStale);
// Crucially, the published page itself was never overwritten by the draft.
var currentPage = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(currentPage);
Assert.Equal("Gepubliceerde titel", currentPage.Title);
}
[Fact]
public async Task Discarding_a_draft_removes_it()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var draftKey = $"discard-me-{Guid.NewGuid():N}";
await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/drafts/{draftKey}", new SaveDraftRequest(null, null, "Weg te gooien", "Inhoud"));
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/drafts/{draftKey}");
request.Headers.Add(csrf.HeaderName, csrf.Value);
using var deleteResponse = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var getResponse = await client.GetAsync($"/api/v1/drafts/{draftKey}");
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
}
}
@@ -0,0 +1,103 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Internal links resolve by stable page identity and survive title, slug and
/// tree changes (docs/27-acceptance-matrix.md, M2-A02).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class InternalLinkResolutionTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task A_resolved_internal_link_keeps_working_after_the_target_is_renamed_and_moved()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var target = await CreatePageAsync(client, null, "PostgreSQL back-up herstellen");
var newParent = await CreatePageAsync(client, null, "Nieuwe categorie");
var sourceResponse = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(
null,
"Runbook overzicht",
"WikiPage",
"Zie [[PostgreSQL back-up herstellen|het herstelrunbook]] voor details.",
null));
var source = await sourceResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(source);
Assert.Contains($"href=\"/knowledge/{target.Id}/{target.Slug}\"", source.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("het herstelrunbook", source.RenderedHtml, StringComparison.Ordinal);
// Rename AND move the target page.
var renamed = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{target.Id}",
new UpdatePageRequest("PostgreSQL herstelprocedure (bijgewerkt)", target.Markdown, "Runbook", "Active", null),
target.ETag);
renamed.EnsureSuccessStatusCode();
var renamedTarget = await renamed.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(renamedTarget);
var moveResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{target.Id}/move", new MovePageRequest(newParent.Id, null), renamedTarget.ETag);
moveResponse.EnsureSuccessStatusCode();
// Re-fetch the SOURCE page: its stored link must still resolve to the same
// page ID even though the source page's own Markdown/authored link text was
// never touched, and the target's title/slug/parent all changed.
var sourceAfter = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{source.Id}");
Assert.NotNull(sourceAfter);
Assert.Contains($"/knowledge/{target.Id}/", sourceAfter.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("[[PostgreSQL", sourceAfter.RenderedHtml, StringComparison.Ordinal);
}
[Fact]
public async Task An_unresolved_internal_link_target_remains_visible_as_plain_bracket_text()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, "Verwijzing naar niets", "WikiPage", "Zie [[Pagina die niet bestaat]].", null));
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Contains("[[Pagina die niet bestaat]]", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<a ", page.RenderedHtml, StringComparison.Ordinal);
}
[Fact]
public async Task An_ambiguous_internal_link_target_also_remains_unresolved_rather_than_guessing()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await CreatePageAsync(client, null, "Overzicht");
await CreatePageAsync(client, null, "Overzicht");
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Verwijst dubbelzinnig", "WikiPage", "Zie [[Overzicht]].", null));
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Contains("[[Overzicht]]", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<a ", page.RenderedHtml, StringComparison.Ordinal);
}
private static async Task<PageDetail> CreatePageAsync(HttpClient client, Guid? parentId, string title)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(parentId, title, "WikiPage", $"# {title}", null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page;
}
}
@@ -0,0 +1,195 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Markdown and Mermaid output is sanitized against active content
/// (docs/27-acceptance-matrix.md, M2-A03) — a corpus of realistic attack payloads
/// rendered through the real HTTP pipeline against a real database, not a unit test
/// against the sanitizer in isolation.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class MarkdownSanitizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Preview_renders_unsaved_markdown_with_the_same_sanitization_boundary()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages/preview",
new PreviewMarkdownRequest("# Nieuw document\n\n**Veilig** <script>alert(1)</script>"));
response.EnsureSuccessStatusCode();
var preview = await response.Content.ReadFromJsonAsync<PreviewMarkdownResponse>();
Assert.NotNull(preview);
Assert.Contains("Nieuw document</h1>", preview.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("<strong>Veilig</strong>", preview.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<script", preview.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Each payload pairs with the *unescaped, live* marker that would only appear if
/// the exploit actually worked — not a bare keyword. Markdig (raw HTML disabled)
/// turns an entire literal <c>&lt;tag&gt;</c> into inert escaped text
/// (<c>&amp;lt;tag&amp;gt;</c>); quote characters inside that escaped text are
/// *not* further escaped, so a bare-keyword check like "javascript:" would still
/// match perfectly safe, non-executing text and produce a false failure. Checking
/// for the literal unescaped opening angle bracket (or, for genuine CommonMark
/// markdown-syntax links/images, the real emitted attribute) is what actually
/// distinguishes "rendered as inert text" from "rendered as live markup".
/// </summary>
public static IEnumerable<object[]> UnsafePayloads()
{
yield return ["<script>alert(1)</script>", "<script"];
yield return ["<img src=x onerror=alert(1)>", "<img"];
yield return ["<a href=\"javascript:alert(1)\">click</a>", "<a href=\"javascript:"];
yield return ["<iframe src=\"https://evil.example\"></iframe>", "<iframe"];
yield return ["<svg onload=alert(1)>", "<svg"];
yield return ["<div style=\"background:url(javascript:alert(1))\">x</div>", "<div style"];
yield return ["<img src=\"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==\">", "<img src=\"data:text/html"];
yield return ["<style>body{background:url('javascript:alert(1)')}</style>", "<style"];
yield return ["<object data=\"evil.swf\"></object>", "<object"];
yield return ["<form action=\"https://evil.example\"><input></form>", "<form"];
yield return ["<a href=\"vbscript:alert(1)\">x</a>", "<a href=\"vbscript:"];
// Genuine CommonMark link/image syntax (not raw HTML) — Markdig *does* emit a
// real <a>/<img> element for these, so the unsafe scheme must be neutralized by
// the sanitizer's AllowedSchemes allowlist, not merely by HTML-escaping.
yield return ["[click me](javascript:alert(1))", "href=\"javascript:"];
yield return ["![alt](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", "src=\"data:text/html"];
}
[Theory]
[MemberData(nameof(UnsafePayloads))]
public async Task Unsafe_markup_never_reaches_the_rendered_output_as_live_markup(string payload, string liveMarker)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, $"XSS-test {Guid.NewGuid():N}", "WikiPage", payload, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.DoesNotContain(liveMarker, page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
// Re-fetch independently of the create response, exercising the read path too.
var fetched = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(fetched);
Assert.DoesNotContain(liveMarker, fetched.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task A_mermaid_fenced_block_renders_as_escaped_source_not_raw_script()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
const string markdown = "```mermaid\nflowchart LR\n A --> B\n```";
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Mermaid-test", "WikiPage", markdown, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
// The exact wrapper markup is an implementation detail; what matters for
// "strict mode, client-rendered" (docs/11-markdown-and-linking.md) is that the
// raw diagram source survives intact for mermaid.js to read, and that nothing
// resembling an executable tag was produced from it.
Assert.Contains("mermaid", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
Assert.Contains("flowchart LR", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<script", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Ordinary_safe_formatting_still_renders_correctly()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
const string markdown = "# Titel\n\n**Vet** en *cursief* met een [veilige koppeling](https://example.org).\n\n- [ ] taak\n- [x] gedaan\n";
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Veilige opmaak", "WikiPage", markdown, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Contains("<strong>Vet</strong>", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("<em>cursief</em>", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("href=\"https://example.org\"", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("rel=\"noopener noreferrer nofollow\"", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("type=\"checkbox\"", page.RenderedHtml, StringComparison.Ordinal);
}
public static IEnumerable<object[]> ManagedMarkdownCases()
{
yield return
[
"<!-- managed-source:start -->\n# Bron\n\nInhoud.\n<!-- managed-source:end -->\n\n<!-- user-notes:start -->\nEigen notitie.\n<!-- user-notes:end -->\n",
"Bron",
"Eigen notitie."
];
yield return
[
"<!-- managed-source:start --># Bron zonder regeleinde<!-- managed-source:end -->Notitie direct erna<!-- user-notes:start --><!-- user-notes:end -->",
"Bron zonder regeleinde",
"Notitie direct erna"
];
yield return
[
"<!-- managed-source:start -->\nLege notities\n<!-- managed-source:end -->\n<!-- user-notes:start -->\n\n<!-- user-notes:end -->",
"Lege notities",
string.Empty
];
}
[Theory]
[MemberData(nameof(ManagedMarkdownCases))]
public async Task Known_import_markers_remain_stored_but_never_render(
string markdown,
string expectedSource,
string expectedNotes)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, $"Marker-test {Guid.NewGuid():N}", "WikiPage", markdown, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Equal(markdown, page.Markdown);
Assert.Contains("<!-- managed-source:start -->", page.Markdown, StringComparison.Ordinal);
Assert.DoesNotContain("managed-source", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("user-notes", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains(expectedSource, page.RenderedHtml, StringComparison.Ordinal);
if (expectedNotes.Length > 0)
{
Assert.Contains(expectedNotes, page.RenderedHtml, StringComparison.Ordinal);
}
}
[Fact]
public async Task Unknown_html_comments_and_active_markup_stay_inert_while_known_markers_are_hidden()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
const string markdown = "<!-- managed-source:start -->\n<!-- unknown-control -->\n<script>alert(1)</script>\n<img src=x onerror=alert(2)>\n<!-- managed-source:end -->";
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Marker security test", "WikiPage", markdown, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.DoesNotContain("managed-source", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("unknown-control", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<script", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("<img", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,85 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Concurrent edits must produce an explicit conflict, never a silently lost
/// update (docs/27-acceptance-matrix.md, M2-A05).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class PageConcurrencyTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Publishing_with_a_stale_ETag_is_rejected_and_never_overwrites_the_newer_revision()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Conflicttest", "WikiPage", "Origineel", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var staleETag = page.ETag;
// Session A publishes successfully first, advancing the ETag.
var firstEdit = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Titel van A", "Inhoud van A", "WikiPage", "Active", null),
staleETag);
Assert.Equal(HttpStatusCode.OK, firstEdit.StatusCode);
// Session B still holds the original (now stale) ETag from before A's publish.
var secondEdit = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Titel van B", "Inhoud van B", "WikiPage", "Active", null),
staleETag);
Assert.Equal(HttpStatusCode.PreconditionFailed, secondEdit.StatusCode);
// The current server state is still A's edit, not silently overwritten by B.
var current = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(current);
Assert.Equal("Titel van A", current.Title);
}
[Fact]
public async Task Simultaneous_publishes_return_one_success_and_only_explicit_conflicts()
{
using var creator = Factory.CreateClient();
await SetupHelper.LoginAsync(creator, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
creator, "/api/v1/pages", new CreatePageRequest(null, "Gelijktijdige conflicttest", "WikiPage", "Origineel", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var clients = Enumerable.Range(0, 8).Select(_ => Factory.CreateClient()).ToArray();
try
{
await Task.WhenAll(clients.Select(client => SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword)));
var responses = await Task.WhenAll(clients.Select((client, index) =>
SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest($"Kandidaat {index}", $"Inhoud {index}", "WikiPage", "Active", null),
page.ETag)));
Assert.Single(responses, response => response.StatusCode == HttpStatusCode.OK);
Assert.Equal(7, responses.Count(response => response.StatusCode == HttpStatusCode.PreconditionFailed));
}
finally
{
foreach (var client in clients)
{
client.Dispose();
}
}
var revisions = await creator.GetFromJsonAsync<IReadOnlyList<PageRevisionSummary>>($"/api/v1/pages/{page.Id}/revisions");
Assert.NotNull(revisions);
Assert.Equal(2, revisions.Count);
}
}
@@ -0,0 +1,243 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// The staged page-creation flow (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md,
/// audit finding H1): a page must never become visible half-published, with a broken
/// attachment link or a partially-attached file. These tests exercise the real
/// failure/retry scenarios the audit named explicitly, not just the happy path.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class PageCreationStagingTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private async Task<HttpClient> AuthenticatedClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static async Task<Guid> BeginSessionAsync(HttpClient client, Guid? parentPageId = null)
{
var response = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/pages/staged", new BeginPageCreationSessionRequest(parentPageId));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<PageCreationSessionResponse>();
Assert.NotNull(session);
return session.SessionId;
}
[Fact]
public async Task A_page_created_with_attachments_is_published_atomically_with_every_attachment_already_linked()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "diagram.png", "image/png");
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
// The URL is real the moment it is uploaded — no placeholder token, nothing to
// rewrite server-side later (a deliberately stronger alternative to the
// preferred "server-side token replacement" direction; see the ADR).
var markdown = $"# Diagram\n\n![diagram]({{content}})".Replace("{content}", $"/api/v1/attachments/{attachment.Id}/content");
var commitResponse = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Pagina met bijlage", "WikiPage", markdown, null));
Assert.Equal(HttpStatusCode.OK, commitResponse.StatusCode);
var page = await commitResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Contains($"/api/v1/attachments/{attachment.Id}/content", page.Markdown);
// The attachment now belongs to the real page — visible through the ordinary
// page-attachments listing, not just independently reachable by ID.
var listed = await client.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{page.Id}/attachments");
Assert.NotNull(listed);
Assert.Contains(listed.Items, item => item.Id == attachment.Id);
var content = await client.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
}
[Fact]
public async Task A_middle_upload_failing_leaves_earlier_successful_uploads_intact_and_the_page_still_unpublished()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var first = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "eerste.png", "image/png");
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var firstAttachment = await first.Content.ReadFromJsonAsync<AttachmentSummary>();
// An executable signature is rejected regardless of declared type — the
// "middle upload fails" scenario.
var executableBytes = new byte[] { 0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00 };
var failing = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", executableBytes, "kwaadaardig.exe", "image/png");
Assert.Equal(HttpStatusCode.UnprocessableEntity, failing.StatusCode);
// The first, successful upload is unaffected and still independently valid —
// the caller can retry the failed one or continue composing; nothing was rolled
// back, and nothing has been published yet either.
var contentStillThere = await client.GetAsync($"/api/v1/attachments/{firstAttachment!.Id}/content");
Assert.Equal(HttpStatusCode.OK, contentStillThere.StatusCode);
var third = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "tweede.png", "image/png");
Assert.Equal(HttpStatusCode.OK, third.StatusCode);
var commit = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Pagina na gedeeltelijke fout", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, commit.StatusCode);
}
[Fact]
public async Task A_discarded_session_removes_its_uploaded_but_never_published_attachments()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "weggegooid.png", "image/png");
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
var discard = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/discard", new { });
Assert.Equal(HttpStatusCode.OK, discard.StatusCode);
var discarded = await discard.Content.ReadFromJsonAsync<DiscardPageCreationSessionResponse>();
Assert.NotNull(discarded);
Assert.Equal(1, discarded.AttachmentsRemoved);
// No half-published, no orphan left silently reachable — the reference itself
// is gone (not merely "unlinked"), matching the same orphan-removal shape
// already proven for LeafWiki import rollback.
var content = await client.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.NotFound, content.StatusCode);
// Discard is idempotent — a retried discard (e.g. after a lost response) is a
// safe no-op, not an error.
var retryDiscard = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/discard", new { });
Assert.Equal(HttpStatusCode.OK, retryDiscard.StatusCode);
}
[Fact]
public async Task Committing_an_already_discarded_session_fails_and_cannot_publish_a_second_time()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/discard", new { });
var commit = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Mag niet publiceren", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.Conflict, commit.StatusCode);
}
[Fact]
public async Task Committing_an_already_committed_session_a_second_time_never_creates_a_second_page()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var request = new CommitPageCreationSessionRequest("Slechts eenmaal", "WikiPage", "# Inhoud", null);
var first = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/commit", request);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var second = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/commit", request);
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.Single(roots!.Items, item => item.Title == "Slechts eenmaal");
}
[Fact]
public async Task Retrying_a_commit_with_the_same_Idempotency_Key_replays_the_original_page_instead_of_creating_a_duplicate()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var request = new CommitPageCreationSessionRequest("Idempotente publicatie", "WikiPage", "# Inhoud", null);
var idempotencyKey = Guid.NewGuid().ToString();
var first = await PostWithIdempotencyKeyAsync(client, $"/api/v1/pages/staged/{sessionId}/commit", request, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var firstBody = await first.Content.ReadFromJsonAsync<PageDetail>();
var second = await PostWithIdempotencyKeyAsync(client, $"/api/v1/pages/staged/{sessionId}/commit", request, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
var secondBody = await second.Content.ReadFromJsonAsync<PageDetail>();
Assert.Equal(firstBody!.Id, secondBody!.Id);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.Single(roots!.Items, item => item.Title == "Idempotente publicatie");
}
[Fact]
public async Task Uploading_into_an_unknown_or_already_closed_session_fails_clearly()
{
using var client = await AuthenticatedClientAsync();
var unknown = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{Guid.NewGuid()}/attachments", PngBytes, "wees.png", "image/png");
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
var sessionId = await BeginSessionAsync(client);
await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/discard", new { });
var closed = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "te-laat.png", "image/png");
Assert.Equal(HttpStatusCode.NotFound, closed.StatusCode);
}
[Fact]
public async Task A_reader_cannot_begin_upload_commit_or_discard_a_staging_session()
{
using var adminClient = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(adminClient);
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users",
new CreateUserRequest("Lezer", "lezer-staging@itworx.test", "reader-strong-password", "Reader"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var readerClient = Factory.CreateClient();
await SetupHelper.LoginAsync(readerClient, "lezer-staging@itworx.test", "reader-strong-password");
var begin = await SetupHelper.PostWithCsrfAsync(readerClient, "/api/v1/pages/staged", new BeginPageCreationSessionRequest(null));
Assert.Equal(HttpStatusCode.Forbidden, begin.StatusCode);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
readerClient, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "onbevoegd.png", "image/png");
Assert.Equal(HttpStatusCode.Forbidden, upload.StatusCode);
var commit = await SetupHelper.PostWithCsrfAsync(
readerClient, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Onbevoegd", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.Forbidden, commit.StatusCode);
var discard = await SetupHelper.PostWithCsrfAsync(readerClient, $"/api/v1/pages/staged/{sessionId}/discard", new { });
Assert.Equal(HttpStatusCode.Forbidden, discard.StatusCode);
}
private static async Task<HttpResponseMessage> PostWithIdempotencyKeyAsync<TBody>(
HttpClient client, string path, TBody body, string idempotencyKey)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = System.Net.Http.Json.JsonContent.Create(body) };
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("Idempotency-Key", idempotencyKey);
return await client.SendAsync(request);
}
}
@@ -0,0 +1,196 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
[Collection(PostgresTestGroup.Name)]
public sealed class PageCrudTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<HttpClient> AuthenticatedClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
[Fact]
public async Task A_root_page_can_be_created_read_and_appears_among_the_roots()
{
using var client = await AuthenticatedClientAsync();
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Kennisbank startpunt", "WikiPage", "# Welkom", null));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(created);
Assert.Equal("Kennisbank startpunt", created.Title);
Assert.Equal("kennisbank-startpunt", created.Slug);
Assert.False(string.IsNullOrWhiteSpace(created.ETag));
var getResponse = await client.GetAsync($"/api/v1/pages/{created.Id}");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(fetched);
// Markdig's advanced extensions add deterministic heading anchor IDs
// (docs/11-markdown-and-linking.md — "Heading IDs are deterministic within a
// rendered revision").
Assert.Equal("<h1 id=\"welkom\">Welkom</h1>\n", fetched.RenderedHtml);
Assert.Single(fetched.Breadcrumb);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
Assert.Contains(roots.Items, item => item.Id == created.Id);
}
[Fact]
public async Task A_child_page_reports_a_breadcrumb_and_makes_its_parent_report_children()
{
using var client = await AuthenticatedClientAsync();
var parent = await CreatePageAsync(client, null, "Ouderpagina");
var child = await CreatePageAsync(client, parent.Id, "Kindpagina");
var childDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{child.Id}");
Assert.NotNull(childDetail);
Assert.Equal(2, childDetail.Breadcrumb.Count);
Assert.Equal(parent.Id, childDetail.Breadcrumb[0].Id);
Assert.Equal(child.Id, childDetail.Breadcrumb[1].Id);
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(children);
Assert.Single(children.Items);
Assert.Equal(child.Id, children.Items[0].Id);
}
[Fact]
public async Task Editing_a_page_with_the_current_ETag_succeeds_and_returns_a_new_ETag()
{
using var client = await AuthenticatedClientAsync();
var page = await CreatePageAsync(client, null, "Bewerkbare pagina");
var updateResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Bewerkte titel", "Nieuwe inhoud", "WikiPage", "Active", "Testbewerking"),
page.ETag);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(updated);
Assert.Equal("Bewerkte titel", updated.Title);
Assert.NotEqual(page.ETag, updated.ETag);
Assert.Equal(2, updated.CurrentRevisionNumber);
}
[Fact]
public async Task Editing_a_page_without_an_if_match_header_is_rejected()
{
using var client = await AuthenticatedClientAsync();
var page = await CreatePageAsync(client, null, "Zonder If-Match");
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/pages/{page.Id}")
{
Content = JsonContent.Create(new UpdatePageRequest("Titel", "Inhoud", "WikiPage", "Active", null))
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.PreconditionRequired, response.StatusCode);
}
[Fact]
public async Task Trashing_a_page_removes_it_from_the_children_listing_and_restoring_brings_it_back()
{
using var client = await AuthenticatedClientAsync();
var parent = await CreatePageAsync(client, null, "Prullenbaktest ouder");
var page = await CreatePageAsync(client, parent.Id, "Weg te gooien pagina");
var trashResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
Assert.Equal(HttpStatusCode.OK, trashResponse.StatusCode);
var childrenAfterTrash = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(childrenAfterTrash);
Assert.Empty(childrenAfterTrash.Items);
var trashedDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(trashedDetail);
Assert.NotNull(trashedDetail.TrashedAt);
var restoreResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{page.Id}/restore", (object?)null, trashedDetail.ETag);
Assert.Equal(HttpStatusCode.OK, restoreResponse.StatusCode);
var childrenAfterRestore = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(childrenAfterRestore);
Assert.Single(childrenAfterRestore.Items);
}
[Fact]
public async Task A_trashed_page_cannot_be_edited_until_restored()
{
using var client = await AuthenticatedClientAsync();
var page = await CreatePageAsync(client, null, "Te bewerken na prullenbak");
await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
var trashedDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(trashedDetail);
var editResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Nieuwe titel", "Nieuwe inhoud", "WikiPage", "Active", null),
trashedDetail.ETag);
Assert.Equal(HttpStatusCode.Conflict, editResponse.StatusCode);
}
[Fact]
public async Task Moving_a_page_under_its_own_descendant_is_rejected_as_a_cycle()
{
using var client = await AuthenticatedClientAsync();
var parent = await CreatePageAsync(client, null, "Cyclustest ouder");
var child = await CreatePageAsync(client, parent.Id, "Cyclustest kind");
var moveResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{parent.Id}/move", new MovePageRequest(child.Id, null), parent.ETag);
Assert.Equal(HttpStatusCode.Conflict, moveResponse.StatusCode);
}
[Fact]
public async Task Reordering_among_siblings_changes_their_listed_order()
{
using var client = await AuthenticatedClientAsync();
var parent = await CreatePageAsync(client, null, "Volgordetest ouder");
var first = await CreatePageAsync(client, parent.Id, "Eerste");
var second = await CreatePageAsync(client, parent.Id, "Tweede");
var beforeChildren = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(beforeChildren);
Assert.Equal([first.Id, second.Id], beforeChildren.Items.Select(item => item.Id).ToArray());
// Move "first" to come after "second".
var moveResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{first.Id}/move", new MovePageRequest(parent.Id, second.Id), first.ETag);
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
var afterChildren = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(afterChildren);
Assert.Equal([second.Id, first.Id], afterChildren.Items.Select(item => item.Id).ToArray());
}
private static async Task<PageDetail> CreatePageAsync(HttpClient client, Guid? parentId, string title)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(parentId, title, "WikiPage", $"# {title}", null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page;
}
}
@@ -0,0 +1,101 @@
using System.Diagnostics;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>A 10,000-node synthetic hierarchy remains navigable without loading the
/// entire tree into the browser (docs/27-acceptance-matrix.md, M2-A07): the API must
/// serve one bounded page of children quickly, regardless of how many siblings exist
/// in total.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class PageTreeScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int ChildCount = 10_000;
[Fact]
public async Task Fetching_one_page_of_children_under_a_10000_child_node_stays_fast_and_bounded()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Grote categorie", "WikiPage", "Root", null));
var parent = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(parent);
await SeedChildrenDirectlyAsync(parent.Id);
var stopwatch = Stopwatch.StartNew();
var firstPage = await client.GetFromJsonAsync<PageChildrenResponse>(
$"/api/v1/pages/{parent.Id}/children?limit=50");
stopwatch.Stop();
Assert.NotNull(firstPage);
Assert.Equal(50, firstPage.Items.Count);
Assert.False(string.IsNullOrEmpty(firstPage.NextCursor));
// Generous bound for a CI/dev machine: the point is "bounded and fast", not a
// precise performance benchmark (that belongs to the M8 release scale suite).
Assert.True(
stopwatch.Elapsed < TimeSpan.FromSeconds(5),
$"Fetching one bounded page of children took {stopwatch.Elapsed}, expected it to stay well under 5s regardless of the {ChildCount} total children.");
var secondPage = await client.GetFromJsonAsync<PageChildrenResponse>(
$"/api/v1/pages/{parent.Id}/children?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
Assert.NotNull(secondPage);
Assert.Equal(50, secondPage.Items.Count);
Assert.Empty(firstPage.Items.Select(item => item.Id).Intersect(secondPage.Items.Select(item => item.Id)));
}
private async Task SeedChildrenDirectlyAsync(Guid parentId)
{
// Seeded directly through the DbContext rather than 10,000 HTTP calls — this
// test is about proving the *read* path stays bounded at scale, not about
// exercising page creation (already covered by PageCrudTests).
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var parent = await db.Pages.FindAsync(parentId);
Assert.NotNull(parent);
var workspaceId = parent.WorkspaceId;
var actorId = parent.CreatedBy ?? Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var sortKey = SortKey.First();
var batch = new List<Page>(1000);
for (var i = 0; i < ChildCount; i++)
{
var page = Page.Create(
workspaceId,
parentId,
sortKey,
PageTitle.Create($"Kindpagina {i:D5}"),
DocumentType.WikiPage,
"Inhoud",
actorId,
now);
batch.Add(page);
sortKey = SortKey.Next(sortKey);
if (batch.Count == 1000)
{
db.Pages.AddRange(batch);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
batch.Clear();
}
}
if (batch.Count > 0)
{
db.Pages.AddRange(batch);
await db.SaveChangesAsync();
}
}
}
@@ -0,0 +1,304 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// The RAG publication manifest (docs/32-rag-publication-convention.md): a single
/// narrow read-only endpoint for RAGcore's n8n sync, entirely driven by the
/// <c>rag:publish</c>/<c>rag:space:</c>/<c>rag:allow-deprecated</c>/<c>locale:</c> tag
/// convention rather than a new editorial model.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class RagPublicationManifestTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static async Task<PageDetail> CreatePageAsync(
HttpClient client, string title, IReadOnlyList<string>? tags = null, string markdown = "Inhoud.")
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, title, "WikiPage", markdown, null, tags));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page;
}
/// <summary>Publishes a page to <c>Active</c> — every page starts as a <c>Draft</c>
/// "concept" at creation (<c>Page.Create</c>), so live-content assertions must
/// explicitly advance it, exactly like an editor publishing from the UI would.
/// </summary>
private static async Task<PageDetail> PublishAsync(HttpClient client, PageDetail page)
{
var response = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest(page.Title, page.Markdown, page.DocumentType, "Active", "Gepubliceerd.", page.Tags),
page.ETag);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var updated = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(updated);
return updated;
}
[Fact]
public async Task Only_pages_tagged_rag_publish_ever_appear_in_the_manifest()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var tagged = await CreatePageAsync(client, $"Gepubliceerd {Guid.NewGuid():N}", [RagPublicationTags.Publish]);
var untagged = await CreatePageAsync(client, $"Niet gepubliceerd {Guid.NewGuid():N}");
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
Assert.Contains(manifest.Items, item => item.ContentId == tagged.Id);
Assert.DoesNotContain(manifest.Items, item => item.ContentId == untagged.Id);
}
[Fact]
public async Task A_draft_concept_comes_back_retired_without_leaking_its_markdown_or_checksum()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
// Deliberately never published — stays Draft ("concept"), matching Page.Create's
// default status.
var draft = await CreatePageAsync(
client, $"Concept {Guid.NewGuid():N}", [RagPublicationTags.Publish], "Geheime conceptinhoud.");
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var item = Assert.Single(manifest.Items, entry => entry.ContentId == draft.Id);
Assert.True(item.Retired);
Assert.Equal("draft", item.RetiredReason);
Assert.Null(item.Markdown);
Assert.Null(item.Checksum);
}
[Fact]
public async Task An_active_published_page_comes_back_live_with_markdown_and_a_matching_checksum()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var created = await CreatePageAsync(
client, $"Live {Guid.NewGuid():N}", [RagPublicationTags.Publish], "# Live inhoud");
var published = await PublishAsync(client, created);
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var item = Assert.Single(manifest.Items, entry => entry.ContentId == published.Id);
Assert.False(item.Retired);
Assert.Null(item.RetiredReason);
Assert.Equal("# Live inhoud", item.Markdown);
Assert.NotNull(item.Checksum);
Assert.Equal(published.CurrentRevisionNumber, item.Revision);
Assert.Contains($"/knowledge/{published.Id}/{published.Slug}", item.CanonicalUrl, StringComparison.Ordinal);
}
[Fact]
public async Task A_trashed_page_comes_back_retired_without_markdown_so_the_sync_can_withdraw_it()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var created = await CreatePageAsync(client, $"Te verwijderen {Guid.NewGuid():N}", [RagPublicationTags.Publish]);
var published = await PublishAsync(client, created);
await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{published.Id}/trash", (object?)null, published.ETag);
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var item = Assert.Single(manifest.Items, entry => entry.ContentId == published.Id);
Assert.True(item.Retired);
Assert.Equal("trashed", item.RetiredReason);
Assert.Null(item.Markdown);
Assert.Null(item.Checksum);
}
[Fact]
public async Task A_deprecated_page_without_the_allow_flag_is_retired_but_with_the_flag_it_stays_live()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var withoutFlag = await PublishAsync(
client, await CreatePageAsync(client, $"Verouderd {Guid.NewGuid():N}", [RagPublicationTags.Publish]));
var deprecatedWithoutFlag = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{withoutFlag.Id}",
new UpdatePageRequest(withoutFlag.Title, withoutFlag.Markdown, withoutFlag.DocumentType, "Deprecated", "Verouderd.", withoutFlag.Tags),
withoutFlag.ETag);
Assert.Equal(HttpStatusCode.OK, deprecatedWithoutFlag.StatusCode);
var withFlagTags = new[] { RagPublicationTags.Publish, RagPublicationTags.AllowDeprecated };
var withFlag = await PublishAsync(
client, await CreatePageAsync(client, $"Verouderd maar toegestaan {Guid.NewGuid():N}", withFlagTags));
var deprecatedWithFlag = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{withFlag.Id}",
new UpdatePageRequest(withFlag.Title, withFlag.Markdown, withFlag.DocumentType, "Deprecated", "Verouderd, maar nog toegestaan.", withFlagTags),
withFlag.ETag);
Assert.Equal(HttpStatusCode.OK, deprecatedWithFlag.StatusCode);
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var retiredItem = Assert.Single(manifest.Items, entry => entry.ContentId == withoutFlag.Id);
Assert.True(retiredItem.Retired);
Assert.Equal("deprecated", retiredItem.RetiredReason);
Assert.Null(retiredItem.Markdown);
var liveItem = Assert.Single(manifest.Items, entry => entry.ContentId == withFlag.Id);
Assert.False(liveItem.Retired);
Assert.NotNull(liveItem.Markdown);
}
[Fact]
public async Task Locale_falls_back_to_the_configured_primary_locale_and_never_defaults_to_english()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var undeclared = await PublishAsync(
client, await CreatePageAsync(client, $"Geen locale {Guid.NewGuid():N}", [RagPublicationTags.Publish]));
var declaredTags = new[] { RagPublicationTags.Publish, "locale:en-US" };
var declared = await PublishAsync(
client, await CreatePageAsync(client, $"Expliciete locale {Guid.NewGuid():N}", declaredTags));
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var undeclaredItem = Assert.Single(manifest.Items, entry => entry.ContentId == undeclared.Id);
// appsettings.json's KnowledgeHub:PrimaryLocale — never a silent "en".
Assert.Equal("nl-BE", undeclaredItem.Locale);
var declaredItem = Assert.Single(manifest.Items, entry => entry.ContentId == declared.Id);
Assert.Equal("en-US", declaredItem.Locale);
}
[Fact]
public async Task The_space_filter_only_returns_pages_carrying_that_exact_space_tag()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var fleetOps = await PublishAsync(
client,
await CreatePageAsync(
client, $"Fleet-ops {Guid.NewGuid():N}", [RagPublicationTags.Publish, "rag:space:fleet-ops"]));
var otherSpace = await PublishAsync(
client,
await CreatePageAsync(
client, $"Andere space {Guid.NewGuid():N}", [RagPublicationTags.Publish, "rag:space:other"]));
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?space=fleet-ops&limit=200");
Assert.NotNull(manifest);
Assert.Contains(manifest.Items, item => item.ContentId == fleetOps.Id);
Assert.DoesNotContain(manifest.Items, item => item.ContentId == otherSpace.Id);
}
[Fact]
public async Task An_invalid_space_parameter_is_rejected_rather_than_smuggled_into_the_array_filter()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await client.GetAsync("/api/v1/rag/publication-manifest?space=Not_Valid!");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task The_manifest_requires_authentication()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync("/api/v1/rag/publication-manifest");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task A_bearer_token_with_only_pages_read_can_call_the_manifest()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
var createServiceAccount = await SetupHelper.PostWithCsrfAsync(
adminClient,
"/api/v1/service-accounts",
new CreateServiceAccountRequest("RAGcore Knowledge Sync (test)", "Alleen-lezen synctoken.", [Scopes.PagesRead]));
Assert.Equal(HttpStatusCode.OK, createServiceAccount.StatusCode);
var created = await createServiceAccount.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
using var bearerClient = Factory.CreateClient();
bearerClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", created.RawToken);
var response = await bearerClient.GetAsync("/api/v1/rag/publication-manifest");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Cursor_pagination_is_deterministic_and_covers_every_matching_page_without_duplicates()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var created = new List<Guid>();
for (var i = 0; i < 7; i++)
{
var page = await CreatePageAsync(client, $"Manifestreeks {i:D2} {Guid.NewGuid():N}", [RagPublicationTags.Publish]);
created.Add(page.Id);
}
var seen = new HashSet<Guid>();
string? cursor = null;
var pages = 0;
do
{
var url = cursor is null
? "/api/v1/rag/publication-manifest?limit=3"
: $"/api/v1/rag/publication-manifest?limit=3&cursor={Uri.EscapeDataString(cursor)}";
var response = await client.GetFromJsonAsync<RagPublicationManifestResponse>(url);
Assert.NotNull(response);
foreach (var item in response.Items)
{
Assert.True(seen.Add(item.ContentId), $"Item {item.ContentId} was returned on more than one page.");
}
cursor = response.NextCursor;
pages++;
Assert.True(pages < 50, "Pagination did not terminate within a reasonable number of pages.");
} while (cursor is not null);
foreach (var id in created)
{
Assert.Contains(id, seen);
}
}
}
@@ -0,0 +1,107 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Every published mutation creates a revision atomically (M3-A01), and a user
/// can compare/restore past revisions without losing the current version (M3-A02).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class RevisionsTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Publishing_creates_a_new_revision_every_time()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Revisiepagina", "WikiPage", "Versie 1", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Revisiepagina", "Versie 2", "WikiPage", "Active", "Naar versie 2"),
page.ETag);
var revisions = await client.GetFromJsonAsync<List<PageRevisionSummary>>($"/api/v1/pages/{page.Id}/revisions");
Assert.NotNull(revisions);
Assert.Equal(2, revisions.Count);
// Most recent first.
Assert.Equal(2, revisions[0].RevisionNumber);
Assert.Equal(1, revisions[1].RevisionNumber);
Assert.Equal("Naar versie 2", revisions[0].ChangeSummary);
}
[Fact]
public async Task Restoring_a_past_revision_creates_a_new_revision_without_losing_history()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Titel A", "WikiPage", "Inhoud A", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var afterFirstEdit = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Titel B", "Inhoud B", "WikiPage", "Active", null),
page.ETag);
var pageAfterEdit = await afterFirstEdit.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(pageAfterEdit);
Assert.Equal("Titel B", pageAfterEdit.Title);
// Fetch the original revision's content before restoring it.
var originalRevision = await client.GetFromJsonAsync<PageRevisionDetailResponse>($"/api/v1/pages/{page.Id}/revisions/1");
Assert.NotNull(originalRevision);
Assert.Equal("Titel A", originalRevision.Title);
Assert.Equal("Inhoud A", originalRevision.Markdown);
var restoreResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{page.Id}/revisions/1/restore", (object?)null, pageAfterEdit.ETag);
Assert.Equal(HttpStatusCode.OK, restoreResponse.StatusCode);
var restored = await restoreResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(restored);
Assert.Equal("Titel A", restored.Title);
Assert.Equal("Inhoud A", restored.Markdown);
Assert.Equal(3, restored.CurrentRevisionNumber);
// History is never rewritten: all three revisions remain, including the
// "Titel B" state that briefly existed as the current version.
var revisions = await client.GetFromJsonAsync<List<PageRevisionSummary>>($"/api/v1/pages/{page.Id}/revisions");
Assert.NotNull(revisions);
Assert.Equal(3, revisions.Count);
Assert.Contains(revisions, revision => revision.RevisionNumber == 2 && revision.Title == "Titel B");
}
[Fact]
public async Task Restoring_against_a_stale_etag_is_rejected_as_a_conflict()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Titel A", "WikiPage", "Inhoud A", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Titel B", "Inhoud B", "WikiPage", "Active", null),
page.ETag);
// page.ETag is now stale (it was captured before the edit above).
var restoreResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{page.Id}/revisions/1/restore", (object?)null, page.ETag);
Assert.Equal(HttpStatusCode.PreconditionFailed, restoreResponse.StatusCode);
}
}
@@ -0,0 +1,435 @@
using System.Diagnostics;
using System.Globalization;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// The full-scale proof docs/10-search-and-indexing.md's "Scale verification" section
/// describes and ADR-012 decision 7 deferred to M8: 100,000 current pages and
/// 1,000,000 revisions, with captured p50/p95/p99 latency, query plans, index sizes,
/// result correctness and a no-leak assertion. Tagged "ReleaseScale" and excluded from
/// the routine test run (scripts/verify.mjs filters it out by default) — seeding and
/// measuring at this scale is a deliberate, occasional release-gate exercise, not a
/// per-commit check. Run explicitly via:
/// <c>dotnet test --filter "Category=ReleaseScale"</c>.
/// Data is generated directly in PostgreSQL via <c>generate_series</c> (a single
/// server-side INSERT...SELECT per table), not one round trip per row — the only way
/// 1.1 million rows seed in a runtime proportionate to a release-gate step rather than
/// a dedicated benchmarking rig.
/// </summary>
[Collection(PostgresTestGroup.Name)]
[Trait("Category", "ReleaseScale")]
public sealed class SearchFullScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int PageCount = 100_000;
private const int RevisionsPerPage = 10; // 100,000 * 10 = 1,000,000 revisions.
private const int SampleCount = 30;
[Fact]
public async Task Search_and_tree_reads_stay_correct_and_bounded_at_the_documented_full_scale()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var report = new StringBuilder();
report.AppendLine(CultureInfo.InvariantCulture, $"M8 full-scale search/tree report — {DateTimeOffset.UtcNow:O}");
report.AppendLine(CultureInfo.InvariantCulture, $"Target: {PageCount:N0} current pages, {PageCount * RevisionsPerPage:N0} revisions (docs/10-search-and-indexing.md).");
Guid workspaceId;
Guid needleId;
var seedStopwatch = Stopwatch.StartNew();
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
workspaceId = workspace.Id;
var connectionOptions = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Infrastructure.Persistence.DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
needleId = await SeedPagesAsync(connection, workspaceId);
await SeedRevisionsAsync(connection, workspaceId);
// A deliberately separate, inaccessible-from-workspace-1 workspace with a
// rare term in its title — proves no cross-workspace leak at scale
// (docs/10-search-and-indexing.md "Tests must include private pages whose
// titles and rare terms would reveal their existence").
await SeedOtherWorkspaceLeakCheckAsync(connection);
// The release measurement represents steady-state reads after an import,
// not PostgreSQL's concurrent statistics, autovacuum or checkpoint work
// immediately after 1.1 million bulk inserts. Make that boundary explicit
// so host IO timing cannot decide which of the 30 samples cross the
// unchanged 1s p95 gate. This does not warm the measured HTTP queries or
// weaken their dataset, sample count or latency bound.
await PrepareReadMeasurementAsync(connection);
}
seedStopwatch.Stop();
report.AppendLine(CultureInfo.InvariantCulture, $"Seed time: {seedStopwatch.Elapsed}.");
using (var scope = Factory.Services.CreateScope())
{
// These counts validate the completed 1.1-million-row seed; they are not
// part of the measured HTTP read latency below. Use the same explicit
// release-scale command timeout as the bulk seed/ANALYZE operations.
// EF's normal 30-second application timeout can expire while Docker
// Desktop is still flushing the just-seeded relation, which makes host IO
// timing fail setup before any actual search performance is measured.
var connectionOptions = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Infrastructure.Persistence.DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
await using var countCommand = new NpgsqlCommand(
"SELECT (SELECT count(*) FROM pages), (SELECT count(*) FROM page_revisions)",
connection)
{
CommandTimeout = 300
};
await using var reader = await countCommand.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync(), "Release-scale row-count query returned no row.");
var actualPages = reader.GetInt64(0);
var actualRevisions = reader.GetInt64(1);
report.AppendLine(CultureInfo.InvariantCulture, $"Actual seeded rows: pages={actualPages:N0}, page_revisions={actualRevisions:N0}.");
Assert.True(actualPages >= PageCount, $"Expected at least {PageCount:N0} pages, found {actualPages:N0}.");
Assert.True(actualRevisions >= PageCount * (long)RevisionsPerPage, $"Expected at least {PageCount * RevisionsPerPage:N0} revisions, found {actualRevisions:N0}.");
}
// --- correctness: the needle is found, and nothing from the other workspace leaks ---
var needleResponse = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=xyzzyplugh");
Assert.NotNull(needleResponse);
Assert.Contains(needleResponse.Items, item => item.Id == needleId);
var leakResponse = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=quibblefrastication");
Assert.NotNull(leakResponse);
Assert.Empty(leakResponse.Items);
report.AppendLine("Correctness: needle term found; other-workspace rare term does not leak. PASS.");
var selectiveTagResponse = await client.GetFromJsonAsync<SearchResponse>(
"/api/v1/search?q=documentatie&tag=selective-scale-test");
Assert.NotNull(selectiveTagResponse);
Assert.NotEmpty(selectiveTagResponse.Items);
Assert.All(selectiveTagResponse.Items, item => Assert.Contains("selective-scale-test", item.Tags));
report.AppendLine("Correctness: selective tag returns only tagged pages. PASS.");
// --- latency: p50/p95/p99 across representative query shapes ---
report.AppendLine();
report.AppendLine("Latency (ms), 30 samples per query shape:");
var measurements = new[]
{
await MeasureAsync(client, report, "common term (\"documentatie\")", "/api/v1/search?q=documentatie"),
await MeasureAsync(client, report, "rare needle term", "/api/v1/search?q=xyzzyplugh"),
await MeasureAsync(client, report, "typo-tolerant title (trigram)", "/api/v1/search?q=documentaite"),
await MeasureAsync(client, report, "tag filter (all pages carry the tag)", "/api/v1/search?q=documentatie&tag=scale-test"),
await MeasureAsync(client, report, "selective tag filter", "/api/v1/search?q=documentatie&tag=selective-scale-test")
};
// --- query plan + index sizes ---
using (var scope = Factory.Services.CreateScope())
{
var connectionOptions = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Infrastructure.Persistence.DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the common-term search:");
report.AppendLine(await ExplainAsync(connection, workspaceId, "documentatie"));
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the typo-only trigram search:");
report.AppendLine(await ExplainFuzzyAsync(connection, workspaceId, "documentaite"));
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the broad tag-filter candidate scan:");
report.AppendLine(await ExplainTagFilterAsync(connection, workspaceId, "documentatie", "scale-test"));
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the selective tag-filter candidate scan:");
var selectiveTagPlan = await ExplainTagFilterAsync(connection, workspaceId, "documentatie", "selective-scale-test");
report.AppendLine(selectiveTagPlan);
Assert.Contains("ix_pages_tags", selectiveTagPlan, StringComparison.Ordinal);
report.AppendLine();
report.AppendLine("Index sizes:");
report.AppendLine(await IndexSizesAsync(connection, "pages"));
}
var reportPath = System.IO.Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "artifacts", "m8-scale-report.txt");
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(reportPath)!);
await System.IO.File.WriteAllTextAsync(reportPath, report.ToString());
foreach (var measurement in measurements)
{
// Generous release-gate bound — proving the indexed query stays
// sub-second at true documented scale, not a tight micro-benchmark.
Assert.True(
measurement.P95 < 1000,
$"{measurement.Label}: p95 was {measurement.P95:F1}ms, expected well under 1000ms at {PageCount:N0}-page scale.");
}
}
private static async Task<Guid> SeedPagesAsync(NpgsqlConnection connection, Guid workspaceId)
{
var needleId = Guid.NewGuid();
const string sql = """
INSERT INTO pages (
id, workspace_id, parent_page_id, sort_key, title, slug, markdown,
document_type, document_status, current_revision_number, content_hash,
created_by, updated_by, created_at, updated_at, tags)
SELECT
CASE WHEN n = 1 THEN @needleId::uuid ELSE gen_random_uuid() END,
@workspaceId,
NULL,
lpad(n::text, 12, '0'),
CASE WHEN n = 1 THEN 'Uniek vindbaar trefwoord xyzzyplugh'
ELSE 'Testpagina ' || n || ' ' ||
(CASE WHEN n % 2 = 0 THEN 'documentatie en projecten' ELSE 'technical procedures and code' END)
END,
'schaal-pagina-' || n,
CASE WHEN n = 1
THEN 'Deze pagina bevat het unieke trefwoord xyzzyplugh nergens anders vermeld.'
ELSE repeat(
'Gewone opvultekst met Nederlandse en Engelse woorden door elkaar over documentatie, projecten, code en procedures. ',
5)
END,
0,
1,
1,
md5(n::text),
NULL,
NULL,
now() - (n || ' seconds')::interval,
now(),
CASE WHEN n % 1000 = 0
THEN ARRAY['scale-test', 'selective-scale-test']::text[]
ELSE ARRAY['scale-test']::text[]
END
FROM generate_series(1, @pageCount) AS n
""";
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
command.Parameters.AddWithValue("needleId", needleId);
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("pageCount", PageCount);
await command.ExecuteNonQueryAsync();
return needleId;
}
private static async Task SeedRevisionsAsync(NpgsqlConnection connection, Guid workspaceId)
{
const string sql = """
INSERT INTO page_revisions (
id, page_id, revision_number, title, slug, markdown, parent_page_id,
sort_key, document_type, document_status, content_hash, authored_by,
change_summary, created_at)
SELECT
gen_random_uuid(), p.id, r.rn, p.title, p.slug, p.markdown, p.parent_page_id,
p.sort_key, p.document_type, p.document_status, p.content_hash, NULL, NULL,
p.created_at + (r.rn || ' minutes')::interval
FROM pages p
CROSS JOIN generate_series(1, @revisionsPerPage) AS r(rn)
WHERE p.workspace_id = @workspaceId
""";
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("revisionsPerPage", RevisionsPerPage);
await command.ExecuteNonQueryAsync();
}
private static async Task SeedOtherWorkspaceLeakCheckAsync(NpgsqlConnection connection)
{
const string sql = """
INSERT INTO workspaces (id, name, slug, settings_version, created_at)
VALUES (@workspaceId, 'Andere werkruimte (lekcontrole)', 'andere-werkruimte-lekcontrole', 1, now());
INSERT INTO pages (
id, workspace_id, parent_page_id, sort_key, title, slug, markdown,
document_type, document_status, current_revision_number, content_hash,
created_by, updated_by, created_at, updated_at, tags)
VALUES (
gen_random_uuid(), @workspaceId, NULL, '000000000001',
'Ontoegankelijke pagina quibblefrastication',
'ontoegankelijke-pagina', 'Bevat de zeldzame term quibblefrastication.',
0, 1, 1, md5('other-workspace'), NULL, NULL, now(), now(), ARRAY[]::text[])
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("workspaceId", Guid.NewGuid());
await command.ExecuteNonQueryAsync();
}
private static async Task PrepareReadMeasurementAsync(NpgsqlConnection connection)
{
// Run each VACUUM outside an explicit/multi-statement transaction. Both tables
// are bulk-loaded by this fixture; leaving page_revisions for autovacuum can
// create the same disk contention even though the search query reads pages.
foreach (var statement in new[]
{
"VACUUM (ANALYZE) pages;",
"VACUUM (ANALYZE) page_revisions;",
"CHECKPOINT;"
})
{
await using var command = new NpgsqlCommand(statement, connection)
{
CommandTimeout = 300
};
await command.ExecuteNonQueryAsync();
}
}
private static async Task<LatencyMeasurement> MeasureAsync(HttpClient client, StringBuilder report, string label, string path)
{
var samples = new List<double>(SampleCount);
for (var i = 0; i < SampleCount; i++)
{
var stopwatch = Stopwatch.StartNew();
using var response = await client.GetAsync(path);
stopwatch.Stop();
response.EnsureSuccessStatusCode();
samples.Add(stopwatch.Elapsed.TotalMilliseconds);
}
samples.Sort();
var p50 = Percentile(samples, 0.50);
var p95 = Percentile(samples, 0.95);
var p99 = Percentile(samples, 0.99);
report.AppendLine(CultureInfo.InvariantCulture, $" {label}: p50={p50:F1}ms p95={p95:F1}ms p99={p99:F1}ms (min={samples[0]:F1}ms max={samples[^1]:F1}ms)");
return new LatencyMeasurement(label, p95);
}
private static double Percentile(IReadOnlyList<double> sortedSamples, double percentile)
{
var index = (int)Math.Ceiling(percentile * sortedSamples.Count) - 1;
return sortedSamples[Math.Clamp(index, 0, sortedSamples.Count - 1)];
}
private static async Task<string> ExplainAsync(NpgsqlConnection connection, Guid workspaceId, string term)
{
const string sql = """
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, title
FROM pages
WHERE workspace_id = @workspaceId
AND trashed_at IS NULL
AND search_vector @@ plainto_tsquery('simple', @term)
ORDER BY ts_rank_cd(search_vector, plainto_tsquery('simple', @term)) DESC
LIMIT 20
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("term", term);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(" " + reader.GetString(0));
}
return lines.ToString();
}
private static async Task<string> ExplainFuzzyAsync(NpgsqlConnection connection, Guid workspaceId, string term)
{
await using var transaction = await connection.BeginTransactionAsync();
await using (var threshold = new NpgsqlCommand("SET LOCAL pg_trgm.similarity_threshold = '0.2';", connection, transaction))
{
await threshold.ExecuteNonQueryAsync();
}
const string sql = """
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, title
FROM pages
WHERE workspace_id = @workspaceId
AND trashed_at IS NULL
AND title % @term
ORDER BY similarity(title, @term) DESC, updated_at DESC, id DESC
LIMIT 20
""";
await using var command = new NpgsqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("term", term);
var lines = new StringBuilder();
{
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
lines.AppendLine(" " + reader.GetString(0));
}
}
await transaction.CommitAsync();
return lines.ToString();
}
private static async Task<string> ExplainTagFilterAsync(
NpgsqlConnection connection,
Guid workspaceId,
string term,
string tag)
{
const string sql = """
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT p.id, p.updated_at,
ts_rank_cd(p.search_vector, plainto_tsquery('simple', @term))::double precision AS rank
FROM pages AS p
WHERE p.workspace_id = @workspaceId
AND p.trashed_at IS NULL
AND p.search_vector @@ plainto_tsquery('simple', @term)
AND p.tags @> ARRAY[@tag]::text[]
ORDER BY rank DESC, p.updated_at DESC, p.id DESC
LIMIT 20
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("term", term);
command.Parameters.AddWithValue("tag", tag);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(" " + reader.GetString(0));
}
return lines.ToString();
}
private static async Task<string> IndexSizesAsync(NpgsqlConnection connection, string tableName)
{
const string sql = """
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass))
FROM pg_indexes
WHERE tablename = @tableName
ORDER BY indexname
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tableName", tableName);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(CultureInfo.InvariantCulture, $" {reader.GetString(0)}: {reader.GetString(1)}");
}
return lines.ToString();
}
private sealed record LatencyMeasurement(string Label, double P95);
}
@@ -0,0 +1,99 @@
using System.Diagnostics;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// M3-A07 proof at a reduced, locally-runnable scale. docs/10-search-and-indexing.md's
/// aspirational scale-verification dataset (100,000 current pages, 1,000,000 revisions)
/// requires dedicated benchmarking infrastructure and multi-minute seeding that does not
/// fit a routine local/CI test run; that full-scale verification is explicitly deferred
/// to M8's "Performance and scale evidence" gate (docs/adr/ADR-012-m3-discovery-revisions-search.md).
/// This test instead proves the search query plan and GIN indexes keep a bounded,
/// permission-filtered query fast and correct at a smaller but still non-trivial
/// synthetic scale — the same proof shape as M2-A07's 10,000-page tree scale test.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class SearchScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int PageCount = 5_000;
[Fact]
public async Task Search_stays_fast_and_correct_against_5000_pages_with_one_needle()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
Guid needleId;
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
var owner = await db.Users.AsNoTracking().FirstAsync();
var sortKey = SortKey.First();
var batch = new List<Page>(1000);
Page? needle = null;
for (var i = 0; i < PageCount; i++)
{
var isNeedle = i == PageCount / 2;
var page = Page.Create(
workspace.Id,
null,
sortKey,
PageTitle.Create(isNeedle ? "Uniek vindbaar trefwoord xyzzyplugh" : $"Vulpagina {i:D5}"),
DocumentType.WikiPage,
isNeedle ? "Deze pagina bevat het unieke trefwoord xyzzyplugh nergens anders." : "Gewone opvultekst zonder bijzondere termen.",
owner.Id,
DateTimeOffset.UtcNow);
if (isNeedle)
{
needle = page;
}
batch.Add(page);
sortKey = SortKey.Next(sortKey);
if (batch.Count == 1000)
{
db.Pages.AddRange(batch);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
batch.Clear();
}
}
if (batch.Count > 0)
{
db.Pages.AddRange(batch);
await db.SaveChangesAsync();
}
Assert.NotNull(needle);
needleId = needle.Id;
}
var stopwatch = Stopwatch.StartNew();
var response = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=xyzzyplugh");
stopwatch.Stop();
Assert.NotNull(response);
Assert.Contains(response.Items, item => item.Id == needleId);
// Generous bound for a CI/dev machine, proving the GIN-indexed query stays
// bounded rather than scanning ~5,000 rows' worth of text at request time — not
// a precise benchmark (that belongs to the M8 release scale suite at full
// documented scale).
Assert.True(
stopwatch.Elapsed < TimeSpan.FromSeconds(3),
$"Searching {PageCount} pages for one needle term took {stopwatch.Elapsed}, expected well under 3s.");
}
}
@@ -0,0 +1,147 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>PostgreSQL full-text + trigram search: title/body matching, tag/type
/// filters, typo tolerance, trash exclusion and authentication
/// (docs/27-acceptance-matrix.md, M3-A04..A06).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class SearchTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Search_finds_a_page_by_title_and_by_body_text()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "PostgreSQL back-uprunbook", "Runbook", "Stappen om een back-up te herstellen.", null));
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Onderwerp zonder verband", "WikiPage", "Volledig andere inhoud.", null));
var byTitle = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=back-uprunbook");
Assert.NotNull(byTitle);
Assert.Contains(byTitle.Items, item => item.Title == "PostgreSQL back-uprunbook");
var byBody = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=herstellen");
Assert.NotNull(byBody);
Assert.Contains(byBody.Items, item => item.Title == "PostgreSQL back-uprunbook");
Assert.DoesNotContain(byBody.Items, item => item.Title == "Onderwerp zonder verband");
}
[Fact]
public async Task Search_is_typo_tolerant_for_titles_via_trigram_similarity()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Troubleshooting netwerkverbinding", "Troubleshooting", "Inhoud.", null));
// Deliberate typo: "Trubleshootng" instead of "Troubleshooting".
var response = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=Trubleshootng");
Assert.NotNull(response);
Assert.Contains(response.Items, item => item.Title == "Troubleshooting netwerkverbinding");
}
[Fact]
public async Task Search_can_be_filtered_by_tag_and_by_document_type()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, "Gearchiveerd project", "ProjectDocument", "Inhoud.", null, ["archief", "netwerk"]));
await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, "Actief project", "ProjectDocument", "Inhoud.", null, ["actief"]));
var byTag = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=project&tag=archief");
Assert.NotNull(byTag);
Assert.Single(byTag.Items);
Assert.Equal("Gearchiveerd project", byTag.Items[0].Title);
var byType = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=project&type=ProjectDocument");
Assert.NotNull(byType);
Assert.Equal(2, byType.Items.Count);
}
[Fact]
public async Task Search_never_returns_a_trashed_page()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Binnenkort verwijderde pagina", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var beforeTrash = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=verwijderde");
Assert.NotNull(beforeTrash);
Assert.Contains(beforeTrash.Items, item => item.Id == page.Id);
await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
var afterTrash = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=verwijderde");
Assert.NotNull(afterTrash);
Assert.DoesNotContain(afterTrash.Items, item => item.Id == page.Id);
}
[Fact]
public async Task Search_requires_authentication()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync("/api/v1/search?q=iets");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Search_cursor_pagination_is_deterministic_and_covers_every_result_without_duplicates()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
for (var i = 0; i < 12; i++)
{
await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, $"Cursortest onderwerp {i:D2}", "WikiPage", "Gedeelde zoekterm cursorproef.", null));
}
var seen = new HashSet<Guid>();
string? cursor = null;
var pages = 0;
do
{
var url = cursor is null
? "/api/v1/search?q=cursorproef&limit=5"
: $"/api/v1/search?q=cursorproef&limit=5&cursor={Uri.EscapeDataString(cursor)}";
var response = await client.GetFromJsonAsync<SearchResponse>(url);
Assert.NotNull(response);
foreach (var item in response.Items)
{
Assert.True(seen.Add(item.Id), $"Item {item.Id} was returned on more than one page.");
}
cursor = response.NextCursor;
pages++;
Assert.True(pages < 10, "Pagination did not terminate within a reasonable number of pages.");
} while (cursor is not null);
Assert.Equal(12, seen.Count);
}
}
@@ -0,0 +1,326 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// Automatic reclamation of expired, abandoned page-creation staging sessions
/// (docs/adr/ADR-029-v2-r5-final-hardening.md). Every scenario here drives the exact
/// same <c>CleanupExpiredPageCreationSessionsUseCase</c> the background worker itself
/// calls, through the admin "run now" endpoint — real PostgreSQL, real attachment
/// blob files on disk, no mocked persistence.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class StagingSessionCleanupTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private static readonly byte[] OtherPngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0xFF, 0xEE, 0xDD, 0xCC];
private async Task<HttpClient> OwnerClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static async Task<Guid> BeginSessionAsync(HttpClient client)
{
var response = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/pages/staged", new BeginPageCreationSessionRequest(null));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<PageCreationSessionResponse>();
Assert.NotNull(session);
return session.SessionId;
}
private static async Task<Guid> UploadAsync(HttpClient client, Guid sessionId, byte[] bytes, string filename)
{
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", bytes, filename, "image/png");
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
return attachment.Id;
}
private async Task ExpireAsync(Guid sessionId)
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE page_creation_sessions SET expires_at = {DateTimeOffset.UtcNow.AddHours(-1)} WHERE id = {sessionId}");
}
private static async Task<RunStagingCleanupResponse> RunCleanupAsync(HttpClient client, bool dryRun = false)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/staging-cleanup/run", new RunStagingCleanupRequest(dryRun));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<RunStagingCleanupResponse>();
Assert.NotNull(body);
return body;
}
[Fact]
public async Task An_empty_expired_session_is_reclaimed_with_nothing_to_remove()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
await ExpireAsync(sessionId);
var result = await RunCleanupAsync(owner);
Assert.Equal(1, result.Result.SessionsClaimed);
Assert.Equal(0, result.Result.AttachmentReferencesRemoved);
Assert.Equal(0, result.Result.BlobsRemoved);
Assert.Empty(result.Result.Warnings);
}
[Fact]
public async Task An_expired_session_with_one_attachment_removes_the_reference_and_the_blob()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadAsync(owner, sessionId, PngBytes, "een.png");
await ExpireAsync(sessionId);
var result = await RunCleanupAsync(owner);
Assert.Equal(1, result.Result.SessionsClaimed);
Assert.Equal(1, result.Result.AttachmentReferencesRemoved);
Assert.Equal(1, result.Result.BlobsRemoved);
var content = await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.NotFound, content.StatusCode);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
Assert.Equal(0, await db.AttachmentBlobs.CountAsync(blob => blob.ByteLength == PngBytes.LongLength));
}
[Fact]
public async Task An_expired_session_with_multiple_attachments_removes_every_one()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var first = await UploadAsync(owner, sessionId, PngBytes, "een.png");
var second = await UploadAsync(owner, sessionId, OtherPngBytes, "twee.png");
await ExpireAsync(sessionId);
var result = await RunCleanupAsync(owner);
Assert.Equal(2, result.Result.AttachmentReferencesRemoved);
Assert.Equal(2, result.Result.BlobsRemoved);
Assert.Equal(HttpStatusCode.NotFound, (await owner.GetAsync($"/api/v1/attachments/{first}/content")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await owner.GetAsync($"/api/v1/attachments/{second}/content")).StatusCode);
}
[Fact]
public async Task A_blob_still_shared_by_a_published_page_survives_cleanup_of_an_expired_session()
{
using var owner = await OwnerClientAsync();
var pageResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/pages", new CreatePageRequest(null, $"Blob-behoud {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var publishedUpload = await SetupHelper.PostMultipartWithCsrfAsync(
owner, $"/api/v1/pages/{page.Id}/attachments", PngBytes, "gepubliceerd.png", "image/png");
Assert.Equal(HttpStatusCode.OK, publishedUpload.StatusCode);
var publishedAttachment = await publishedUpload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(publishedAttachment);
var sessionId = await BeginSessionAsync(owner);
var stagedId = await UploadAsync(owner, sessionId, PngBytes, "nog-niet.png");
await ExpireAsync(sessionId);
var result = await RunCleanupAsync(owner);
Assert.Equal(1, result.Result.AttachmentReferencesRemoved);
// The blob itself is still referenced by the published page's own reference —
// never physically deleted.
Assert.Equal(0, result.Result.BlobsRemoved);
Assert.Equal(HttpStatusCode.NotFound, (await owner.GetAsync($"/api/v1/attachments/{stagedId}/content")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await owner.GetAsync($"/api/v1/attachments/{publishedAttachment.Id}/content")).StatusCode);
}
[Fact]
public async Task An_active_not_yet_expired_session_is_never_touched()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadAsync(owner, sessionId, PngBytes, "actief.png");
var result = await RunCleanupAsync(owner);
Assert.Equal(0, result.Result.SessionsExpired);
Assert.Equal(0, result.Result.SessionsClaimed);
// Still fully usable afterwards — the session was never touched.
Assert.Equal(HttpStatusCode.OK, (await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content")).StatusCode);
var commit = await SetupHelper.PostWithCsrfAsync(
owner, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Nog steeds actief", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, commit.StatusCode);
}
[Fact]
public async Task Running_cleanup_twice_in_a_row_is_safe_and_the_second_run_finds_nothing_left_to_do()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
await UploadAsync(owner, sessionId, PngBytes, "dubbel.png");
await ExpireAsync(sessionId);
var first = await RunCleanupAsync(owner);
Assert.Equal(1, first.Result.SessionsClaimed);
var second = await RunCleanupAsync(owner);
Assert.Equal(0, second.Result.SessionsExpired);
Assert.Equal(0, second.Result.SessionsClaimed);
Assert.Empty(second.Result.Warnings);
}
[Fact]
public async Task Two_concurrent_cleanup_runs_never_double_process_the_same_session()
{
using var owner = await OwnerClientAsync();
var sessionIds = new List<Guid>();
for (var i = 0; i < 5; i++)
{
var sessionId = await BeginSessionAsync(owner);
await UploadAsync(owner, sessionId, PngBytes, $"gelijktijdig-{i}.png");
await ExpireAsync(sessionId);
sessionIds.Add(sessionId);
}
using var ownerForSecondCall = await OwnerClientAsync();
var results = await Task.WhenAll(RunCleanupAsync(owner), RunCleanupAsync(ownerForSecondCall));
var totalClaimed = results.Sum(result => result.Result.SessionsClaimed);
// Exactly 5 sessions total, each claimed by exactly one of the two concurrent
// runs — never zero (lost forever), never two (double-processed).
Assert.Equal(5, totalClaimed);
Assert.All(results, result => Assert.Empty(result.Result.Warnings));
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var stillOpen = await db.PageCreationSessions
.CountAsync(session => sessionIds.Contains(session.Id) && session.Status == PageCreationSessionStatus.Open);
Assert.Equal(0, stillOpen);
}
[Fact]
public async Task A_missing_physical_blob_file_does_not_fail_cleanup_and_the_database_row_is_still_removed()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadAsync(owner, sessionId, PngBytes, "verdwenen-bestand.png");
await ExpireAsync(sessionId);
// Simulate the file having already been removed out-of-band (e.g. a previous
// partial run's crash-after-DB-delete, or manual operator intervention).
string blobPath;
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var reference = await db.AttachmentReferences.SingleAsync(reference => reference.Id == attachmentId);
var hash = reference.BlobSha256;
blobPath = Path.Combine(Factory.AttachmentsDirectory, hash[..2], hash[2..4], hash);
}
Assert.True(File.Exists(blobPath));
File.Delete(blobPath);
var result = await RunCleanupAsync(owner);
Assert.Empty(result.Result.Warnings);
Assert.Equal(1, result.Result.AttachmentReferencesRemoved);
// The blob store reports "already gone" rather than throwing — the database
// row is still cleaned up (reported as removed) instead of dangling forever.
Assert.Equal(1, result.Result.BlobsRemoved);
}
[Fact]
public async Task Dry_run_reports_the_expired_count_without_claiming_or_deleting_anything()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadAsync(owner, sessionId, PngBytes, "droog.png");
await ExpireAsync(sessionId);
var preview = await RunCleanupAsync(owner, dryRun: true);
Assert.True(preview.DryRun);
Assert.Equal(1, preview.Result.SessionsExpired);
Assert.Equal(0, preview.Result.SessionsClaimed);
// Nothing was actually claimed or deleted by the dry run — a real run
// afterwards still finds the same session expired and claims (and cleans up)
// it exactly once. The attachment itself already reads as gone even before the
// real cleanup pass runs (an expired-but-not-yet-reclaimed session is already
// treated as closed for authorization purposes, matching
// CommitPageCreationSessionUseCase's own expiry check) — the dry run does not
// change that outcome, it only reports counts without mutating anything.
Assert.Equal(HttpStatusCode.NotFound, (await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content")).StatusCode);
var real = await RunCleanupAsync(owner);
Assert.Equal(1, real.Result.SessionsClaimed);
Assert.Equal(1, real.Result.AttachmentReferencesRemoved);
}
[Fact]
public async Task Status_reflects_active_and_expired_counts_and_the_most_recent_run()
{
using var owner = await OwnerClientAsync();
var activeSession = await BeginSessionAsync(owner);
var expiredSession = await BeginSessionAsync(owner);
await ExpireAsync(expiredSession);
var beforeStatus = await owner.GetFromJsonAsync<StagingCleanupStatusResponse>("/api/v1/admin/staging-cleanup/status");
Assert.NotNull(beforeStatus);
Assert.Equal(2, beforeStatus.ActiveSessionCount);
Assert.Equal(1, beforeStatus.ExpiredPendingCleanupCount);
await RunCleanupAsync(owner);
var afterStatus = await owner.GetFromJsonAsync<StagingCleanupStatusResponse>("/api/v1/admin/staging-cleanup/status");
Assert.NotNull(afterStatus);
Assert.Equal(1, afterStatus.ActiveSessionCount);
Assert.Equal(0, afterStatus.ExpiredPendingCleanupCount);
Assert.NotNull(afterStatus.LastResult);
Assert.Equal("manual", afterStatus.LastRunTrigger);
Assert.NotNull(afterStatus.LastRunCompletedAt);
// The still-active session remains untouched.
var commit = await SetupHelper.PostWithCsrfAsync(
owner, $"/api/v1/pages/staged/{activeSession}/commit",
new CommitPageCreationSessionRequest("Nog steeds actief", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, commit.StatusCode);
}
[Fact]
public async Task A_reader_cannot_view_status_or_trigger_cleanup()
{
using var owner = await OwnerClientAsync();
var createResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/admin/users",
new CreateUserRequest("Lezer", "lezer-cleanup@itworx.test", "reader-strong-password", "Reader"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
using var reader = Factory.CreateClient();
await SetupHelper.LoginAsync(reader, "lezer-cleanup@itworx.test", "reader-strong-password");
Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/v1/admin/staging-cleanup/status")).StatusCode);
var run = await SetupHelper.PostWithCsrfAsync(reader, "/api/v1/admin/staging-cleanup/run", new RunStagingCleanupRequest(true));
Assert.Equal(HttpStatusCode.Forbidden, run.StatusCode);
}
}
@@ -0,0 +1,65 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Templates create valid study note, runbook, ADR and troubleshooting
/// documents (docs/27-acceptance-matrix.md, M2-A06).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class TemplateTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task All_four_built_in_templates_are_listed()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var templates = await client.GetFromJsonAsync<IReadOnlyList<TemplateSummary>>("/api/v1/templates");
Assert.NotNull(templates);
Assert.Equal(4, templates.Count);
Assert.Contains(templates, t => t.Key == "study-note");
Assert.Contains(templates, t => t.Key == "runbook");
Assert.Contains(templates, t => t.Key == "architecture-decision");
Assert.Contains(templates, t => t.Key == "troubleshooting");
}
[Theory]
[InlineData("study-note", "StudyNote", "## Kernidee")]
[InlineData("runbook", "Runbook", "## Rollback")]
[InlineData("architecture-decision", "ArchitectureDecision", "## Beslissing")]
[InlineData("troubleshooting", "Troubleshooting", "## Escalatie")]
public async Task Creating_a_page_from_a_template_produces_the_expected_structure(
string templateKey,
string expectedDocumentType,
string expectedSectionHeading)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, $"Sjabloontest {templateKey}", null, null, templateKey));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Equal(expectedDocumentType, page.DocumentType);
Assert.Contains($"# Sjabloontest {templateKey}", page.Markdown, StringComparison.Ordinal);
Assert.Contains(expectedSectionHeading, page.Markdown, StringComparison.Ordinal);
}
[Fact]
public async Task An_unknown_template_key_is_rejected()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Ongeldig sjabloon", null, null, "does-not-exist"));
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
}
@@ -0,0 +1,95 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>The explicit administrative purge never removes a page still within its
/// trash retention window, and is never reachable except by an administrator
/// (docs/09-data-safety.md: "never a permanent delete through a normal UI flow").</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class TrashPurgeTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Purge_does_not_remove_a_trashed_page_still_within_its_retention_window()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Recent verwijderd", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
var purgeResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/trash/purge", (object?)null);
Assert.Equal(HttpStatusCode.OK, purgeResponse.StatusCode);
var result = await purgeResponse.Content.ReadFromJsonAsync<TrashPurgeResponse>();
Assert.NotNull(result);
Assert.Equal(0, result.PurgedCount);
var stillThere = await client.GetAsync($"/api/v1/pages/{page.Id}");
Assert.Equal(HttpStatusCode.OK, stillThere.StatusCode);
}
[Fact]
public async Task Purge_permanently_removes_a_trashed_page_once_its_retention_window_has_elapsed()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Lang geleden verwijderd", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
// Directly back-date the retention window rather than waiting 30 real days —
// the use case's own eligibility computation (Trash(...) with a fixed
// TimeSpan) is exercised elsewhere; this test proves the purge query itself
// correctly identifies and removes an eligible row.
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE pages SET purge_after = {DateTimeOffset.UtcNow.AddDays(-1)} WHERE id = {page.Id}");
}
var purgeResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/trash/purge", (object?)null);
Assert.Equal(HttpStatusCode.OK, purgeResponse.StatusCode);
var result = await purgeResponse.Content.ReadFromJsonAsync<TrashPurgeResponse>();
Assert.NotNull(result);
Assert.Equal(1, result.PurgedCount);
var afterPurge = await client.GetAsync($"/api/v1/pages/{page.Id}");
Assert.Equal(HttpStatusCode.NotFound, afterPurge.StatusCode);
}
[Fact]
public async Task Purge_is_administrator_only()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var editorEmail = "redacteur-purge@itworx.test";
var editorPassword = "correct-horse-battery-staple";
await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/users",
new CreateUserRequest("Redacteur", editorEmail, editorPassword, "Editor"));
using var editorClient = Factory.CreateClient();
await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword);
var response = await SetupHelper.PostWithCsrfAsync(editorClient, "/api/v1/admin/trash/purge", (object?)null);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
@@ -0,0 +1,212 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
/// <summary>
/// M9-A01: "A complete synthetic cutover rehearsal, including rollback, succeeds
/// before real data is used." Distinct from `LeafWikiImportTests` (which proves each
/// mechanism — preflight, execution, idempotency, rollback, conflict handling —
/// correct in isolation): this test walks the actual documented
/// "Real migration procedure" (docs/17-leafwiki-migration.md) end to end, against one
/// coherent synthetic fixture combining nested pages, UTF-8/special characters,
/// resolved and unresolved wiki-links, a present and a missing attachment reference,
/// and produces a written cutover-rehearsal report (the "cutover record" M9's
/// `requiredEvidence` names) exactly as an operator would read before a real cutover.
/// Steps 1-3 and 9-15 of the documented procedure are LeafWiki-source-specific or
/// require the real production Unraid server (M9-A02..A06, all marked
/// <c>"external": true</c> in product/milestones.json) and are explicitly out of
/// scope here — this proves everything locally executable.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class CutoverRehearsalTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] DiagramPngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
[Fact]
public async Task A_full_synthetic_cutover_rehearsal_including_rollback_succeeds()
{
var report = new StringBuilder();
report.AppendLine(CultureInfo.InvariantCulture, $"M9 synthetic cutover rehearsal report — {DateTimeOffset.UtcNow:O}");
report.AppendLine("Synthetic fixture only — no real LeafWiki source was read (CLAUDE.md §2/§9).");
report.AppendLine();
var source = Path.Combine(Factory.ImportsDirectory, "cutover-rehearsal");
Directory.CreateDirectory(source);
BuildFixture(source);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var sourceRelativePath = Path.GetRelativePath(Factory.ImportsDirectory, source).Replace('\\', '/');
// --- Step: inspector/preflight (docs/17 "Real migration procedure" step 4) ---
var preflightResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight",
new PreflightImportRequest(sourceRelativePath, $"cutover-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.OK, preflightResponse.StatusCode);
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.NotNull(plan.JobId);
report.AppendLine("Preflight:");
report.AppendLine(CultureInfo.InvariantCulture, $" Adapter: {plan.AdapterName}");
report.AppendLine(CultureInfo.InvariantCulture, $" Discovered: {plan.DiscoveredCount}, Planned: {plan.PlannedCount}");
report.AppendLine(CultureInfo.InvariantCulture, $" Warnings: {plan.Warnings.Count}");
foreach (var warning in plan.Warnings)
{
report.AppendLine(CultureInfo.InvariantCulture, $" - {warning}");
}
report.AppendLine(CultureInfo.InvariantCulture, $" Blockers: {plan.Blockers.Count}");
// --- Step: resolve all blockers before proceeding (docs/17 step 5) ---
// A well-formed synthetic fixture produces none; a real cutover would stop
// here and resolve each one before continuing.
Assert.Empty(plan.Blockers);
// One reported warning is expected: the missing attachment reference below.
Assert.Contains(plan.Warnings, warning => warning.Contains("ontbrekend.png", StringComparison.Ordinal));
// --- Step: import into a non-production target (docs/17 step 6) ---
// "Non-production target" here is this test class's own isolated database —
// never the real production instance, matching every other integration test
// in this suite.
var executeResponse = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(sourceRelativePath)}",
(object?)null);
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
var executeResult = await executeResponse.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executeResult);
report.AppendLine();
report.AppendLine("Execution:");
report.AppendLine(CultureInfo.InvariantCulture, $" Imported: {executeResult.Imported}, Skipped: {executeResult.Skipped}, Failed: {executeResult.Failed}");
report.AppendLine(CultureInfo.InvariantCulture, $" Source unchanged after execution (fingerprint before == after): {executeResult.SourceUnchangedAfterExecution}");
Assert.Equal(0, executeResult.Failed);
// Source fingerprint immutability (docs/17 "A complete source fingerprint is
// captured before and after") — the synthetic source on disk was never
// mutated by the importer.
Assert.True(executeResult.SourceUnchangedAfterExecution);
// --- Step: validate representative content (docs/17 step 7 / "Validation") ---
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
// The filesystem-markdown adapter treats a root index file as one root-level
// "front page", not an umbrella parent of every other top-level directory
// (matching LeafWikiImportTests' own established preflight assertions) — so
// "Overstap-hoofdpagina" and "School" are root-level siblings here.
Assert.Contains(roots.Items, item => item.Title == "Overstap-hoofdpagina");
var schoolPage = Assert.Single(roots.Items, item => item.Title == "School");
var mathChildren = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{schoolPage.Id}/children");
Assert.NotNull(mathChildren);
var mathPage = Assert.Single(mathChildren.Items, item => item.Title == "Wiskunde: café-oefeningen");
var mathDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{mathPage.Id}");
Assert.NotNull(mathDetail);
// Link resolution: the wiki-links are authored on the School page (which
// links to Wiskunde and to a nonexistent page), so the resolved-vs-unresolved
// check reads the School page's own rendered HTML, not the link target's.
var schoolDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{schoolPage.Id}");
Assert.NotNull(schoolDetail);
var linksResolved = schoolDetail.RenderedHtml.Contains("<a href=", StringComparison.Ordinal);
var linksUnresolvedPreserved = schoolDetail.RenderedHtml.Contains("[[Onbestaande pagina]]", StringComparison.Ordinal);
Assert.True(linksResolved);
Assert.True(linksUnresolvedPreserved);
// UTF-8/special characters preserved exactly, not mangled or replaced.
Assert.Contains("café-oefeningen", mathDetail.Title, StringComparison.Ordinal);
Assert.Contains("Formules met bijzondere tekens: café, naïef, © 2026.", mathDetail.Markdown, StringComparison.Ordinal);
// Attachment present and searchable/servable; missing one already reported as
// a preflight warning above, never silently dropped.
var attachments = await client.GetFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Attachments.AttachmentListResponse>($"/api/v1/pages/{mathPage.Id}/attachments");
Assert.NotNull(attachments);
var attachment = Assert.Single(attachments.Items);
var attachmentContentResponse = await client.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.OK, attachmentContentResponse.StatusCode);
var attachmentBytes = await attachmentContentResponse.Content.ReadAsByteArrayAsync();
Assert.Equal(DiagramPngBytes, attachmentBytes);
// Target searchability: the imported content is findable, not just present.
var searchResponse = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=bijzondere");
Assert.NotNull(searchResponse);
Assert.Contains(searchResponse.Items, item => item.Id == mathPage.Id);
report.AppendLine();
report.AppendLine("Validation (all checked, all passed):");
report.AppendLine(" Hierarchy (Overstap-hoofdpagina and School as root siblings, Wiskunde nested under School): OK");
report.AppendLine(CultureInfo.InvariantCulture, $" Resolved link rendered as anchor: {linksResolved}");
report.AppendLine(CultureInfo.InvariantCulture, $" Unresolved link preserved literally, not broken: {linksUnresolvedPreserved}");
report.AppendLine(" UTF-8/special characters preserved exactly: True");
report.AppendLine(CultureInfo.InvariantCulture, $" Attachment present and byte-identical to source: True ({attachment.Filename}, {attachmentBytes.Length} bytes)");
report.AppendLine(" Target searchability (imported content findable): True");
// --- Step: rehearse rollback (docs/17 step 8) ---
var rollbackResponse = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/admin/imports/{plan.JobId}/rollback", (object?)null);
Assert.Equal(HttpStatusCode.OK, rollbackResponse.StatusCode);
var rollback = await rollbackResponse.Content.ReadFromJsonAsync<RollbackImportResponse>();
Assert.NotNull(rollback);
var rootsAfterRollback = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(rootsAfterRollback);
Assert.DoesNotContain(rootsAfterRollback.Items, item => item.Title == "Overstap-hoofdpagina");
report.AppendLine();
report.AppendLine("Rollback rehearsal:");
report.AppendLine(CultureInfo.InvariantCulture, $" Removed pages: {rollback.RemovedPages}, Preserved pages: {rollback.PreservedPages}, Removed attachments: {rollback.RemovedAttachments}");
report.AppendLine(" Root import page no longer present after rollback: True");
report.AppendLine(" Source file on disk untouched throughout (never renamed/repaired/deleted): True");
report.AppendLine();
report.AppendLine("Steps requiring a real LeafWiki source and/or production Unraid server");
report.AppendLine("(M9-A02..A06, external per product/milestones.json — not exercised here):");
report.AppendLine(" - Real source fingerprint/inspection (M9-A02).");
report.AppendLine(" - Real import report covering every discovered item (M9-A03).");
report.AppendLine(" - User validation of representative real content (M9-A04).");
report.AppendLine(" - LeafWiki read-only rollback window (M9-A05).");
report.AppendLine(" - Production deployment/backup/restore verification on the target Unraid server (M9-A06).");
var reportPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "artifacts", "m9-cutover-rehearsal-report.txt");
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
await File.WriteAllTextAsync(reportPath, report.ToString());
}
private static void BuildFixture(string root)
{
WriteFile(root, "index.md", "# Overstap-hoofdpagina\nDe hoofdpagina van de synthetische overstap-rehearsal.");
WriteFile(
root,
"school/index.md",
"# School\nZie [[Wiskunde: café-oefeningen]] en [[Onbestaande pagina]] voor meer.");
WriteFile(
root,
"school/wiskunde.md",
"# Wiskunde: café-oefeningen\n" +
"Formules met bijzondere tekens: café, naïef, © 2026.\n\n" +
// Asset references are matched against the fingerprint's source-root-
// relative path, not a path relative to the referencing Markdown file's
// own directory — so "school/diagram.png" here, not "diagram.png".
"![Diagram](school/diagram.png)\n\n" +
"![Ontbrekend](school/ontbrekend.png)\n");
File.WriteAllBytes(Path.Combine(root, "school", "diagram.png"), DiagramPngBytes);
// ontbrekend.png is deliberately never written — proves the importer reports
// a missing referenced attachment rather than silently dropping the
// reference or failing the whole import.
}
private static void WriteFile(string root, string relativePath, string content)
{
var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
}
@@ -0,0 +1,192 @@
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.Domain.Projects;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
/// <summary>
/// V2-M9: rehearses the one real production upgrade path — an existing, already-
/// populated V1 database (every migration through <c>AddAutomationAndOutbox</c>,
/// matching what real production already had before any V2 code ever ran) being
/// upgraded in place by the single new V2 migration
/// (<c>AddStructuredInfrastructureKnowledge</c>). A real production LeafWiki/database
/// snapshot is not available in this environment (an external blocker, not a reason to
/// skip local verification — CLAUDE.md §3) — this uses a synthetic V1-shaped database
/// instead, built the same way <c>RestoreServiceTests.cs</c>'s clean-target pattern
/// already does (a fresh, separately-created database on the shared Testcontainers
/// Postgres server).
///
/// This closes a genuinely different gap than the existing backup/restore tests: every
/// prior migration test starts from an *empty* database and migrates straight to
/// latest, or restores a backup into a freshly-migrated-to-latest target. Neither
/// exercises "a database that already has real content, migrated forward one more
/// step" — the actual shape of a real production rollout.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class InPlaceUpgradeRehearsalTests(PostgresContainerFixture postgres)
{
// The last migration production already has, before any V2 code ever ran —
// confirmed by direct inspection of every migration file: this is the migration
// immediately preceding AddStructuredInfrastructureKnowledge.
private const string LastV1Migration = "20260801023652_AddAutomationAndOutbox";
[Fact]
public async Task Existing_V1_content_survives_the_V2_infrastructure_migration_completely_unchanged()
{
var databaseName = $"upgrade_rehearsal_{Guid.NewGuid():N}";
await using (var admin = new NpgsqlConnection(postgres.AdminConnectionString))
{
await admin.OpenAsync();
await using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin);
await create.ExecuteNonQueryAsync();
}
var connectionString = new NpgsqlConnectionStringBuilder(postgres.AdminConnectionString)
{
Database = databaseName
}.ConnectionString;
try
{
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
Guid workspaceId, pageId, projectId;
string pageContentHashBeforeUpgrade, projectSlugBeforeUpgrade;
DateTimeOffset pageUpdatedAtBeforeUpgrade;
// Step 1: migrate only to the last migration real production already has,
// never touching the new V2 tables — matching production's actual current
// state exactly, not a hypothetical.
await using (var db = new KnowledgeHubDbContext(options))
{
await db.Database.GetService<IMigrator>().MigrateAsync(LastV1Migration);
var now = DateTimeOffset.UtcNow;
var workspace = Workspace.Create(WorkspaceName.Create("Productiewerkruimte"), now);
db.Workspaces.Add(workspace);
workspaceId = workspace.Id;
var actorId = Guid.NewGuid();
var page = Page.Create(
workspace.Id,
parentPageId: null,
SortKey.First(),
PageTitle.Create("Bestaande productiepagina"),
DocumentType.WikiPage,
"# Bestaande productiepagina\n\nDeze inhoud bestond al vóór de V2-migratie.",
actorId,
now);
db.Pages.Add(page);
pageId = page.Id;
var project = Project.Create(
workspace.Id,
"Bestaand productieproject",
"Een project dat al bestond vóór de V2-migratie.",
ProjectPriority.Medium,
"Actieve productiewerklast.",
ownerId: actorId,
createdBy: actorId,
now);
projectId = project.Id;
await db.SaveChangesAsync();
// Seed the V1-shaped row with the V1 schema. The current EF model
// already maps columns introduced after V1 (such as the project
// icon), so using db.Projects.Add here would ask PostgreSQL for a
// column that correctly does not exist until the upgrade below.
await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO projects
(id, workspace_id, name, slug, summary, status, priority,
current_state_markdown, owner_id, technologies, created_by,
created_at, updated_at)
VALUES
({project.Id}, {workspace.Id}, {project.Name}, {project.Slug},
{project.Summary}, {(short)project.Status}, {(short)project.Priority},
{project.CurrentStateMarkdown}, {project.OwnerId}, {project.TechnologiesValue},
{project.CreatedBy}, {project.CreatedAt}, {project.UpdatedAt})
""");
pageContentHashBeforeUpgrade = page.ContentHash;
// PostgreSQL's timestamptz column only stores microsecond precision —
// truncate the in-memory .NET tick-precision value the same way before
// comparing, so this asserts real round-trip fidelity rather than a
// false failure from a column-precision artifact unrelated to the
// migration itself.
pageUpdatedAtBeforeUpgrade = TruncateToMicroseconds(page.UpdatedAt);
projectSlugBeforeUpgrade = project.Slug;
}
// Step 2: the real production upgrade action — apply every pending
// migration (here, exactly one: AddStructuredInfrastructureKnowledge) to
// the now-populated database, exactly as SchemaGuard/Program.cs's normal
// startup migration would against a real upgraded production instance.
await using (var db = new KnowledgeHubDbContext(options))
{
await db.Database.MigrateAsync();
}
// Step 3: a fresh context/connection (never reusing the one that ran the
// migration) re-reads the pre-existing content and the new tables.
await using (var db = new KnowledgeHubDbContext(options))
{
var workspace = await db.Workspaces.FindAsync(workspaceId);
Assert.NotNull(workspace);
Assert.Equal("Productiewerkruimte", workspace!.Name);
var page = await db.Pages.FindAsync(pageId);
Assert.NotNull(page);
Assert.Equal("Bestaande productiepagina", page!.TitleValue);
Assert.Equal(
"# Bestaande productiepagina\n\nDeze inhoud bestond al vóór de V2-migratie.",
page.Markdown);
Assert.Equal(pageContentHashBeforeUpgrade, page.ContentHash);
Assert.Equal(pageUpdatedAtBeforeUpgrade, page.UpdatedAt);
Assert.False(page.IsTrashed);
var project = await db.Projects.FindAsync(projectId);
Assert.NotNull(project);
Assert.Equal("Bestaand productieproject", project!.Name);
Assert.Equal(projectSlugBeforeUpgrade, project.Slug);
Assert.Equal(ProjectLifecycleStatus.Concept, project.Status);
// The new V2 tables exist and are queryable (empty — no inventory
// snapshot has ever been posted against this rehearsal database),
// proving the upgrade is additive, not merely non-destructive to what
// already existed.
Assert.Equal(0, await db.InventorySnapshots.CountAsync());
Assert.Equal(0, await db.InfrastructureServices.CountAsync());
Assert.Equal(0, await db.EditorialProfiles.CountAsync());
Assert.Equal(0, await db.ManualOverrides.CountAsync());
}
}
finally
{
await using var admin = new NpgsqlConnection(postgres.AdminConnectionString);
await admin.OpenAsync();
await using (var terminate = admin.CreateCommand())
{
terminate.CommandText =
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = @name AND pid <> pg_backend_pid()";
terminate.Parameters.AddWithValue("name", databaseName);
await terminate.ExecuteNonQueryAsync();
}
await using var drop = admin.CreateCommand();
drop.CommandText = $"DROP DATABASE IF EXISTS \"{databaseName}\"";
await drop.ExecuteNonQueryAsync();
}
}
private static DateTimeOffset TruncateToMicroseconds(DateTimeOffset value) =>
new(value.Ticks - (value.Ticks % (TimeSpan.TicksPerMillisecond / 1000)), value.Offset);
}
@@ -0,0 +1,556 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Application.Migration;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
/// <summary>
/// The LeafWiki filesystem-Markdown importer end to end (docs/17-leafwiki-migration.md,
/// docs/27-acceptance-matrix.md M4-A04..A08): discovery/fingerprint, preflight without
/// target mutation, hierarchy/link/attachment-preserving execution, rerun idempotency,
/// rollback that preserves user edits, and source immutability. Every fixture here is
/// synthetic, written fresh per test into an isolated temp directory — never a real
/// LeafWiki snapshot (docs/09-data-safety, docs/22-testing-strategy.md).
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class LeafWikiImportTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private string CreateSourceDirectory()
{
var path = Path.Combine(Factory.ImportsDirectory, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private string RelativeToImportsRoot(string absolutePath) =>
Path.GetRelativePath(Factory.ImportsDirectory, absolutePath).Replace('\\', '/');
private static void WriteFile(string root, string relativePath, string content)
{
var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
[Fact]
public async Task Preflight_discovers_nested_pages_and_never_mutates_the_target()
{
var source = CreateSourceDirectory();
WriteFile(source, "index.md", "---\ntitle: Startpagina\n---\nWelkom.");
WriteFile(source, "school/index.md", "# School\nOverzicht van schoolinhoud.");
WriteFile(source, "school/wiskunde.md", "# Wiskunde\nFormules en oefeningen.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var beforeRootsResponse = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(beforeRootsResponse);
var beforeCount = beforeRootsResponse.Items.Count;
var preflightResponse = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"test-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.OK, preflightResponse.StatusCode);
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.Equal("Success", plan.Status);
Assert.Equal("filesystem-markdown", plan.AdapterName);
Assert.Equal(3, plan.PlannedCount);
Assert.Contains(plan.Items, item => item.ProposedTitle == "Startpagina" && item.ProposedParentSourceIdentity == null);
Assert.Contains(plan.Items, item => item.ProposedTitle == "School" && item.ProposedParentSourceIdentity == null);
Assert.Contains(plan.Items, item => item.ProposedTitle == "Wiskunde" && item.ProposedParentSourceIdentity == "school/index.md");
// No target mutation during preflight.
var afterRootsResponse = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(afterRootsResponse);
Assert.Equal(beforeCount, afterRootsResponse.Items.Count);
}
[Fact]
public async Task A_source_root_absolute_asset_link_is_recognized_and_imported_not_reported_missing()
{
// A real LeafWiki export commonly writes asset links as source-root-absolute
// ("/assets/<id>/file.png"), unlike every other fixture in this file (and
// CutoverRehearsalTests' own comment on this exact point), which only ever
// uses an already-relative reference. Found against a real export, not a
// hypothetical — this is the real regression test for that fix.
byte[] pngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
var source = CreateSourceDirectory();
WriteFile(source, "index.md", "# Netwerkdiagram\n![Diagram](/assets/abc123/diagram.png)\n");
var assetPath = Path.Combine(source, "assets", "abc123", "diagram.png");
Directory.CreateDirectory(Path.GetDirectoryName(assetPath)!);
File.WriteAllBytes(assetPath, pngBytes);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflightResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"asset-{Guid.NewGuid():N}"));
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.DoesNotContain(plan.Warnings, warning => warning.Contains("niet gevonden", StringComparison.Ordinal));
var executeResponse = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
var executed = await executeResponse.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executed);
Assert.Empty(executed.Diagnostics);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var page = Assert.Single(roots!.Items, item => item.Title == "Netwerkdiagram");
var attachments = await client.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{page.Id}/attachments");
Assert.NotNull(attachments);
Assert.Single(attachments.Items);
}
[Fact]
public async Task Execute_creates_the_hierarchy_resolves_wiki_links_and_leaves_unresolved_links_as_reported()
{
var source = CreateSourceDirectory();
WriteFile(source, "school/index.md", "# School\nZie [[Wiskunde]] en [[Onbestaande pagina]].");
WriteFile(source, "school/wiskunde.md", "# Wiskunde\nFormules.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflightResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"test-{Guid.NewGuid():N}"));
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.NotNull(plan.JobId);
var executeResponse = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
var executeResult = await executeResponse.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executeResult);
Assert.Equal(2, executeResult.Imported);
Assert.Equal(0, executeResult.Failed);
Assert.True(executeResult.SourceUnchangedAfterExecution);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
var schoolPage = Assert.Single(roots.Items, item => item.Title == "School");
var schoolDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{schoolPage.Id}");
Assert.NotNull(schoolDetail);
// A resolved internal link renders as a real anchor; the unresolved one stays
// literal bracket text (docs/11-markdown-and-linking.md, proven again here on
// imported content).
Assert.Contains("<a href=", schoolDetail.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("[[Onbestaande pagina]]", schoolDetail.RenderedHtml, StringComparison.Ordinal);
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{schoolPage.Id}/children");
Assert.NotNull(children);
Assert.Contains(children.Items, item => item.Title == "Wiskunde");
}
[Fact]
public async Task Rerunning_the_same_source_is_idempotent_and_skips_unchanged_items()
{
var source = CreateSourceDirectory();
WriteFile(source, "pagina.md", "# Enkele pagina\nInhoud.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
async Task<ExecuteImportResponse> RunOnceAsync(string idempotencyKey)
{
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), idempotencyKey));
var plan = await preflight.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
var execute = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
var result = await execute.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(result);
return result;
}
var first = await RunOnceAsync($"run1-{Guid.NewGuid():N}");
Assert.Equal(1, first.Imported);
Assert.Equal(0, first.Skipped);
var second = await RunOnceAsync($"run2-{Guid.NewGuid():N}");
Assert.Equal(0, second.Imported);
Assert.Equal(1, second.Skipped);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
Assert.Single(roots.Items, item => item.Title == "Enkele pagina");
}
[Fact]
public async Task Rollback_removes_an_unedited_imported_page_but_preserves_one_the_user_edited_afterward()
{
var source = CreateSourceDirectory();
WriteFile(source, "ongewijzigd.md", "# Ongewijzigd\nBlijft zoals geïmporteerd.");
WriteFile(source, "bewerkt.md", "# Bewerkt\nWordt na import aangepast.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"rb-{Guid.NewGuid():N}"));
var plan = await preflight.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
var editedPage = Assert.Single(roots.Items, item => item.Title == "Bewerkt");
var editedDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{editedPage.Id}");
Assert.NotNull(editedDetail);
// The user edits the imported page before the rollback happens.
await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{editedPage.Id}",
new UpdatePageRequest("Bewerkt door gebruiker", editedDetail.Markdown, "WikiPage", "Active", null),
editedDetail.ETag);
var rollbackResponse = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/admin/imports/{plan.JobId}/rollback", (object?)null);
Assert.Equal(HttpStatusCode.OK, rollbackResponse.StatusCode);
var rollback = await rollbackResponse.Content.ReadFromJsonAsync<RollbackImportResponse>();
Assert.NotNull(rollback);
Assert.Equal(1, rollback.RemovedPages);
Assert.Equal(1, rollback.PreservedPages);
var rootsAfterRollback = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(rootsAfterRollback);
Assert.DoesNotContain(rootsAfterRollback.Items, item => item.Title == "Ongewijzigd");
Assert.Contains(rootsAfterRollback.Items, item => item.Title == "Bewerkt door gebruiker");
}
[Fact]
public async Task A_symlink_escaping_the_source_root_is_recorded_but_never_followed()
{
var source = CreateSourceDirectory();
var outsideTarget = Path.Combine(Path.GetTempPath(), "ikh-outside-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideTarget);
File.WriteAllText(Path.Combine(outsideTarget, "geheim.md"), "# Geheim\nMag niet gelezen worden.");
WriteFile(source, "normale-pagina.md", "# Normale pagina\nInhoud.");
try
{
File.CreateSymbolicLink(Path.Combine(source, "ontsnapping.md"), Path.Combine(outsideTarget, "geheim.md"));
}
catch (IOException)
{
// Symlink creation can require elevated privileges on some Windows
// configurations; skip this specific assertion rather than fail the whole
// suite for an environment limitation unrelated to the importer's logic.
return;
}
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"sym-{Guid.NewGuid():N}"));
var plan = await preflight.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
// The symlink is not itself a Markdown source item, so it's excluded from the
// plan entirely — the reader never opens a symlink target at all (recorded,
// never followed), so the outside file's content cannot appear anywhere here.
Assert.DoesNotContain(plan.Items, item => item.SourceIdentity.Contains("ontsnapping", StringComparison.Ordinal));
Assert.Single(plan.Items, item => item.SourceIdentity == "normale-pagina.md");
}
[Fact]
public async Task Preflight_refuses_a_linked_source_root_that_points_outside_imports()
{
Directory.CreateDirectory(Factory.ImportsDirectory);
var outsideTarget = Path.Combine(Path.GetTempPath(), "ikh-outside-root-" + Guid.NewGuid().ToString("N"));
var linkedSource = Path.Combine(Factory.ImportsDirectory, "linked-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideTarget);
WriteFile(outsideTarget, "outside-secret.md", "# Buiten de imports-map\nDeze bron mag niet worden ontdekt.");
var outsideFile = Path.Combine(outsideTarget, "outside-secret.md");
var originalBytes = File.ReadAllBytes(outsideFile);
try
{
Assert.True(
TryCreateDirectoryLink(linkedSource, outsideTarget),
"The test host must support a directory symlink or junction to exercise the source-root boundary.");
Assert.NotNull(new DirectoryInfo(linkedSource).LinkTarget);
Assert.True((File.GetAttributes(linkedSource) & FileAttributes.ReparsePoint) != 0);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(linkedSource), $"linked-root-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, preflight.StatusCode);
var responseBody = await preflight.Content.ReadAsStringAsync();
Assert.Contains("import-source-invalid", responseBody, StringComparison.Ordinal);
Assert.DoesNotContain("Deze bron mag niet worden ontdekt", responseBody, StringComparison.Ordinal);
Assert.Equal(originalBytes, File.ReadAllBytes(outsideFile));
var reader = Factory.Services.GetRequiredService<ILeafWikiSourceReader>();
await Assert.ThrowsAsync<InvalidOperationException>(
() => reader.OpenFileAsync(linkedSource, "outside-secret.md", CancellationToken.None));
Assert.Equal(originalBytes, File.ReadAllBytes(outsideFile));
}
finally
{
if (Directory.Exists(linkedSource))
{
Directory.Delete(linkedSource);
}
if (Directory.Exists(outsideTarget))
{
File.Delete(outsideFile);
Directory.Delete(outsideTarget);
}
}
}
private static bool TryCreateDirectoryLink(string linkPath, string targetPath)
{
if (!OperatingSystem.IsWindows())
{
try
{
Directory.CreateSymbolicLink(linkPath, targetPath);
return true;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
return false;
}
}
try
{
var startInfo = new ProcessStartInfo("cmd.exe")
{
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("/d");
startInfo.ArgumentList.Add("/c");
startInfo.ArgumentList.Add("mklink");
startInfo.ArgumentList.Add("/J");
startInfo.ArgumentList.Add(linkPath);
startInfo.ArgumentList.Add(targetPath);
using var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
process.WaitForExit();
return process.ExitCode == 0;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception)
{
return false;
}
}
[Fact]
public async Task A_synthetic_sqlite_source_with_the_recognized_schema_is_imported_via_the_sqlite_adapter()
{
var source = CreateSourceDirectory();
var databasePath = Path.Combine(source, "leafwiki.sqlite");
CreateSyntheticSqliteFixture(databasePath);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"sqlite-{Guid.NewGuid():N}"));
var plan = await preflight.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.Equal("synthetic-sqlite-v1", plan.AdapterName);
Assert.Equal(2, plan.PlannedCount);
var execute = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
var result = await execute.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(result);
Assert.Equal(2, result.Imported);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
Assert.Contains(roots.Items, item => item.Title == "SQLite-hoofdpagina");
}
[Fact]
public async Task An_unrecognized_sqlite_schema_is_a_preflight_blocker_not_a_guessed_mapping()
{
var source = CreateSourceDirectory();
var databasePath = Path.Combine(source, "unknown.sqlite");
CreateUnknownSchemaSqliteFixture(databasePath);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"unknown-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, preflight.StatusCode);
}
[Fact]
public async Task Only_an_administrator_can_run_a_preflight()
{
using var ownerClient = Factory.CreateClient();
await SetupHelper.LoginAsync(ownerClient, OwnerEmail, OwnerPassword);
var editorEmail = $"redacteur-import-{Guid.NewGuid():N}@itworx.test";
const string editorPassword = "correct-horse-battery-staple";
await SetupHelper.PostWithCsrfAsync(
ownerClient,
"/api/v1/admin/users",
new ItWorx.KnowledgeHub.Contracts.Admin.CreateUserRequest("Redacteur", editorEmail, editorPassword, "Editor"));
using var editorClient = Factory.CreateClient();
await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword);
var response = await SetupHelper.PostWithCsrfAsync(
editorClient, "/api/v1/admin/imports/preflight", new PreflightImportRequest(".", $"forbidden-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task A_bearer_token_with_imports_scopes_can_preflight_and_execute_a_real_import()
{
var source = CreateSourceDirectory();
WriteFile(source, "index.md", "# Startpagina\nWelkom.");
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts",
new CreateServiceAccountRequest("Migratie-bot", "Testaccount.", [Scopes.ImportsExecute, Scopes.ImportsRead]));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
using var bearerClient = Factory.CreateClient();
bearerClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", created.RawToken);
var preflightResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"bearer-import-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.OK, preflightResponse.StatusCode);
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.NotNull(plan.JobId);
var jobResponse = await bearerClient.GetAsync($"/api/v1/admin/imports/{plan.JobId}");
Assert.Equal(HttpStatusCode.OK, jobResponse.StatusCode);
var executeResponse = await bearerClient.PostAsync(
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
content: null);
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
var executed = await executeResponse.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executed);
Assert.True(executed.Imported > 0);
// imports:execute/imports:read never implicitly grant pages:read — scopes are
// narrow and additive, never bundled (M6-A02), same property already proven
// for pages:write/projects:write in ServiceAccountTests.cs.
var pagesResponse = await bearerClient.GetAsync("/api/v1/pages/roots");
Assert.Equal(HttpStatusCode.Forbidden, pagesResponse.StatusCode);
}
[Fact]
public async Task A_bearer_token_without_imports_scopes_cannot_run_a_preflight_vertical_privilege_escalation()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts",
new CreateServiceAccountRequest("Read-only-bot", "Testaccount.", [Scopes.PagesRead]));
var created = await createResponse.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
using var bearerClient = Factory.CreateClient();
bearerClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", created.RawToken);
var response = await bearerClient.PostAsJsonAsync(
"/api/v1/admin/imports/preflight", new PreflightImportRequest(".", $"escalation-{Guid.NewGuid():N}"));
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the escalation attempt to be denied, but got {response.StatusCode}.");
}
private static void CreateSyntheticSqliteFixture(string path)
{
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
"""
CREATE TABLE leafwiki_meta (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO leafwiki_meta (key, value) VALUES ('schema', 'itworx-synthetic-leafwiki-v1');
CREATE TABLE pages (
id INTEGER PRIMARY KEY,
parent_id INTEGER NULL REFERENCES pages(id),
title TEXT NOT NULL,
body_markdown TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT INTO pages (id, parent_id, title, body_markdown, created_at)
VALUES (1, NULL, 'SQLite-hoofdpagina', '# Hoofdpagina', '2020-01-01');
INSERT INTO pages (id, parent_id, title, body_markdown, created_at)
VALUES (2, 1, 'SQLite-subpagina', '# Subpagina', '2020-01-02');
""";
command.ExecuteNonQuery();
}
private static void CreateUnknownSchemaSqliteFixture(string path)
{
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE articles (id INTEGER PRIMARY KEY, headline TEXT);";
command.ExecuteNonQuery();
}
}
@@ -0,0 +1,317 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
/// <summary>
/// The version-validated LeafWiki v5 filesystem adapter end to end
/// (docs/17-leafwiki-migration.md; docs/adr/ADR-032-leafwiki-v5-import-fidelity.md).
/// Every fixture is synthetic and written fresh per test — it reproduces the *shape* of
/// a real LeafWiki v5 export (schema.json, leafwiki_* frontmatter including
/// YAML-escaped titles, .order.json sibling ordering, source-root-absolute
/// /assets/&lt;page-id&gt;/ image links) without containing any real user content.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class LeafWikiV5ImportTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private string CreateSourceDirectory()
{
var path = Path.Combine(Factory.ImportsDirectory, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private string RelativeToImportsRoot(string absolutePath) =>
Path.GetRelativePath(Factory.ImportsDirectory, absolutePath).Replace('\\', '/');
private static void WriteFile(string root, string relativePath, string content)
{
var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
private static void WriteBinary(string root, string relativePath, byte[] content)
{
var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllBytes(full, content);
}
/// <summary>
/// Builds a source shaped exactly like the real export: a schema.json version
/// marker, index.md-per-directory, leafwiki_id/leafwiki_title frontmatter (one of
/// them a YAML double-quoted scalar with a \U escape, one of them title-only with
/// no body at all), .order.json files whose order deliberately contradicts
/// alphabetical order, and a source-root-absolute asset reference.
/// </summary>
private string CreateV5Source()
{
var source = CreateSourceDirectory();
WriteFile(source, "schema.json", "{\n \"version\": 5\n}");
WriteFile(source, ".order.json", "{\"ordered_ids\": [\"netw-01\", \"alg-01\"]}");
// "Netwerken" sorts after "Algemeen" alphabetically but is listed first.
WriteFile(source, "netwerken/index.md",
"---\nleafwiki_id: netw-01\nleafwiki_title: \"\\U0001F539 Netwerken\"\n---\n");
WriteFile(source, "netwerken/.order.json", "{\"ordered_ids\": [\"netw-vlan\", \"netw-stp\"]}");
WriteFile(source, "netwerken/stp.md",
"---\nleafwiki_id: netw-stp\nleafwiki_title: Spanning Tree\n---\n# STP – ANDERE KOP\nBlokkeert lussen.");
WriteFile(source, "netwerken/vlan.md",
"---\nleafwiki_id: netw-vlan\n---\n# VLAN's\n![diagram](/assets/netw-vlan/diagram.png)\n");
WriteFile(source, "algemeen/index.md",
"---\nleafwiki_id: alg-01\nleafwiki_title: Algemeen\n---\nInleiding.");
WriteBinary(source, "assets/netw-vlan/diagram.png", PngBytes);
return source;
}
private async Task<ImportPreflightResponse> PreflightAsync(HttpClient client, string source)
{
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"v5-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var plan = await response.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
return plan;
}
private async Task<ExecuteImportResponse> ExecuteAsync(HttpClient client, string source, Guid? jobId)
{
Assert.NotNull(jobId);
var response = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{jobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var executed = await response.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executed);
return executed;
}
[Fact]
public async Task A_leafwiki_v5_export_is_recognized_and_titled_from_its_own_frontmatter()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
Assert.Equal("Success", plan.Status);
Assert.Equal("leafwiki-filesystem-v5", plan.AdapterName);
// The escaped YAML scalar becomes a real title, not the literal "\U0001F539".
Assert.Contains(plan.Items, item => item.ProposedTitle == "\U0001F539 Netwerken");
Assert.DoesNotContain(plan.Items, item => item.ProposedTitle.Contains("\\U0001", StringComparison.Ordinal));
// LeafWiki's stored title wins over a body heading that says something else,
// and a body with no heading at all is still correctly titled (the generic
// fallback adapter would have called this one "Index").
Assert.Contains(plan.Items, item => item.ProposedTitle == "Spanning Tree");
Assert.DoesNotContain(plan.Items, item => item.ProposedTitle == "Index");
// No frontmatter title at all: the body heading is still used.
Assert.Contains(plan.Items, item => item.ProposedTitle == "VLAN's");
}
[Fact]
public async Task Imported_siblings_follow_the_order_files_not_alphabetical_order()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.Equal("Success", executed.Status);
Assert.Equal(0, executed.Failed);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
var importedRoots = roots.Items
.Where(item => item.Title is "\U0001F539 Netwerken" or "Algemeen")
.Select(item => item.Title)
.ToList();
Assert.Equal(["\U0001F539 Netwerken", "Algemeen"], importedRoots);
var networks = Assert.Single(roots.Items, item => item.Title == "\U0001F539 Netwerken");
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{networks.Id}/children");
Assert.NotNull(children);
Assert.Equal(["VLAN's", "Spanning Tree"], children.Items.Select(item => item.Title).ToList());
}
[Fact]
public async Task An_imported_image_link_points_at_the_attachment_the_import_created()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.Empty(executed.Diagnostics);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var networks = Assert.Single(roots!.Items, item => item.Title == "\U0001F539 Netwerken");
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{networks.Id}/children");
var vlan = Assert.Single(children!.Items, item => item.Title == "VLAN's");
var attachments = await client.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{vlan.Id}/attachments");
var attachment = Assert.Single(attachments!.Items);
var detail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{vlan.Id}");
Assert.NotNull(detail);
// The stored Markdown must reference the attachment this import created —
// the source path "/assets/netw-vlan/diagram.png" resolves to nothing here.
Assert.Contains($"/api/v1/attachments/{attachment.Id}/content", detail.Markdown, StringComparison.Ordinal);
Assert.DoesNotContain("/assets/netw-vlan/diagram.png", detail.Markdown, StringComparison.Ordinal);
// And the rendered HTML really serves those bytes back.
Assert.Contains($"/api/v1/attachments/{attachment.Id}/content", detail.RenderedHtml, StringComparison.Ordinal);
var content = await client.GetAsync(new Uri($"/api/v1/attachments/{attachment.Id}/content", UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
Assert.Equal(PngBytes, await content.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task A_page_whose_body_is_only_frontmatter_is_imported_as_an_empty_page_not_skipped()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.Equal(4, plan.PlannedCount);
Assert.Equal(4, executed.Imported);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var networks = Assert.Single(roots!.Items, item => item.Title == "\U0001F539 Netwerken");
var detail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{networks.Id}");
Assert.Equal(string.Empty, detail!.Markdown);
}
[Fact]
public async Task A_body_heading_that_only_repeats_the_title_is_not_imported_twice()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
await ExecuteAsync(client, source, plan.JobId);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var networks = Assert.Single(roots!.Items, item => item.Title == "🔹 Netwerken");
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{networks.Id}/children");
// "# VLAN's" repeats the page title exactly: the reader would otherwise see the
// same title twice, and the document would have two h1 elements.
var vlan = Assert.Single(children!.Items, item => item.Title == "VLAN's");
var vlanDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{vlan.Id}");
Assert.DoesNotContain("# VLAN's", vlanDetail!.Markdown, StringComparison.Ordinal);
// The rendered body no longer carries an h1 of its own; the application renders
// the page title as the document heading above it.
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(vlanDetail.RenderedHtml, "<h1"));
// "# STP – ANDERE KOP" says something different from the title "Spanning Tree":
// that is content, and it is kept.
var stp = Assert.Single(children.Items, item => item.Title == "Spanning Tree");
var stpDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{stp.Id}");
Assert.Contains("# STP – ANDERE KOP", stpDetail!.Markdown, StringComparison.Ordinal);
Assert.Single(System.Text.RegularExpressions.Regex.Matches(stpDetail.RenderedHtml, "<h1"));
}
[Fact]
public async Task An_unsupported_declared_schema_version_falls_back_instead_of_guessing()
{
var source = CreateSourceDirectory();
WriteFile(source, "schema.json", "{\"version\": 99}");
WriteFile(source, "pagina.md", "---\nleafwiki_id: x1\nleafwiki_title: Toekomstige versie\n---\n# Kop\nTekst.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
// The v5 adapter refuses an unknown declared version; the generic Markdown
// fallback still imports what it safely can, and says so.
Assert.Equal("filesystem-markdown", plan.AdapterName);
Assert.Contains(plan.Items, item => item.ProposedTitle == "Kop");
}
[Fact]
public async Task A_plain_markdown_source_without_leafwiki_frontmatter_still_uses_the_generic_adapter()
{
var source = CreateSourceDirectory();
WriteFile(source, "index.md", "---\ntitle: Startpagina\n---\nWelkom.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
Assert.Equal("filesystem-markdown", plan.AdapterName);
}
[Fact]
public async Task Rerunning_a_v5_import_is_idempotent()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var firstPlan = await PreflightAsync(client, source);
var first = await ExecuteAsync(client, source, firstPlan.JobId);
Assert.Equal(4, first.Imported);
var secondPlan = await PreflightAsync(client, source);
var second = await ExecuteAsync(client, source, secondPlan.JobId);
Assert.Equal(0, second.Imported);
Assert.Equal(4, second.Skipped);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.Single(roots!.Items, item => item.Title == "\U0001F539 Netwerken");
}
[Fact]
public async Task The_source_is_never_modified_by_a_v5_import()
{
var source = CreateV5Source();
var before = FingerprintDirectory(source);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.True(executed.SourceUnchangedAfterExecution);
Assert.Equal(before, FingerprintDirectory(source));
}
private static string FingerprintDirectory(string root)
{
var builder = new StringBuilder();
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).OrderBy(path => path, StringComparer.Ordinal))
{
builder.Append(Path.GetRelativePath(root, file).Replace('\\', '/'))
.Append('=')
.Append(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(file))))
.Append('\n');
}
return builder.ToString();
}
}
@@ -0,0 +1,168 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
[Collection(PostgresTestGroup.Name)]
public sealed class SourceBatchPurgeTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private const string BatchId = "canvas-20260801T134020Z";
[Fact]
public async Task The_administrator_only_endpoint_rejects_anonymous_requests()
{
using var client = Factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/v1/admin/source-batches/purge/dry-run",
new SourceBatchPurgeRequest(BatchId, "canvas", [new SourceBatchPurgePageEvidence(Guid.NewGuid(), new string('0', 64))]));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Dry_run_blocks_when_a_canvas_candidate_is_not_in_the_reviewed_batch_list()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var selected = await CreatePageAsync(client, "Geselecteerde bron", ["source:canvas", "course:test"]);
await CreatePageAsync(client, "Niet geselecteerde bron", ["source:canvas", "course:test"]);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/dry-run",
new SourceBatchPurgeRequest(BatchId, "canvas", [await EvidenceAsync(selected)]));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var dryRun = await response.Content.ReadFromJsonAsync<SourceBatchPurgeDryRunResponse>();
Assert.NotNull(dryRun);
Assert.False(dryRun.Safe);
Assert.Contains(dryRun.Blockers, blocker => blocker.Contains("buiten de gecontroleerde batchlijst", StringComparison.Ordinal));
}
[Fact]
public async Task Dry_run_blocks_when_a_selected_page_no_longer_matches_its_reviewed_content_hash()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client, "Canvasbron", ["source:canvas", "course:test"]);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/dry-run",
new SourceBatchPurgeRequest(BatchId, "canvas", [new SourceBatchPurgePageEvidence(pageId, new string('0', 64))]));
var dryRun = await response.Content.ReadFromJsonAsync<SourceBatchPurgeDryRunResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(dryRun);
Assert.False(dryRun.Safe);
Assert.Contains(dryRun.Blockers, blocker => blocker.Contains("gevalideerde bronhash", StringComparison.Ordinal));
}
[Fact]
public async Task A_confirmed_safe_purge_removes_the_selected_page_references_and_verified_orphan_blob()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client, "Canvasbron", ["source:canvas", "course:test"]);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{pageId}/attachments", PngBytes, "canvas.png", "image/png");
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
var dryRun = await DryRunAsync(client, [pageId]);
Assert.True(dryRun.Safe);
Assert.Equal(1, dryRun.Pages);
Assert.Equal(1, dryRun.AttachmentReferences);
Assert.Equal(1, dryRun.OrphanedBlobs);
var execute = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/execute",
new ExecuteSourceBatchPurgeRequest(BatchId, "canvas", [await EvidenceAsync(pageId)], dryRun.ConfirmationHash));
Assert.Equal(HttpStatusCode.OK, execute.StatusCode);
var result = await execute.Content.ReadFromJsonAsync<SourceBatchPurgeResponse>();
Assert.NotNull(result);
Assert.Equal("purged", result.Status);
Assert.Equal(1, result.RemovedPages);
Assert.Equal(1, result.RemovedAttachmentReferences);
Assert.Equal(1, result.RemovedBlobs);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/v1/pages/{pageId}")).StatusCode);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
Assert.Equal(0, await db.AttachmentReferences.CountAsync());
Assert.Equal(0, await db.AttachmentBlobs.CountAsync());
var sha256 = Convert.ToHexString(SHA256.HashData(PngBytes)).ToLowerInvariant();
Assert.False(File.Exists(Path.Combine(Factory.AttachmentsDirectory, sha256[..2], sha256[2..4], sha256)));
}
[Fact]
public async Task A_shared_blob_is_retained_when_a_non_batch_page_still_references_it()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var canvasPageId = await CreatePageAsync(client, "Canvasbron", ["source:canvas", "course:test"]);
var retainedPageId = await CreatePageAsync(client, "Infrastructuur", ["infrastructure"]);
var canvasUpload = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{canvasPageId}/attachments", PngBytes, "canvas.png", "image/png");
var retainedUpload = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{retainedPageId}/attachments", PngBytes, "infra.png", "image/png");
Assert.Equal(HttpStatusCode.OK, canvasUpload.StatusCode);
Assert.Equal(HttpStatusCode.OK, retainedUpload.StatusCode);
var retainedAttachment = await retainedUpload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(retainedAttachment);
var dryRun = await DryRunAsync(client, [canvasPageId]);
Assert.True(dryRun.Safe);
Assert.Equal(0, dryRun.OrphanedBlobs);
Assert.Equal(1, dryRun.SharedBlobs);
var execute = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/execute",
new ExecuteSourceBatchPurgeRequest(BatchId, "canvas", [await EvidenceAsync(canvasPageId)], dryRun.ConfirmationHash));
var result = await execute.Content.ReadFromJsonAsync<SourceBatchPurgeResponse>();
Assert.NotNull(result);
Assert.Equal(HttpStatusCode.OK, execute.StatusCode);
Assert.Equal(0, result.RemovedBlobs);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/api/v1/attachments/{retainedAttachment.Id}/content")).StatusCode);
}
private static async Task<Guid> CreatePageAsync(HttpClient client, string title, IReadOnlyList<string> tags)
{
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, title, "WikiPage", "<!-- managed-source:start -->\nBron\n<!-- managed-source:end -->", null, tags));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page.Id;
}
private async Task<SourceBatchPurgeDryRunResponse> DryRunAsync(HttpClient client, IReadOnlyList<Guid> pageIds)
{
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/dry-run",
new SourceBatchPurgeRequest(BatchId, "canvas", await Task.WhenAll(pageIds.Select(pageId => EvidenceAsync(pageId)))));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return await response.Content.ReadFromJsonAsync<SourceBatchPurgeDryRunResponse>()
?? throw new InvalidOperationException("Geen dry-run ontvangen.");
}
private async Task<SourceBatchPurgePageEvidence> EvidenceAsync(Guid pageId)
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var contentHash = await db.Pages
.Where(page => page.Id == pageId)
.Select(page => page.ContentHash)
.SingleAsync();
return new SourceBatchPurgePageEvidence(pageId, contentHash);
}
}
@@ -0,0 +1,28 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Activity;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
[Collection(PostgresTestGroup.Name)]
public sealed class ActivityFeedTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Activity_feed_returns_real_append_only_audit_events_in_reverse_chronological_order()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var create = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Auditbare pagina", "WikiPage", "Inhoud.", null));
create.EnsureSuccessStatusCode();
var activity = await client.GetFromJsonAsync<ActivityListResponse>("/api/v1/activity?limit=20");
Assert.NotNull(activity);
var pageCreated = Assert.Single(activity.Items, item => item.Action == "page.created");
Assert.Equal("page", pageCreated.TargetType);
Assert.Contains("Auditbare pagina", pageCreated.Summary, StringComparison.Ordinal);
Assert.DoesNotContain("Inhoud.", pageCreated.Summary, StringComparison.Ordinal);
Assert.Equal(activity.Items.OrderByDescending(item => item.OccurredAt).Select(item => item.Id), activity.Items.Select(item => item.Id));
}
}
@@ -0,0 +1,88 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Application.Operations;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
/// <summary>Proves M7-A03 ("A full backup captures database, attachments, manifest and
/// integrity hashes") against a real running application and real PostgreSQL, not a
/// mock.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class BackupServiceTests(PostgresContainerFixture postgres)
: OwnerSetupTestBase(postgres, new Dictionary<string, string?> { ["KnowledgeHub:Backup:RetainedCount"] = "2" })
{
[Fact]
public async Task A_backup_captures_every_table_and_verifies_its_own_hashes()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Back-uptestpagina", "WikiPage", "Inhoud.", null));
using var scope = Factory.Services.CreateScope();
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
var summary = await backups.CreateBackupAsync(CancellationToken.None);
Assert.True(summary.TableCount > 0);
Assert.True(summary.TotalRowCount > 0);
var manifest = await backups.GetManifestAsync(summary.BackupId, CancellationToken.None);
Assert.NotNull(manifest);
var pagesTable = Assert.Single(manifest.Tables, table => table.TableName == "pages");
Assert.True(pagesTable.RowCount >= 1);
Assert.False(string.IsNullOrWhiteSpace(pagesTable.Sha256));
// Never a migration-bookkeeping table — that is recreated by the target's own
// startup migration, not restored as user data (ADR-016 decision 2).
Assert.DoesNotContain(manifest.Tables, table => table.TableName == "__EFMigrationsHistory");
}
[Fact]
public async Task A_backup_includes_uploaded_attachments_with_verified_hashes()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Pagina met bijlage", "WikiPage", "Inhoud.", null));
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}/attachments",
"Testbestand voor back-up."u8.ToArray(),
"backup-test.txt",
"text/plain");
Assert.True(uploadResponse.IsSuccessStatusCode);
using var scope = Factory.Services.CreateScope();
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
var summary = await backups.CreateBackupAsync(CancellationToken.None);
Assert.Equal(1, summary.AttachmentCount);
var manifest = await backups.GetManifestAsync(summary.BackupId, CancellationToken.None);
Assert.NotNull(manifest);
Assert.Single(manifest.Attachments);
}
[Fact]
public async Task Listing_backups_returns_newest_first_and_retention_prunes_older_ones()
{
using var scope = Factory.Services.CreateScope();
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
var first = await backups.CreateBackupAsync(CancellationToken.None);
var second = await backups.CreateBackupAsync(CancellationToken.None);
var third = await backups.CreateBackupAsync(CancellationToken.None);
var list = await backups.ListBackupsAsync(CancellationToken.None);
// Configured KnowledgeHub:Backup:RetainedCount = 2 for this test class.
Assert.Equal(2, list.Count);
Assert.Equal(third.BackupId, list[0].BackupId);
Assert.Equal(second.BackupId, list[1].BackupId);
Assert.DoesNotContain(list, entry => entry.BackupId == first.BackupId);
}
}
@@ -0,0 +1,134 @@
using ItWorx.KnowledgeHub.Infrastructure.Operations;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
/// <summary>Proves M7-A05 ("Health endpoints distinguish liveness, readiness and
/// degraded dependencies") against real PostgreSQL and a real filesystem, not mocks.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class HealthCheckTests(PostgresContainerFixture postgres)
{
[Fact]
public async Task A_fully_migrated_reachable_database_is_healthy()
{
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(target.ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
await using var db = new KnowledgeHubDbContext(options);
var check = new DatabaseHealthCheck(db);
var result = await check.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Healthy, result.Status);
}
[Fact]
public async Task An_unreachable_database_is_unhealthy_not_a_thrown_exception()
{
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql("Host=127.0.0.1;Port=1;Database=does-not-exist;Username=nobody;Password=nothing;Timeout=1")
.UseSnakeCaseNamingConvention()
.Options;
await using var db = new KnowledgeHubDbContext(options);
var check = new DatabaseHealthCheck(db);
var result = await check.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Unhealthy, result.Status);
}
[Fact]
public async Task A_database_with_pending_migrations_is_unhealthy()
{
var databaseName = $"health_unmigrated_{Guid.NewGuid():N}";
await using (var admin = new Npgsql.NpgsqlConnection(postgres.AdminConnectionString))
{
await admin.OpenAsync();
await using var create = new Npgsql.NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin);
await create.ExecuteNonQueryAsync();
}
try
{
var connectionString = new Npgsql.NpgsqlConnectionStringBuilder(postgres.AdminConnectionString) { Database = databaseName }.ConnectionString;
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
await using var db = new KnowledgeHubDbContext(options);
var check = new DatabaseHealthCheck(db);
// Reachable, but never migrated — must never be reported ready.
var result = await check.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Unhealthy, result.Status);
}
finally
{
await using var admin = new Npgsql.NpgsqlConnection(postgres.AdminConnectionString);
await admin.OpenAsync();
await using var drop = new Npgsql.NpgsqlCommand($"DROP DATABASE IF EXISTS \"{databaseName}\" WITH (FORCE)", admin);
await drop.ExecuteNonQueryAsync();
}
}
[Fact]
public async Task All_writable_configured_paths_are_healthy()
{
var root = Path.Combine(Path.GetTempPath(), "ikh-health-writable", Guid.NewGuid().ToString("N"));
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["KnowledgeHub:Paths:Data"] = Path.Combine(root, "data"),
["KnowledgeHub:Paths:Attachments"] = Path.Combine(root, "attachments")
})
.Build();
var check = new StorageHealthCheck(configuration);
try
{
var result = await check.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Healthy, result.Status);
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}
[Fact]
public async Task A_path_that_cannot_be_created_as_a_directory_is_unhealthy()
{
var blockingFile = Path.Combine(Path.GetTempPath(), $"ikh-health-blocking-{Guid.NewGuid():N}.tmp");
await File.WriteAllBytesAsync(blockingFile, [1]);
try
{
// A configured path that collides with an existing *file* can never become
// a writable directory — a real, not simulated, storage failure.
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["KnowledgeHub:Paths:Data"] = blockingFile })
.Build();
var check = new StorageHealthCheck(configuration);
var result = await check.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Unhealthy, result.Status);
}
finally
{
File.Delete(blockingFile);
}
}
}
@@ -0,0 +1,55 @@
using System.Collections.Concurrent;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
/// <summary>Proves docs/19-observability.md's "Never log ... passwords" rule with a
/// real request through the real logging pipeline, not by code review: every log line
/// emitted while handling a real login attempt (wrong password, so the flow visits the
/// most log-heavy failure path) is captured and asserted to never contain the raw
/// password value.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class LoginRedactionTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task No_log_line_emitted_during_a_login_attempt_contains_the_raw_password()
{
const string secretPassword = "u3q7VeryUniquePasswordNeverLogged9Zx";
var captured = new ConcurrentQueue<string>();
using var loggingFactory = Factory.WithWebHostBuilder(builder =>
builder.ConfigureLogging(logging => logging.AddProvider(new CapturingLoggerProvider(captured))));
using var client = loggingFactory.CreateClient();
// A wrong-password attempt exercises the most log-heavy path (Identity
// failure handling, audit event, rate limiter) without needing the real
// owner password.
await SetupHelper.LoginAsync(client, OwnerEmail, secretPassword);
Assert.DoesNotContain(captured, line => line.Contains(secretPassword, StringComparison.Ordinal));
}
private sealed class CapturingLoggerProvider(ConcurrentQueue<string> sink) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, sink);
public void Dispose()
{
}
private sealed class CapturingLogger(string categoryName, ConcurrentQueue<string> sink) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
sink.Enqueue($"[{categoryName}] {formatter(state, exception)} {exception}");
}
}
}
}
@@ -0,0 +1,188 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Application.Operations;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
/// <summary>Proves M7-A04 ("A clean-target restore recreates logical data, attachments
/// and permissions and passes integrity validation") end to end against real
/// PostgreSQL: a real backup of a real source database, restored into a second, freshly
/// migrated, empty database on the same server — never the source itself
/// (docs/18-backup-restore.md: "Never test restore by overwriting the only production
/// instance").</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class RestoreServiceTests : OwnerSetupTestBase
{
private readonly PostgresContainerFixture _postgres;
public RestoreServiceTests(PostgresContainerFixture postgres) : base(postgres)
{
_postgres = postgres;
}
[Fact]
public async Task A_backup_restores_into_a_clean_target_with_matching_row_counts_and_attachments()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Herstelde pagina", "WikiPage", "Inhoud om te herstellen.", null));
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{page.Id}/attachments", "Herstelbestand."u8.ToArray(), "restore-test.txt", "text/plain");
Assert.True(uploadResponse.IsSuccessStatusCode);
using var sourceScope = Factory.Services.CreateScope();
var backupService = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
var backup = await backupService.CreateBackupAsync(CancellationToken.None);
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
try
{
var restoreService = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
var report = await restoreService.RestoreAsync(
backup.BackupId,
new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory),
CancellationToken.None);
Assert.True(report.Success, string.Join("; ", report.Warnings));
Assert.All(report.Tables, table => Assert.True(table.Matches, $"{table.TableName}: {table.RestoredRowCount}/{table.ExpectedRowCount}"));
var pagesTable = Assert.Single(report.Tables, table => table.TableName == "pages");
Assert.True(pagesTable.RestoredRowCount >= 1);
Assert.Equal(1, report.AttachmentsRestored);
Assert.Equal(report.AttachmentsExpected, report.AttachmentsRestored);
var restoredAttachmentFiles = Directory.GetFiles(target.AttachmentsDirectory, "*", SearchOption.AllDirectories);
Assert.Single(restoredAttachmentFiles);
}
finally
{
await target.DisposeAsync();
}
}
[Fact]
public async Task Restoring_into_a_non_empty_target_is_refused()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Bron voor niet-schone test", "WikiPage", "I.", null));
using var sourceScope = Factory.Services.CreateScope();
var backupService = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
var backup = await backupService.CreateBackupAsync(CancellationToken.None);
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
try
{
// Seed the "clean" target with a pre-existing row so it is not actually empty.
await using (var connection = new NpgsqlConnection(target.ConnectionString))
{
await connection.OpenAsync();
await using var seed = new NpgsqlCommand(
"INSERT INTO \"public\".\"installations\" (id, instance_id, primary_locale, time_zone, product_version, schema_version) " +
"VALUES (gen_random_uuid(), gen_random_uuid(), 'nl-BE', 'Europe/Brussels', '0.0.0', 'm7')",
connection);
await seed.ExecuteNonQueryAsync();
}
var restoreService = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
await Assert.ThrowsAsync<InvalidOperationException>(() => restoreService.RestoreAsync(
backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None));
}
finally
{
await target.DisposeAsync();
}
}
}
/// <summary>A second, freshly migrated, empty logical database on the same shared
/// Testcontainers Postgres server — a genuine "clean target", never the source
/// database under test.</summary>
internal sealed class RestoreTargetDatabase : IAsyncDisposable
{
private readonly PostgresContainerFixture _postgres;
private readonly string _databaseName;
public string ConnectionString { get; }
public string AttachmentsDirectory { get; }
private RestoreTargetDatabase(PostgresContainerFixture postgres, string databaseName, string connectionString, string attachmentsDirectory)
{
_postgres = postgres;
_databaseName = databaseName;
ConnectionString = connectionString;
AttachmentsDirectory = attachmentsDirectory;
}
public static async Task<RestoreTargetDatabase> CreateAsync(PostgresContainerFixture postgres)
{
var databaseName = $"restore_target_{Guid.NewGuid():N}";
await using (var admin = new NpgsqlConnection(postgres.AdminConnectionString))
{
await admin.OpenAsync();
await using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin);
await create.ExecuteNonQueryAsync();
}
var connectionString = new NpgsqlConnectionStringBuilder(postgres.AdminConnectionString)
{
Database = databaseName
}.ConnectionString;
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
await using (var db = new KnowledgeHubDbContext(options))
{
await db.Database.MigrateAsync();
}
var attachmentsDirectory = Path.Combine(Path.GetTempPath(), "ikh-tests-restore-target", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(attachmentsDirectory);
return new RestoreTargetDatabase(postgres, databaseName, connectionString, attachmentsDirectory);
}
public async ValueTask DisposeAsync()
{
if (Directory.Exists(AttachmentsDirectory))
{
Directory.Delete(AttachmentsDirectory, recursive: true);
}
try
{
await using var admin = new NpgsqlConnection(_postgres.AdminConnectionString);
await admin.OpenAsync();
await using (var terminate = admin.CreateCommand())
{
terminate.CommandText =
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = @name AND pid <> pg_backend_pid()";
terminate.Parameters.AddWithValue("name", _databaseName);
await terminate.ExecuteNonQueryAsync();
}
await using var drop = admin.CreateCommand();
drop.CommandText = $"DROP DATABASE IF EXISTS \"{_databaseName}\"";
await drop.ExecuteNonQueryAsync();
}
catch (NpgsqlException)
{
// Best-effort cleanup, same rationale as KnowledgeHubIntegrationFactory's
// own teardown.
}
}
}
@@ -0,0 +1,89 @@
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
/// <summary>Proves M7-A02 ("Startup refuses unsafe schema drift") against a real,
/// freshly migrated PostgreSQL database — forward migration (the normal path) must
/// stay silent, and a database with an applied migration this build's assembly does
/// not know about must be refused before any data-mutating call.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class SchemaGuardTests(PostgresContainerFixture postgres)
{
[Fact]
public async Task A_freshly_migrated_database_with_only_known_migrations_is_accepted()
{
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(target.ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
await using var db = new KnowledgeHubDbContext(options);
await SchemaGuard.EnsureCompatibleAsync(db.Database, CancellationToken.None);
}
[Fact]
public async Task An_unmigrated_brand_new_database_is_accepted_nothing_can_be_ahead_of_an_empty_history()
{
var databaseName = $"schema_guard_fresh_{Guid.NewGuid():N}";
await using (var admin = new NpgsqlConnection(postgres.AdminConnectionString))
{
await admin.OpenAsync();
await using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin);
await create.ExecuteNonQueryAsync();
}
try
{
var connectionString = new NpgsqlConnectionStringBuilder(postgres.AdminConnectionString) { Database = databaseName }.ConnectionString;
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
await using var db = new KnowledgeHubDbContext(options);
// No exception, even though the migrations-history table does not exist yet.
await SchemaGuard.EnsureCompatibleAsync(db.Database, CancellationToken.None);
}
finally
{
await using var admin = new NpgsqlConnection(postgres.AdminConnectionString);
await admin.OpenAsync();
await using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{databaseName}\" WITH (FORCE)", admin);
await drop.ExecuteNonQueryAsync();
}
}
[Fact]
public async Task A_database_with_an_applied_migration_unknown_to_this_build_is_refused()
{
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
// Simulate a database that is ahead of this build (e.g. rolled back to an
// older image against a newer database) by recording a migration this
// assembly has never heard of.
await using (var connection = new NpgsqlConnection(target.ConnectionString))
{
await connection.OpenAsync();
await using var insert = new NpgsqlCommand(
"INSERT INTO \"public\".\"__EFMigrationsHistory\" (migration_id, product_version) VALUES (@id, @version)",
connection);
insert.Parameters.AddWithValue("id", "99999999999999_FutureMigrationFromAnUnreleasedBuild");
insert.Parameters.AddWithValue("version", "99.0.0");
await insert.ExecuteNonQueryAsync();
}
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(target.ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
await using var db = new KnowledgeHubDbContext(options);
var exception = await Assert.ThrowsAsync<SchemaDriftException>(
() => SchemaGuard.EnsureCompatibleAsync(db.Database, CancellationToken.None));
Assert.Contains("FutureMigrationFromAnUnreleasedBuild", exception.Message, StringComparison.Ordinal);
}
}
@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
/// <summary>docs/19-observability.md: "/api/v1/system/info" is permissioned
/// information, distinct from the anonymous /health/live and /health/ready
/// endpoints.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class SystemInfoTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task An_authenticated_administrator_sees_the_current_schema_version()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await client.GetAsync("/api/v1/system/info");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<SystemInfoPayload>();
Assert.NotNull(payload);
Assert.Equal("ItWorx Knowledge Hub", payload.Product);
Assert.Equal("m7", payload.SchemaVersion);
}
[Fact]
public async Task An_anonymous_caller_is_rejected()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync("/api/v1/system/info");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
/// <summary>docs/adr/ADR-029-v2-r5-final-hardening.md (brief Phase 9): the
/// canonical release identity always includes a live-computed database migration
/// range for whichever database the instance is actually connected to — this is
/// never baked into the build-time manifest, so it must be non-null once
/// migrations have run (which they always have, by the time an integration test's
/// database is usable at all). Build-time-only fields (openApiHash, etc.) are
/// honestly null in this test environment, since no release-manifest.json exists
/// on a local test run — never fabricated.</summary>
[Fact]
public async Task The_release_identity_always_reports_a_live_database_migration_range()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await client.GetAsync("/api/v1/system/info");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<SystemInfoWithReleasePayload>();
Assert.NotNull(payload);
Assert.NotNull(payload.Release);
Assert.False(string.IsNullOrWhiteSpace(payload.Release.DatabaseMigrationRange));
// No release-manifest.json is baked in for a local test run — this must
// degrade to null, never a fabricated value.
Assert.Null(payload.Release.OpenApiHash);
}
private sealed record SystemInfoPayload(string Product, string SchemaVersion);
private sealed record SystemInfoWithReleasePayload(string Product, ReleaseIdentityPayload Release);
private sealed record ReleaseIdentityPayload(string? DatabaseMigrationRange, string? OpenApiHash);
}
@@ -0,0 +1,159 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Decisions;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Projects;
[Collection(PostgresTestGroup.Name)]
public sealed class DecisionTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<HttpClient> AuthenticatedClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static async Task<DecisionDetail> CreateDecisionAsync(HttpClient client, string title, Guid? supersedes = null)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/decisions",
new CreateDecisionRequest(null, null, title, "## Context\n\nEen besluit.", "Samenvatting van het besluit.", supersedes));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var decision = await response.Content.ReadFromJsonAsync<DecisionDetail>();
Assert.NotNull(decision);
return decision;
}
[Fact]
public async Task Creating_a_decision_creates_a_backing_ArchitectureDecision_page_with_a_profile()
{
using var client = await AuthenticatedClientAsync();
var decision = await CreateDecisionAsync(client, "Gebruik PostgreSQL als opslag");
Assert.Equal("Proposed", decision.Status);
Assert.Equal(1, decision.DecisionNumber);
Assert.Equal("Gebruik PostgreSQL als opslag", decision.Title);
var pageResponse = await client.GetAsync($"/api/v1/pages/{decision.PageId}");
Assert.Equal(HttpStatusCode.OK, pageResponse.StatusCode);
var page = await pageResponse.Content.ReadFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Knowledge.PageDetail>();
Assert.Equal("ArchitectureDecision", page!.DocumentType);
}
[Fact]
public async Task Accepting_a_proposed_decision_transitions_its_status()
{
using var client = await AuthenticatedClientAsync();
var decision = await CreateDecisionAsync(client, "Gebruik trigger-based search_vector");
var response = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/decisions/{decision.Id}/accept", new { }, decision.ETag);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var accepted = await response.Content.ReadFromJsonAsync<DecisionDetail>();
Assert.Equal("Accepted", accepted!.Status);
}
[Fact]
public async Task Rejecting_an_already_accepted_decision_is_an_invalid_transition()
{
using var client = await AuthenticatedClientAsync();
var decision = await CreateDecisionAsync(client, "Al geaccepteerd besluit");
var acceptResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/decisions/{decision.Id}/accept", new { }, decision.ETag);
var accepted = await acceptResponse.Content.ReadFromJsonAsync<DecisionDetail>();
var rejectResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/decisions/{decision.Id}/reject", new { }, accepted!.ETag);
Assert.Equal(HttpStatusCode.Conflict, rejectResponse.StatusCode);
}
[Fact]
public async Task Accepting_a_decision_that_supersedes_an_accepted_one_flips_the_original_to_superseded_with_bidirectional_links()
{
using var client = await AuthenticatedClientAsync();
var original = await CreateDecisionAsync(client, "Oorspronkelijk besluit");
var acceptOriginalResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/decisions/{original.Id}/accept", new { }, original.ETag);
var acceptedOriginal = await acceptOriginalResponse.Content.ReadFromJsonAsync<DecisionDetail>();
var replacement = await CreateDecisionAsync(client, "Vervangend besluit", acceptedOriginal!.Id);
var acceptReplacementResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/decisions/{replacement.Id}/accept", new { }, replacement.ETag);
Assert.Equal(HttpStatusCode.OK, acceptReplacementResponse.StatusCode);
var acceptedReplacement = await acceptReplacementResponse.Content.ReadFromJsonAsync<DecisionDetail>();
Assert.Equal("Accepted", acceptedReplacement!.Status);
Assert.Equal(acceptedOriginal.Id, acceptedReplacement.Supersedes!.Id);
var originalAfter = await client.GetFromJsonAsync<DecisionDetail>($"/api/v1/decisions/{original.Id}");
Assert.Equal("Superseded", originalAfter!.Status);
Assert.Equal(replacement.Id, originalAfter.SupersededBy!.Id);
}
[Fact]
public async Task Withdrawing_a_proposed_decision_transitions_its_status()
{
using var client = await AuthenticatedClientAsync();
var decision = await CreateDecisionAsync(client, "In te trekken besluit");
var response = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/decisions/{decision.Id}/withdraw", new { }, decision.ETag);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var withdrawn = await response.Content.ReadFromJsonAsync<DecisionDetail>();
Assert.Equal("Withdrawn", withdrawn!.Status);
}
[Fact]
public async Task Decisions_appear_in_the_workspace_decision_list()
{
using var client = await AuthenticatedClientAsync();
var decision = await CreateDecisionAsync(client, "Vindbaar besluit");
var list = await client.GetFromJsonAsync<DecisionListResponse>("/api/v1/decisions");
Assert.NotNull(list);
Assert.Contains(list.Items, item => item.Id == decision.Id && item.Title == "Vindbaar besluit");
}
// Reuses the exact reader-boundary pattern already established in
// ProjectHubTests.A_reader_cannot_create_or_mutate_projects_but_can_read_them —
// V2-M5 found this same boundary was left untested for decisions/runbooks even
// though the endpoints themselves (DecisionsEndpoints.cs) already correctly
// require Editor for create/accept/reject/withdraw.
[Fact]
public async Task A_reader_can_read_decisions_but_cannot_create_or_transition_them()
{
using var adminClient = await AuthenticatedClientAsync();
var decision = await CreateDecisionAsync(adminClient, "Alleen-lezenbesluit");
const string readerEmail = "besluitlezer@itworx.test";
const string readerPassword = "reader-strong-password";
var createUserResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users", new CreateUserRequest("Testlezer", readerEmail, readerPassword, "Reader"));
Assert.Equal(HttpStatusCode.OK, createUserResponse.StatusCode);
using var readerClient = Factory.CreateClient();
await SetupHelper.LoginAsync(readerClient, readerEmail, readerPassword);
var readResponse = await readerClient.GetAsync($"/api/v1/decisions/{decision.Id}");
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
var createResponse = await SetupHelper.PostWithCsrfAsync(
readerClient, "/api/v1/decisions",
new CreateDecisionRequest(null, null, "Ongeautoriseerd besluit", null, "S", null));
Assert.Equal(HttpStatusCode.Forbidden, createResponse.StatusCode);
var acceptResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
readerClient, $"/api/v1/decisions/{decision.Id}/accept", new { }, decision.ETag);
Assert.Equal(HttpStatusCode.Forbidden, acceptResponse.StatusCode);
}
}
@@ -0,0 +1,362 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Projects;
[Collection(PostgresTestGroup.Name)]
public sealed class ProjectHubTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<HttpClient> AuthenticatedClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static async Task<ProjectDetail> CreateProjectAsync(HttpClient client, string name)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects", new CreateProjectRequest(name, "Samenvatting", "Medium", "Startsituatie.", Guid.NewGuid()));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var project = await response.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
return project;
}
private static async Task<HttpResponseMessage> UploadProjectIconAsync(
HttpClient client, Guid projectId, string eTag, byte[] bytes, string filename, string mediaType)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var content = new MultipartFormDataContent();
var image = new ByteArrayContent(bytes);
image.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
content.Add(image, "file", filename);
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/projects/{projectId}/icon") { Content = content };
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.TryAddWithoutValidation("If-Match", $"\"{eTag}\"");
return await client.SendAsync(request);
}
[Fact]
public async Task A_project_can_be_created_and_read_back_with_its_default_lifecycle_status()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Kennisbank migratie");
Assert.Equal("Concept", project.Status);
Assert.Equal("kennisbank-migratie", project.Slug);
Assert.Empty(project.Documents);
Assert.Empty(project.RecentUpdates);
Assert.Empty(project.Milestones);
var fetched = await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}");
Assert.NotNull(fetched);
Assert.Equal(project.Id, fetched.Id);
}
[Fact]
public async Task A_project_appears_in_the_workspace_project_list()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Zichtbaar project");
var list = await client.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects");
Assert.NotNull(list);
Assert.Contains(list.Items, item => item.Id == project.Id);
}
[Fact]
public async Task A_project_icon_is_uploaded_as_a_safe_attachment_and_returned_in_project_reads()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Project met logo");
byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03];
var upload = await UploadProjectIconAsync(client, project.Id, project.ETag, png, "logo.png", "image/png");
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
var icon = await upload.Content.ReadFromJsonAsync<ProjectIconResponse>();
Assert.NotNull(icon);
Assert.NotEqual(project.ETag, icon.ETag);
Assert.Equal(icon.ETag, SetupHelper.GetETag(upload));
var detail = await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}");
Assert.Equal(icon.IconAttachmentId, detail!.IconAttachmentId);
var list = await client.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects");
Assert.Equal(icon.IconAttachmentId, list!.Items.Single(item => item.Id == project.Id).IconAttachmentId);
var content = await client.GetAsync($"/api/v1/attachments/{icon.IconAttachmentId}/content");
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
Assert.Equal("image/png", content.Content.Headers.ContentType?.MediaType);
Assert.Equal(png, await content.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task Replacing_a_project_icon_retires_prior_content_and_bounds_project_icon_usages()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Roteerbaar projectlogo");
byte[] firstPng = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01];
byte[] secondPng = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x02];
byte[] thirdPng = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x03];
var firstResponse = await UploadProjectIconAsync(client, project.Id, project.ETag, firstPng, "first.png", "image/png");
var first = await firstResponse.Content.ReadFromJsonAsync<ProjectIconResponse>();
var secondResponse = await UploadProjectIconAsync(client, project.Id, first!.ETag, secondPng, "second.png", "image/png");
var second = await secondResponse.Content.ReadFromJsonAsync<ProjectIconResponse>();
var thirdResponse = await UploadProjectIconAsync(client, project.Id, second!.ETag, thirdPng, "third.png", "image/png");
var third = await thirdResponse.Content.ReadFromJsonAsync<ProjectIconResponse>();
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, thirdResponse.StatusCode);
Assert.NotNull(third);
Assert.Equal(third.IconAttachmentId, (await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}"))!.IconAttachmentId);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/v1/attachments/{first.IconAttachmentId}/content")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/v1/attachments/{second.IconAttachmentId}/content")).StatusCode);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Infrastructure.Persistence.KnowledgeHubDbContext>();
Assert.Equal(2, await db.AttachmentUsages.CountAsync(usage => usage.ProjectId == project.Id));
Assert.False(await db.AttachmentReferences.AnyAsync(reference => reference.Id == first.IconAttachmentId));
Assert.True(await db.AttachmentReferences.AnyAsync(reference => reference.Id == second.IconAttachmentId && reference.TrashedAt != null));
}
[Fact]
public async Task Project_icon_upload_rejects_svg_and_files_above_two_mebibytes()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Veilig projectlogo");
var svg = await UploadProjectIconAsync(client, project.Id, project.ETag, "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"u8.ToArray(), "logo.svg", "image/svg+xml");
Assert.Equal(HttpStatusCode.UnprocessableEntity, svg.StatusCode);
var tooLarge = await UploadProjectIconAsync(client, project.Id, project.ETag, new byte[(2 * 1024 * 1024) + 1], "large.png", "image/png");
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, tooLarge.StatusCode);
}
[Fact]
public async Task Project_catalog_filters_and_totals_are_server_authoritative()
{
using var client = await AuthenticatedClientAsync();
var matching = await CreateProjectAsync(client, "Catalogusdoel Alpha");
_ = await CreateProjectAsync(client, "Andere werkruimte Alpha");
var filtered = await client.GetFromJsonAsync<ProjectListResponse>(
"/api/v1/projects?search=Catalogusdoel&priority=Medium&limit=1");
Assert.NotNull(filtered);
Assert.Single(filtered.Items);
Assert.Equal(matching.Id, filtered.Items[0].Id);
var totals = await client.GetFromJsonAsync<ProjectCatalogTotalsResponse>("/api/v1/projects/totals");
Assert.NotNull(totals);
Assert.True(totals.TotalCount >= 2);
Assert.Equal(0, totals.ActiveCount);
// The list's own TotalCount answers a different question from /projects/totals:
// how many projects the *current filters* match. With limit=1 it must still be
// the filtered total, never the returned page length.
Assert.Equal(1, filtered.TotalCount);
var unfiltered = await client.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects?limit=1");
Assert.NotNull(unfiltered);
Assert.Single(unfiltered.Items);
Assert.Equal(totals.TotalCount, unfiltered.TotalCount);
// And it survives a change of sort order, which uses a different cursor scheme.
var byRecency = await client.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects?sort=updatedAt&limit=1");
Assert.NotNull(byRecency);
Assert.Equal(totals.TotalCount, byRecency.TotalCount);
}
[Fact]
public async Task Updating_a_project_with_the_current_ETag_succeeds_and_returns_a_new_ETag()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Bewerkbaar project");
var response = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/projects/{project.Id}",
new UpdateProjectRequest(
"Bijgewerkte naam", "Nieuwe samenvatting", "High", "Bijgewerkte stand.", project.OwnerId,
null, "2026-12-01", null, "https://example.test/repo", null, null, null, ["C#", "React"]),
project.ETag);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var updated = await response.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(updated);
Assert.Equal("Bijgewerkte naam", updated.Name);
Assert.Equal("High", updated.Priority);
Assert.Equal(["C#", "React"], updated.Technologies);
Assert.NotEqual(project.ETag, updated.ETag);
}
[Fact]
public async Task Updating_a_project_with_a_stale_ETag_is_rejected_as_a_conflict()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Conflictproject");
await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/projects/{project.Id}",
new UpdateProjectRequest("Eerste update", project.Summary, project.Priority, project.CurrentStateMarkdown, project.OwnerId, null, null, null, null, null, null, null, null),
project.ETag);
var staleResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/projects/{project.Id}",
new UpdateProjectRequest("Tweede update", project.Summary, project.Priority, project.CurrentStateMarkdown, project.OwnerId, null, null, null, null, null, null, null, null),
project.ETag);
Assert.Equal(HttpStatusCode.PreconditionFailed, staleResponse.StatusCode);
}
[Theory]
[InlineData("Planned", true)]
[InlineData("Active", false)]
public async Task Project_status_transitions_follow_the_documented_lifecycle(string newStatus, bool expectSuccess)
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, $"Statusproject {newStatus}");
var response = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/projects/{project.Id}/status", new ChangeProjectStatusRequest(newStatus), project.ETag);
if (expectSuccess)
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var updated = await response.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.Equal(newStatus, updated!.Status);
}
else
{
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
}
[Fact]
public async Task A_page_can_be_linked_to_a_project_with_a_role_and_later_unlinked()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Documentproject");
var pageResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new ItWorx.KnowledgeHub.Contracts.Knowledge.CreatePageRequest(null, "Architectuuroverzicht", "WikiPage", "# Architectuur", null));
var page = await pageResponse.Content.ReadFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Knowledge.PageDetail>();
Assert.NotNull(page);
var linkResponse = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/documents", new LinkProjectDocumentRequest(page.Id, "Architecture"));
Assert.Equal(HttpStatusCode.OK, linkResponse.StatusCode);
var link = await linkResponse.Content.ReadFromJsonAsync<ProjectDocumentSummary>();
Assert.NotNull(link);
var detail = await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}");
Assert.Contains(detail!.Documents, document => document.PageId == page.Id && document.Role == "Architecture");
var unlinkResponse = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/projects/documents/{link.Id}");
Assert.Equal(HttpStatusCode.OK, unlinkResponse.StatusCode);
var afterUnlink = await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}");
Assert.DoesNotContain(afterUnlink!.Documents, document => document.PageId == page.Id);
// The page itself must never be touched by unlinking (docs/15-project-hub.md).
var pageStillExists = await client.GetAsync($"/api/v1/pages/{page.Id}");
Assert.Equal(HttpStatusCode.OK, pageStillExists.StatusCode);
}
[Fact]
public async Task Project_updates_form_an_ordered_timeline_and_a_correction_supersedes_the_original()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Tijdlijnproject");
var firstResponse = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/updates",
new CreateProjectUpdateRequest("Progress", "Eerste update", "Voortgang.", "Reported", null, null, null, null, null, null, null, null, null, null));
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
var first = await firstResponse.Content.ReadFromJsonAsync<ProjectUpdateSummary>();
Assert.NotNull(first);
var secondResponse = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/updates",
new CreateProjectUpdateRequest("Progress", "Tweede update", "Meer voortgang.", "Verified", null, null, null, null, null, null, null, null, null, null));
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
var correctionResponse = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/updates",
new CreateProjectUpdateRequest(
"Progress", "Gecorrigeerde eerste update", "Correctie.", "Verified", null, null, null, null, null,
null, null, null, null, first.Id));
Assert.Equal(HttpStatusCode.OK, correctionResponse.StatusCode);
var timeline = await client.GetFromJsonAsync<TimelineEnvelope>($"/api/v1/projects/{project.Id}/updates");
Assert.NotNull(timeline);
Assert.Equal(3, timeline.Items.Count);
var originalAfterCorrection = timeline.Items.Single(update => update.Id == first.Id);
Assert.Equal("Superseded", originalAfterCorrection.VerificationStatus);
}
[Fact]
public async Task Project_milestones_can_be_created_and_updated_with_ETag_concurrency()
{
using var client = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(client, "Mijlpalenproject");
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/milestones",
new CreateProjectMilestoneRequest("Eerste mijlpaal", "2026-09-01", "Doel.", "Criteria."));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var milestone = await createResponse.Content.ReadFromJsonAsync<ProjectMilestoneSummary>();
Assert.NotNull(milestone);
Assert.Equal("Planned", milestone.State);
var updateResponse = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/projects/milestones/{milestone.Id}",
new UpdateProjectMilestoneRequest("Eerste mijlpaal", "2026-09-01", "Doel.", "Criteria.", "Completed"),
milestone.ETag);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<ProjectMilestoneSummary>();
Assert.Equal("Completed", updated!.State);
var milestones = await client.GetFromJsonAsync<IReadOnlyList<ProjectMilestoneSummary>>($"/api/v1/projects/{project.Id}/milestones");
Assert.Contains(milestones!, item => item.Id == milestone.Id && item.State == "Completed");
}
[Fact]
public async Task A_reader_cannot_create_or_mutate_projects_but_can_read_them()
{
using var adminClient = await AuthenticatedClientAsync();
var project = await CreateProjectAsync(adminClient, "Alleen-lezenproject");
const string readerEmail = "projectlezer@itworx.test";
const string readerPassword = "reader-strong-password";
var createUserResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users", new CreateUserRequest("Testlezer", readerEmail, readerPassword, "Reader"));
Assert.Equal(HttpStatusCode.OK, createUserResponse.StatusCode);
using var readerClient = Factory.CreateClient();
await SetupHelper.LoginAsync(readerClient, readerEmail, readerPassword);
var readResponse = await readerClient.GetAsync($"/api/v1/projects/{project.Id}");
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
var createResponse = await SetupHelper.PostWithCsrfAsync(
readerClient, "/api/v1/projects", new CreateProjectRequest("Ongeautoriseerd project", "S", "Medium", "S", Guid.NewGuid()));
Assert.Equal(HttpStatusCode.Forbidden, createResponse.StatusCode);
}
private sealed record TimelineEnvelope(IReadOnlyList<ProjectUpdateSummary> Items, string? NextCursor);
}
Loaded 100 of 825 files, more files were not shown because too many files have changed in this diff. Show more