Publish curated Knowledge Hub source from 2ed833f290b439a7bc91e5fd19b042c269e3bee9
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
+80
-2
@@ -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]
|
||||
|
||||
+200
@@ -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)}\"";
|
||||
}
|
||||
Reference in new issue
Block a user