Publish curated Knowledge Hub source from 2ed833f290b439a7bc91e5fd19b042c269e3bee9

This commit is contained in:
Public Source Publisher committed 2026-10-03 17:07:21 +00:00
1 parent 10f2edcd29
commit 59f15b2d08
218 files changed
+23995 -1849

No files matched your search

@@ -1,13 +1,35 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Application.Audit;
using ItWorx.KnowledgeHub.Contracts.Activity;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
[Collection(PostgresTestGroup.Name)]
public sealed class ActivityFeedTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Activity_excludes_security_events_before_pagination_and_audit_retains_them()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
(await SetupHelper.PostWithCsrfAsync(client, "/api/v1/pages",
new CreatePageRequest(null, "Operationeel resultaat", "WikiPage", "Inhoud.", null))).EnsureSuccessStatusCode();
using var scope = Factory.Services.CreateScope();
var audit = scope.ServiceProvider.GetRequiredService<IAuditEventRecorder>();
for (var i = 0; i < 3; i++)
await audit.RecordAsync(new AuditEventRecord(AuditActorKind.System, null, "security",
"token.rotated", "service-account", Guid.NewGuid().ToString(), "Beveiligingswijziging", null,
DateTimeOffset.UtcNow), CancellationToken.None);
var feed = await client.GetFromJsonAsync<ActivityListResponse>("/api/v1/activity?limit=1");
Assert.Single(feed!.Items);
Assert.Equal("page.created", feed.Items[0].Action);
var raw = await scope.ServiceProvider.GetRequiredService<IAuditEventReader>().ListRecentAsync(10, CancellationToken.None);
Assert.Equal(3, raw.Count(item => item.Action == "token.rotated"));
}
[Fact]
public async Task Activity_feed_returns_real_append_only_audit_events_in_reverse_chronological_order()
{
@@ -0,0 +1,185 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using ItWorx.KnowledgeHub.Application.Operations;
using ItWorx.KnowledgeHub.Infrastructure.Operations;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
public sealed class BackupEvidenceTests : IDisposable
{
private readonly string root = Path.Combine(Path.GetTempPath(), "kh-evidence-" + Guid.NewGuid().ToString("N"));
private string BackupsRoot => Path.Combine(root, "backups");
private string KeysRoot => Path.Combine(root, "keys");
[Fact]
public async Task Historical_manifest_has_unknown_integrity_and_restore_until_actual_evidence_is_recorded()
{
var store = CreateStore("backup-one");
var initial = await store.GetAsync("backup-one", CancellationToken.None);
Assert.NotNull(initial);
Assert.Null(initial.IntegrityVerifiedAt);
Assert.Null(initial.RestoreVerifiedAt);
Assert.False(initial.Protected);
await WriteValidIntegrityAsync("backup-one");
await store.RecordRestoreAsync("backup-one", new RestoreReport("backup-one", true,
[new RestoreTableReport("pages", 3, 3)], 1, 1, [], ManifestHash("backup-one")), CancellationToken.None);
var verified = await store.GetAsync("backup-one", CancellationToken.None);
Assert.NotNull(verified!.RestoreVerifiedAt);
// Changing the manifest invalidates previously verified evidence.
await File.AppendAllTextAsync(Path.Combine(BackupsRoot, "backup-one", "manifest.json"), " ");
Assert.Null((await store.GetAsync("backup-one", CancellationToken.None))!.RestoreVerifiedAt);
}
[Fact]
public async Task Manifest_changed_during_restore_cannot_receive_successful_receipt()
{
var store = CreateStore("backup-changed");
await WriteValidIntegrityAsync("backup-changed");
var digest = ManifestHash("backup-changed");
await File.AppendAllTextAsync(Path.Combine(BackupsRoot, "backup-changed", "manifest.json"), " ");
await Assert.ThrowsAsync<IOException>(() => store.RecordRestoreAsync("backup-changed",
new RestoreReport("backup-changed", true, [], 0, 0, [], digest), CancellationToken.None));
Assert.Null((await store.GetAsync("backup-changed", CancellationToken.None))!.RestoreVerifiedAt);
}
[Fact]
public async Task Integrity_evidence_is_bound_to_the_exact_manifest_hash_used_for_staging()
{
const string backupId = "backup-bound-manifest";
var store = CreateStore(backupId);
await WriteValidIntegrityAsync(backupId);
var manifestHash = ManifestHash(backupId);
var matching = await store.GetAsync(backupId, CancellationToken.None, manifestHash);
var mismatching = await store.GetAsync(backupId, CancellationToken.None, new string('0', 64));
Assert.NotNull(matching);
Assert.NotNull(matching.IntegrityVerifiedAt);
Assert.NotNull(mismatching);
Assert.Null(mismatching.IntegrityVerifiedAt);
}
[Fact]
public async Task Forged_manifest_and_matching_unsigned_receipt_do_not_verify()
{
const string backupId = "backup-forged";
var store = CreateStore(backupId);
await WriteValidIntegrityAsync(backupId);
var manifestPath = Path.Combine(BackupsRoot, backupId, "manifest.json");
var manifest = JsonSerializer.Deserialize<BackupManifest>(await File.ReadAllTextAsync(manifestPath));
Assert.NotNull(manifest);
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest with { ProductVersion = "forged" }));
var changedHash = ManifestHash(backupId);
var forgedReceipt = new
{
Version = 1,
BackupId = backupId,
ManifestSha256 = changedHash,
CompletedAt = DateTimeOffset.UtcNow,
Success = true,
HmacSha256 = new string('0', 64)
};
await File.WriteAllTextAsync(Path.Combine(BackupsRoot, backupId, ".integrity.json"),
JsonSerializer.Serialize(forgedReceipt));
var evidence = await store.GetAsync(backupId, CancellationToken.None);
Assert.NotNull(evidence);
Assert.Null(evidence.IntegrityVerifiedAt);
}
[Fact]
public async Task Missing_integrity_key_invalidates_a_previously_signed_receipt()
{
const string backupId = "backup-key-lost";
var store = CreateStore(backupId);
await WriteValidIntegrityAsync(backupId);
Assert.NotNull((await store.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
File.Delete(Path.Combine(KeysRoot, "backup-integrity.key"));
Assert.Null((await store.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
}
[Fact]
public async Task Existing_unix_integrity_key_is_restricted_to_owner_read_write_before_use()
{
if (OperatingSystem.IsWindows()) return;
const string backupId = "backup-key-mode";
var store = CreateStore(backupId);
await WriteValidIntegrityAsync(backupId);
var keyPath = Path.Combine(KeysRoot, "backup-integrity.key");
File.SetUnixFileMode(keyPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
var evidence = await store.GetAsync(backupId, CancellationToken.None);
Assert.NotNull(evidence);
Assert.NotNull(evidence.IntegrityVerifiedAt);
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(keyPath));
}
[Fact]
public async Task Protection_is_versioned_and_stale_write_cannot_remove_it()
{
var store = CreateStore("backup-two");
var protectedBackup = await store.SetProtectionAsync("backup-two", true, 0, Guid.NewGuid(), CancellationToken.None);
Assert.Equal(BackupProtectionResult.Updated, protectedBackup);
Assert.Equal(BackupProtectionResult.Conflict,
await store.SetProtectionAsync("backup-two", false, 0, Guid.NewGuid(), CancellationToken.None));
var state = await store.GetAsync("backup-two", CancellationToken.None);
Assert.True(state!.Protected);
Assert.Equal(1, state.ProtectionVersion);
Assert.Null(await store.GetAsync("../escape", CancellationToken.None));
}
private FileBackupEvidenceStore CreateStore(string id)
{
Directory.CreateDirectory(Path.Combine(BackupsRoot, id));
File.WriteAllText(Path.Combine(BackupsRoot, id, "manifest.json"), JsonSerializer.Serialize(
new BackupManifest(id, DateTimeOffset.UtcNow, "Hub", "1.0", "development", "schema", [], [], 0)));
return new FileBackupEvidenceStore(new BackupPaths(BackupsRoot, Path.Combine(root, "attachments"), KeysRoot), TimeProvider.System);
}
private string ManifestHash(string id) => Convert.ToHexStringLower(
SHA256.HashData(File.ReadAllBytes(Path.Combine(BackupsRoot, id, "manifest.json"))));
private async Task WriteValidIntegrityAsync(string id)
{
Directory.CreateDirectory(KeysRoot);
var keyPath = Path.Combine(KeysRoot, "backup-integrity.key");
if (!File.Exists(keyPath))
await File.WriteAllBytesAsync(keyPath, RandomNumberGenerator.GetBytes(32));
var key = await File.ReadAllBytesAsync(keyPath);
var completedAt = DateTimeOffset.UtcNow;
var manifestHash = ManifestHash(id);
var payload = string.Join('\n',
"itworx-knowledge-hub-backup-evidence-v1",
"integrity",
id,
manifestHash,
completedAt.UtcTicks.ToString(CultureInfo.InvariantCulture),
"1");
var record = new
{
Version = 1,
BackupId = id,
ManifestSha256 = manifestHash,
CompletedAt = completedAt,
Success = true,
HmacSha256 = Convert.ToHexStringLower(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(payload)))
};
await File.WriteAllTextAsync(Path.Combine(BackupsRoot, id, ".integrity.json"),
JsonSerializer.Serialize(record));
}
public void Dispose()
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
@@ -1,7 +1,10 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Application.Operations;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Operations;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
@@ -10,9 +13,16 @@ namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
/// 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" })
public sealed class BackupServiceTests : OwnerSetupTestBase
{
private readonly PostgresContainerFixture _postgres;
public BackupServiceTests(PostgresContainerFixture postgres)
: base(postgres, new Dictionary<string, string?> { ["KnowledgeHub:Backup:RetainedCount"] = "2" })
{
_postgres = postgres;
}
[Fact]
public async Task A_backup_captures_every_table_and_verifies_its_own_hashes()
{
@@ -23,6 +33,10 @@ public sealed class BackupServiceTests(PostgresContainerFixture postgres)
using var scope = Factory.Services.CreateScope();
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
var database = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var appliedMigrations = await database.Database.GetAppliedMigrationsAsync();
Assert.NotEmpty(appliedMigrations);
var latestAppliedMigration = appliedMigrations.Last();
var summary = await backups.CreateBackupAsync(CancellationToken.None);
@@ -31,12 +45,31 @@ public sealed class BackupServiceTests(PostgresContainerFixture postgres)
var manifest = await backups.GetManifestAsync(summary.BackupId, CancellationToken.None);
Assert.NotNull(manifest);
Assert.Equal(latestAppliedMigration, manifest.SchemaVersion);
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");
var receipt = await scope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>().GetAsync(summary.BackupId, CancellationToken.None);
Assert.NotNull(receipt!.IntegrityVerifiedAt);
Assert.Null(receipt.RestoreVerifiedAt);
AssertBackupFilesArePrivate(
scope.ServiceProvider.GetRequiredService<BackupPaths>(), summary.BackupId);
// Restore the binary dump into a separate migrated database and verify every
// manifest row count against the rows actually written by PostgreSQL COPY.
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
var restore = scope.ServiceProvider.GetRequiredService<IRestoreService>();
var report = await restore.RestoreAsync(
summary.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}: restored {table.RestoredRowCount}, manifest expected {table.ExpectedRowCount}"));
}
[Fact]
@@ -85,4 +118,49 @@ public sealed class BackupServiceTests(PostgresContainerFixture postgres)
Assert.Equal(second.BackupId, list[1].BackupId);
Assert.DoesNotContain(list, entry => entry.BackupId == first.BackupId);
}
[Fact]
public async Task Protected_backup_survives_retention_of_newer_unprotected_copies()
{
using var scope = Factory.Services.CreateScope();
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
var evidence = scope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>();
var protectedBackup = await backups.CreateBackupAsync(CancellationToken.None);
Assert.Equal(BackupProtectionResult.Updated,
await evidence.SetProtectionAsync(protectedBackup.BackupId, true, 0, Guid.NewGuid(), CancellationToken.None));
await backups.CreateBackupAsync(CancellationToken.None);
await backups.CreateBackupAsync(CancellationToken.None);
var latest = await backups.CreateBackupAsync(CancellationToken.None);
var list = await backups.ListBackupsAsync(CancellationToken.None);
Assert.Contains(list, backup => backup.BackupId == protectedBackup.BackupId);
Assert.Contains(list, backup => backup.BackupId == latest.BackupId);
Assert.Equal(3, list.Count);
}
private static void AssertBackupFilesArePrivate(BackupPaths paths, string backupId)
{
if (OperatingSystem.IsWindows()) return;
var backupDirectory = Path.Combine(paths.BackupsRoot, backupId);
Assert.Equal(
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
File.GetUnixFileMode(paths.BackupsRoot));
var directories = Directory.EnumerateDirectories(backupDirectory, "*", SearchOption.AllDirectories)
.Prepend(backupDirectory);
foreach (var directory in directories)
{
Assert.Equal(
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
File.GetUnixFileMode(directory));
}
foreach (var file in Directory.EnumerateFiles(backupDirectory, "*", SearchOption.AllDirectories))
{
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(file));
}
Assert.Equal(
UnixFileMode.UserRead | UnixFileMode.UserWrite,
File.GetUnixFileMode(Path.Combine(paths.BackupsRoot, ".backup.lock")));
}
}
@@ -0,0 +1,65 @@
using System.Net;
using System.Text.Json;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
[Collection(PostgresTestGroup.Name)]
public sealed class DataSourceApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<HttpClient> ClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
[Fact]
public async Task A_registered_source_is_persisted_as_unknown_and_requires_concurrency_for_disable()
{
using var client = await ClientAsync();
using var created = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/data-sources", new { sourceKey = $"test:{Guid.NewGuid():N}", displayName = "Test source", kind = "Inventory", expectedIntervalHours = 24 });
Assert.Equal(HttpStatusCode.OK, created.StatusCode);
using var json = JsonDocument.Parse(await created.Content.ReadAsStringAsync());
var id = json.RootElement.GetProperty("id").GetGuid();
Assert.Equal("Unknown", json.RootElement.GetProperty("state").GetString());
Assert.Equal(JsonValueKind.Null, json.RootElement.GetProperty("lastSuccessAt").ValueKind);
using var missing = await SetupHelper.PutWithCsrfAsync(client, $"/api/v1/admin/data-sources/{id}", new { displayName = "Test source", enabled = false, expectedIntervalHours = 24 });
Assert.Equal((HttpStatusCode)428, missing.StatusCode);
using var changed = await SetupHelper.PutWithCsrfAsync(client, $"/api/v1/admin/data-sources/{id}", new { displayName = "Test source", enabled = false, expectedIntervalHours = 24 }, "1");
Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
using var stale = await SetupHelper.PutWithCsrfAsync(client, $"/api/v1/admin/data-sources/{id}", new { displayName = "Stale writer", enabled = true, expectedIntervalHours = 24 }, "1");
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
using var response = await client.GetAsync("/api/v1/admin/data-sources?limit=100");
using var list = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var row = list.RootElement.GetProperty("items").EnumerateArray().Single(item => item.GetProperty("id").GetGuid() == id);
Assert.Equal("Disabled", row.GetProperty("state").GetString());
Assert.Equal("Test source", row.GetProperty("displayName").GetString());
}
[Fact]
public async Task The_inventory_importer_key_cannot_be_registered_as_an_operator_source()
{
using var client = await ClientAsync();
foreach (var kind in new[] { "Gitea", "Inventory" })
{
using var response = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/data-sources",
new { sourceKey = "inventory:default", displayName = "Reserved source", kind, expectedIntervalHours = 24 });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
using var list = await client.GetAsync("/api/v1/admin/data-sources?limit=100");
using var json = JsonDocument.Parse(await list.Content.ReadAsStringAsync());
Assert.Empty(json.RootElement.GetProperty("items").EnumerateArray());
}
[Fact]
public async Task Unauthenticated_mutations_and_missing_csrf_do_not_register_sources()
{
using var anonymous = Factory.CreateClient();
using var denied = await anonymous.GetAsync("/api/v1/admin/data-sources");
Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
using var owner = await ClientAsync();
using var csrfDenied = await owner.PostAsync("/api/v1/admin/data-sources", new StringContent("{}", System.Text.Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.BadRequest, csrfDenied.StatusCode);
}
}
@@ -0,0 +1,54 @@
using System.Diagnostics.Metrics;
using System.Net;
using System.Text;
using ItWorx.KnowledgeHub.Application.Operations;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Metrics;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
public sealed class OtlpExportTests
{
[Fact]
public async Task Platform_metrics_are_exported_over_real_OTLP_HTTP_without_private_labels()
{
var received = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.ConfigureKestrel(server => server.Listen(IPAddress.Loopback, 0));
await using var collector = builder.Build();
collector.MapPost("/v1/metrics", async context =>
{
using var body = new MemoryStream();
await context.Request.Body.CopyToAsync(body);
received.TrySetResult(body.ToArray());
context.Response.ContentType = "application/x-protobuf";
context.Response.StatusCode = 200;
});
await collector.StartAsync();
var address = collector.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
using var metrics = new PlatformMetrics();
using var provider = Sdk.CreateMeterProviderBuilder().AddMeter("ItWorx.KnowledgeHub.Platform")
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(new Uri(address), "/v1/metrics");
options.Protocol = OtlpExportProtocol.HttpProtobuf;
}).Build();
metrics.Record("private-document-title", "secret-token", .125, 2);
metrics.SetQueueState(2, 1, 0, 90);
metrics.SetOperationalAges(30, 60, 120);
Assert.True(provider.ForceFlush(10_000));
var payload = Encoding.UTF8.GetString(await received.Task.WaitAsync(TimeSpan.FromSeconds(10)));
Assert.Contains("platform_operations_total", payload, StringComparison.Ordinal);
Assert.Contains("platform_evidence_age_seconds", payload, StringComparison.Ordinal);
Assert.DoesNotContain("private-document-title", payload, StringComparison.Ordinal);
Assert.DoesNotContain("secret-token", payload, StringComparison.Ordinal);
await collector.StopAsync();
}
}
@@ -0,0 +1,27 @@
using System.Diagnostics.Metrics;
using ItWorx.KnowledgeHub.Application.Operations;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
public sealed class PlatformMetricsTests
{
[Fact]
public void Metrics_reject_unbounded_operation_and_outcome_dimensions()
{
var observed = new List<KeyValuePair<string, object?>>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, current) =>
{
if (instrument.Meter.Name == "ItWorx.KnowledgeHub.Platform") current.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<double>((_, _, tags, _) => observed.AddRange(tags.ToArray()));
listener.SetMeasurementEventCallback<long>((_, _, tags, _) => observed.AddRange(tags.ToArray()));
listener.Start();
using var metrics = new PlatformMetrics();
metrics.Record("private query and bearer token", "secret", 0.1, 3);
Assert.NotEmpty(observed);
Assert.All(observed, tag => Assert.Contains(tag.Key, new[] { "operation", "outcome" }));
Assert.DoesNotContain(observed, tag => tag.Value?.ToString()?.Contains("secret", StringComparison.Ordinal) == true);
Assert.Contains(observed, tag => tag.Value?.ToString() == "other");
}
}
@@ -1,9 +1,13 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.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.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
@@ -57,6 +61,10 @@ public sealed class RestoreServiceTests : OwnerSetupTestBase
Assert.True(pagesTable.RestoredRowCount >= 1);
Assert.Equal(1, report.AttachmentsRestored);
Assert.Equal(report.AttachmentsExpected, report.AttachmentsRestored);
var receipt = await sourceScope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>()
.GetAsync(backup.BackupId, CancellationToken.None);
Assert.NotNull(receipt!.RestoreVerifiedAt);
Assert.True(receipt.LastRestoreSucceeded);
var restoredAttachmentFiles = Directory.GetFiles(target.AttachmentsDirectory, "*", SearchOption.AllDirectories);
Assert.Single(restoredAttachmentFiles);
@@ -67,6 +75,208 @@ public sealed class RestoreServiceTests : OwnerSetupTestBase
}
}
[Fact]
public async Task A_backup_from_the_previous_schema_restores_before_the_target_is_upgraded()
{
// Both databases belong to this test. Seeded owner/workspace data survives
// restore at its original schema and the subsequent additive upgrade.
using var scope = Factory.Services.CreateScope();
var sourceDb = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
const string previousSchema = "20260924182209_AddProjectIcons";
await sourceDb.GetService<IMigrator>().MigrateAsync(previousSchema);
var originalWorkspace = await sourceDb.Workspaces.AsNoTracking().SingleAsync();
var backup = await scope.ServiceProvider.GetRequiredService<IBackupService>().CreateBackupAsync(CancellationToken.None);
Assert.Equal(previousSchema, backup.SchemaVersion);
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(target.ConnectionString).UseSnakeCaseNamingConvention().Options;
await using var db = new KnowledgeHubDbContext(options);
await db.GetService<IMigrator>().MigrateAsync(previousSchema);
var report = await scope.ServiceProvider.GetRequiredService<IRestoreService>().RestoreAsync(
backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None);
Assert.True(report.Success);
await db.Database.MigrateAsync();
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
Assert.Equal(originalWorkspace.Id, (await db.Workspaces.AsNoTracking().SingleAsync()).Id);
Assert.Equal(OwnerEmail, (await db.Users.AsNoTracking().SingleAsync()).Email);
}
[Fact]
public async Task A_different_target_schema_is_rejected_before_any_rows_are_restored()
{
using var scope = Factory.Services.CreateScope();
var backup = await scope.ServiceProvider.GetRequiredService<IBackupService>().CreateBackupAsync(CancellationToken.None);
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(target.ConnectionString).UseSnakeCaseNamingConvention().Options;
await using var db = new KnowledgeHubDbContext(options);
await db.GetService<IMigrator>().MigrateAsync("20260924182209_AddProjectIcons");
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
scope.ServiceProvider.GetRequiredService<IRestoreService>().RestoreAsync(
backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None));
Assert.Contains("doelschema", exception.Message, StringComparison.Ordinal);
Assert.Empty(await db.Workspaces.ToListAsync());
Assert.Empty(await db.Users.ToListAsync());
Assert.Empty(Directory.EnumerateFileSystemEntries(target.AttachmentsDirectory));
}
[Fact]
public async Task A_truncated_manifest_cannot_claim_a_successful_restore()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
using var sourceScope = Factory.Services.CreateScope();
var backups = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
var backup = await backups.CreateBackupAsync(CancellationToken.None);
var manifest = await backups.GetManifestAsync(backup.BackupId, CancellationToken.None);
Assert.NotNull(manifest);
var manifestPath = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "manifest.json");
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest with { Tables = [] }));
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
var restore = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
backup.BackupId,
new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory),
CancellationToken.None));
var receipt = await sourceScope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>()
.GetAsync(backup.BackupId, CancellationToken.None);
Assert.Null(receipt!.RestoreVerifiedAt);
}
[Fact]
public async Task A_forged_manifest_and_matching_receipt_cannot_authenticate_an_altered_dump()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
using var sourceScope = Factory.Services.CreateScope();
var backups = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
var backup = await backups.CreateBackupAsync(CancellationToken.None);
var manifest = await backups.GetManifestAsync(backup.BackupId, CancellationToken.None);
Assert.NotNull(manifest);
var table = Assert.Single(manifest.Tables, entry => entry.TableName == "pages");
var tablePath = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "db", table.FileName);
await using (var dump = new FileStream(tablePath, FileMode.Append, FileAccess.Write, FileShare.None))
dump.WriteByte(0x5A);
await using var alteredDump = File.OpenRead(tablePath);
var alteredDumpHash = Convert.ToHexStringLower(await SHA256.HashDataAsync(alteredDump));
var alteredTable = table with { SizeBytes = new FileInfo(tablePath).Length, Sha256 = alteredDumpHash };
var forgedManifest = manifest with
{
ProductVersion = "forged",
Tables = manifest.Tables.Select(entry => entry.TableName == table.TableName ? alteredTable : entry).ToArray(),
TotalSizeBytes = manifest.TotalSizeBytes + 1
};
var manifestPath = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "manifest.json");
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(forgedManifest));
var forgedManifestHash = Convert.ToHexStringLower(SHA256.HashData(await File.ReadAllBytesAsync(manifestPath)));
var forgedReceipt = new
{
Version = 1,
BackupId = backup.BackupId,
ManifestSha256 = forgedManifestHash,
CompletedAt = DateTimeOffset.UtcNow,
Success = true,
HmacSha256 = new string('0', 64)
};
await File.WriteAllTextAsync(
Path.Combine(Factory.BackupsDirectory, backup.BackupId, ".integrity.json"),
JsonSerializer.Serialize(forgedReceipt));
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
var restore = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
backup.BackupId,
new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory),
CancellationToken.None));
Assert.Contains("integriteit", exception.Message, StringComparison.OrdinalIgnoreCase);
var receipt = await sourceScope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>()
.GetAsync(backup.BackupId, CancellationToken.None);
Assert.Null(receipt!.IntegrityVerifiedAt);
Assert.Null(receipt.RestoreVerifiedAt);
}
[Fact]
public async Task Restore_uses_the_verified_snapshot_if_backup_files_change_during_restore()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "TOCTOU-bronpagina", "WikiPage", "Inhoud.", null));
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var originalContents = "signed backup attachment"u8.ToArray();
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/{page.Id}/attachments", originalContents, "restore-race.txt", "text/plain");
Assert.True(uploadResponse.IsSuccessStatusCode);
using var sourceScope = Factory.Services.CreateScope();
var backups = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
var backup = await backups.CreateBackupAsync(CancellationToken.None);
var manifest = await backups.GetManifestAsync(backup.BackupId, CancellationToken.None);
Assert.NotNull(manifest);
var attachment = Assert.Single(manifest.Attachments);
var table = Assert.Single(manifest.Tables, entry => entry.TableName == "pages");
var sourceTable = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "db", table.FileName);
var sourceAttachment = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "attachments",
attachment.RelativePath.Replace('/', Path.DirectorySeparatorChar));
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
var applicationName = "restore-toctou-" + Guid.NewGuid().ToString("N");
var targetConnectionString = new NpgsqlConnectionStringBuilder(target.ConnectionString)
{
ApplicationName = applicationName
}.ConnectionString;
await using var lockConnection = new NpgsqlConnection(targetConnectionString);
await lockConnection.OpenAsync();
await using var lockTransaction = await lockConnection.BeginTransactionAsync();
await using (var lockTable = new NpgsqlCommand(
"LOCK TABLE public.pages IN ACCESS EXCLUSIVE MODE", lockConnection, lockTransaction))
{
await lockTable.ExecuteNonQueryAsync();
}
var restore = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
var restoreTask = restore.RestoreAsync(
backup.BackupId, new RestoreTarget(targetConnectionString, target.AttachmentsDirectory), CancellationToken.None);
var reachedBlockedTargetCheck = await WaitForRestoreLockWaitAsync(applicationName);
await using (var dump = new FileStream(sourceTable, FileMode.Append, FileAccess.Write, FileShare.None))
await dump.WriteAsync("changed after staging"u8.ToArray());
await File.WriteAllBytesAsync(sourceAttachment, "replaced after verification"u8.ToArray());
await lockTransaction.CommitAsync();
var report = await restoreTask;
Assert.True(reachedBlockedTargetCheck, "The restore should have reached the target database after source verification.");
Assert.True(report.Success, string.Join("; ", report.Warnings));
Assert.True(Assert.Single(report.Tables, restored => restored.TableName == "pages").Matches);
var restoredAttachment = Path.Combine(target.AttachmentsDirectory,
attachment.RelativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.Equal(originalContents, await File.ReadAllBytesAsync(restoredAttachment));
}
private async Task<bool> WaitForRestoreLockWaitAsync(string applicationName)
{
var deadline = DateTime.UtcNow.AddSeconds(15);
while (DateTime.UtcNow < deadline)
{
await using var observer = new NpgsqlConnection(_postgres.AdminConnectionString);
await observer.OpenAsync();
await using var command = new NpgsqlCommand(
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE application_name = @application_name AND wait_event_type = 'Lock')",
observer);
command.Parameters.AddWithValue("application_name", applicationName);
if ((bool)(await command.ExecuteScalarAsync() ?? false)) return true;
await Task.Delay(50);
}
return false;
}
[Fact]
public async Task Restoring_into_a_non_empty_target_is_refused()
{
@@ -1,6 +1,9 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
@@ -22,7 +25,10 @@ public sealed class SystemInfoTests(PostgresContainerFixture postgres) : OwnerSe
var payload = await response.Content.ReadFromJsonAsync<SystemInfoPayload>();
Assert.NotNull(payload);
Assert.Equal("ItWorx Knowledge Hub", payload.Product);
Assert.Equal("m7", payload.SchemaVersion);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var currentMigration = (await db.Database.GetAppliedMigrationsAsync()).Last();
Assert.Equal(currentMigration, payload.SchemaVersion);
}
[Fact]
@@ -0,0 +1,200 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using ItWorx.KnowledgeHub.Application.Operations;
using ItWorx.KnowledgeHub.Infrastructure.Operations;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
[Collection(PostgresTestGroup.Name)]
public sealed class TableIdentifierSafetyTests : OwnerSetupTestBase
{
private readonly PostgresContainerFixture _postgres;
public TableIdentifierSafetyTests(PostgresContainerFixture postgres) : base(postgres)
{
_postgres = postgres;
}
[Theory]
[InlineData("unsafe\";SELECT 1;--")]
[InlineData("unsafe';SELECT 1;--")]
[InlineData("unsafe;SELECT 1;--")]
public async Task Backup_rejects_catalog_identifiers_before_building_copy_sql(string tableName)
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.OpenConnectionAsync();
var connection = (NpgsqlConnection)db.Database.GetDbConnection();
var quotedName = QuoteIdentifier(tableName);
await using (var create = new NpgsqlCommand($"CREATE TABLE public.{quotedName} (id integer)", connection))
await create.ExecuteNonQueryAsync();
try
{
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
backups.CreateBackupAsync(CancellationToken.None));
Assert.Contains("identificatie", exception.Message, StringComparison.OrdinalIgnoreCase);
}
finally
{
await using var drop = new NpgsqlCommand($"DROP TABLE IF EXISTS public.{quotedName}", connection);
await drop.ExecuteNonQueryAsync();
}
}
[Theory]
[InlineData("unsafe\";SELECT 1;--")]
[InlineData("unsafe';SELECT 1;--")]
[InlineData("unsafe;SELECT 1;--")]
public async Task Restore_rejects_a_validly_signed_manifest_identifier_before_target_connection(string tableName)
{
var root = Path.Combine(Path.GetTempPath(), "kh-identifier-" + Guid.NewGuid().ToString("N"));
var backupsRoot = Path.Combine(root, "backups");
var backupId = "signed-identifier-test";
var backupDirectory = Path.Combine(backupsRoot, backupId);
var keysRoot = Path.Combine(root, "keys");
var attachmentsRoot = Path.Combine(root, "source-attachments");
var targetAttachments = Path.Combine(root, "target-attachments");
Directory.CreateDirectory(backupDirectory);
Directory.CreateDirectory(keysRoot);
Directory.CreateDirectory(attachmentsRoot);
Directory.CreateDirectory(targetAttachments);
try
{
await WriteSignedManifestAsync(backupDirectory, keysRoot, backupId, tableName);
var paths = new BackupPaths(backupsRoot, attachmentsRoot, keysRoot);
var evidence = new FileBackupEvidenceStore(paths, TimeProvider.System);
Assert.NotNull((await evidence.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
var restore = new PostgresRestoreService(paths, evidence);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
backupId,
new RestoreTarget(
"Host=127.0.0.1;Port=1;Database=must-not-connect;Username=test;Password=test;Timeout=1",
targetAttachments),
CancellationToken.None));
Assert.Contains("identificatie", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Empty(Directory.EnumerateFileSystemEntries(targetAttachments));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Fact]
public async Task Restore_rejects_duplicate_signed_manifest_tables_before_target_connection()
{
var root = Path.Combine(Path.GetTempPath(), "kh-identifier-" + Guid.NewGuid().ToString("N"));
var backupsRoot = Path.Combine(root, "backups");
var backupId = "duplicate-table-test";
var backupDirectory = Path.Combine(backupsRoot, backupId);
var keysRoot = Path.Combine(root, "keys");
var attachmentsRoot = Path.Combine(root, "source-attachments");
var targetAttachments = Path.Combine(root, "target-attachments");
Directory.CreateDirectory(backupDirectory);
Directory.CreateDirectory(keysRoot);
Directory.CreateDirectory(attachmentsRoot);
Directory.CreateDirectory(targetAttachments);
try
{
await WriteSignedManifestAsync(backupDirectory, keysRoot, backupId, "pages", duplicateTable: true);
var paths = new BackupPaths(backupsRoot, attachmentsRoot, keysRoot);
var evidence = new FileBackupEvidenceStore(paths, TimeProvider.System);
Assert.NotNull((await evidence.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
var restore = new PostgresRestoreService(paths, evidence);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
backupId,
new RestoreTarget(
"Host=127.0.0.1;Port=1;Database=must-not-connect;Username=test;Password=test;Timeout=1",
targetAttachments),
CancellationToken.None));
Assert.Contains("dubbele tabelnamen", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Empty(Directory.EnumerateFileSystemEntries(targetAttachments));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Fact]
public async Task Restore_rejects_unsafe_identity_column_names_before_committing_target_rows()
{
using var scope = Factory.Services.CreateScope();
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
var backup = await backups.CreateBackupAsync(CancellationToken.None);
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
var unsafeColumn = QuoteIdentifier("unsafe';SELECT 1;--");
await using (var connection = new NpgsqlConnection(target.ConnectionString))
{
await connection.OpenAsync();
await using var create = new NpgsqlCommand(
$"CREATE TABLE public.safe_identity_test ({unsafeColumn} integer GENERATED BY DEFAULT AS IDENTITY)", connection);
await create.ExecuteNonQueryAsync();
}
var restore = scope.ServiceProvider.GetRequiredService<IRestoreService>();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
backup.BackupId,
new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory),
CancellationToken.None));
Assert.Contains("identificatie", exception.Message, StringComparison.OrdinalIgnoreCase);
await using var verify = new NpgsqlConnection(target.ConnectionString);
await verify.OpenAsync();
await using var count = new NpgsqlCommand("SELECT count(*) FROM public.workspaces", verify);
Assert.Equal(0L, (long)(await count.ExecuteScalarAsync() ?? 0L));
Assert.Empty(Directory.EnumerateFileSystemEntries(target.AttachmentsDirectory));
}
private static async Task WriteSignedManifestAsync(
string backupDirectory, string keysRoot, string backupId, string tableName, bool duplicateTable = false)
{
var table = new BackupTableManifestEntry(tableName, "rows.bin", 0, 0, new string('0', 64));
BackupTableManifestEntry[] tables = duplicateTable ? [table, table] : [table];
var manifest = new BackupManifest(
backupId, DateTimeOffset.UtcNow, "ItWorx Knowledge Hub", "1.0", "test", "schema",
tables, [], 0);
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest);
await File.WriteAllBytesAsync(Path.Combine(backupDirectory, "manifest.json"), manifestBytes);
var key = RandomNumberGenerator.GetBytes(32);
await File.WriteAllBytesAsync(Path.Combine(keysRoot, "backup-integrity.key"), key);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(Path.Combine(keysRoot, "backup-integrity.key"),
UnixFileMode.UserRead | UnixFileMode.UserWrite);
var completedAt = DateTimeOffset.UtcNow;
var manifestHash = Convert.ToHexStringLower(SHA256.HashData(manifestBytes));
var payload = string.Join('\n',
"itworx-knowledge-hub-backup-evidence-v1", "integrity", backupId, manifestHash,
completedAt.UtcTicks.ToString(CultureInfo.InvariantCulture), "1");
var receipt = new
{
Version = 1,
BackupId = backupId,
ManifestSha256 = manifestHash,
CompletedAt = completedAt,
Success = true,
HmacSha256 = Convert.ToHexStringLower(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(payload)))
};
await File.WriteAllTextAsync(Path.Combine(backupDirectory, ".integrity.json"), JsonSerializer.Serialize(receipt));
CryptographicOperations.ZeroMemory(key);
}
private static string QuoteIdentifier(string identifier) => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
}