Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -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;
|
||||
+14
@@ -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));
|
||||
}
|
||||
}
|
||||
+21
@@ -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));
|
||||
}
|
||||
}
|
||||
+250
@@ -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);
|
||||
}
|
||||
}
|
||||
+229
@@ -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);
|
||||
}
|
||||
}
|
||||
+134
@@ -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);
|
||||
}
|
||||
}
|
||||
+61
@@ -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;
|
||||
+157
@@ -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.");
|
||||
}
|
||||
}
|
||||
+47
@@ -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();
|
||||
}
|
||||
}
|
||||
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
+249
@@ -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",
|
||||
[], [], [], [], [], [], [], [], [])]);
|
||||
}
|
||||
+174
@@ -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);
|
||||
}
|
||||
}
|
||||
+258
@@ -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);
|
||||
}
|
||||
+173
@@ -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;
|
||||
}
|
||||
}
|
||||
+248
@@ -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);
|
||||
}
|
||||
}
|
||||
+514
@@ -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);
|
||||
}
|
||||
}
|
||||
+205
@@ -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",
|
||||
[], [], [], [], [], [], [], [], [])]);
|
||||
}
|
||||
+184
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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."));
|
||||
}
|
||||
}
|
||||
+327
@@ -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);
|
||||
}
|
||||
}
|
||||
+767
@@ -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);
|
||||
}
|
||||
}
|
||||
+218
@@ -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"));
|
||||
}
|
||||
+87
@@ -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();
|
||||
}
|
||||
}
|
||||
+160
@@ -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());
|
||||
}
|
||||
}
|
||||
+184
@@ -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);
|
||||
}
|
||||
}
|
||||
+259
@@ -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);
|
||||
}
|
||||
}
|
||||
+304
@@ -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>());
|
||||
}
|
||||
}
|
||||
+179
@@ -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;
|
||||
}
|
||||
}
|
||||
+168
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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;
|
||||
}
|
||||
}
|
||||
+195
@@ -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><tag></c> into inert escaped text
|
||||
/// (<c>&lt;tag&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 ["", "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);
|
||||
}
|
||||
}
|
||||
+243
@@ -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".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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+304
@@ -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);
|
||||
}
|
||||
}
|
||||
+326
@@ -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);
|
||||
}
|
||||
}
|
||||
+212
@@ -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".
|
||||
"\n\n" +
|
||||
"\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));
|
||||
}
|
||||
}
|
||||
+192
@@ -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\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();
|
||||
}
|
||||
}
|
||||
+317
@@ -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/<page-id>/ 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\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();
|
||||
}
|
||||
}
|
||||
+168
@@ -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
Reference in new issue
Block a user