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; namespace ItWorx.KnowledgeHub.IntegrationTests.Operations; /// Proves M7-A04 ("A clean-target restore recreates logical data, attachments /// and permissions and passes integrity validation") end to end against real /// PostgreSQL: a real backup of a real source database, restored into a second, freshly /// migrated, empty database on the same server — never the source itself /// (docs/18-backup-restore.md: "Never test restore by overwriting the only production /// instance"). [Collection(PostgresTestGroup.Name)] public sealed class RestoreServiceTests : OwnerSetupTestBase { private readonly PostgresContainerFixture _postgres; public RestoreServiceTests(PostgresContainerFixture postgres) : base(postgres) { _postgres = postgres; } [Fact] public async Task A_backup_restores_into_a_clean_target_with_matching_row_counts_and_attachments() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var pageResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Herstelde pagina", "WikiPage", "Inhoud om te herstellen.", null)); var page = await pageResponse.Content.ReadFromJsonAsync(); Assert.NotNull(page); var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync( client, $"/api/v1/pages/{page.Id}/attachments", "Herstelbestand."u8.ToArray(), "restore-test.txt", "text/plain"); Assert.True(uploadResponse.IsSuccessStatusCode); using var sourceScope = Factory.Services.CreateScope(); var backupService = sourceScope.ServiceProvider.GetRequiredService(); var backup = await backupService.CreateBackupAsync(CancellationToken.None); await using var target = await RestoreTargetDatabase.CreateAsync(_postgres); try { var restoreService = sourceScope.ServiceProvider.GetRequiredService(); var report = await restoreService.RestoreAsync( backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None); Assert.True(report.Success, string.Join("; ", report.Warnings)); Assert.All(report.Tables, table => Assert.True(table.Matches, $"{table.TableName}: {table.RestoredRowCount}/{table.ExpectedRowCount}")); var pagesTable = Assert.Single(report.Tables, table => table.TableName == "pages"); Assert.True(pagesTable.RestoredRowCount >= 1); Assert.Equal(1, report.AttachmentsRestored); Assert.Equal(report.AttachmentsExpected, report.AttachmentsRestored); var receipt = await sourceScope.ServiceProvider.GetRequiredService() .GetAsync(backup.BackupId, CancellationToken.None); Assert.NotNull(receipt!.RestoreVerifiedAt); Assert.True(receipt.LastRestoreSucceeded); var restoredAttachmentFiles = Directory.GetFiles(target.AttachmentsDirectory, "*", SearchOption.AllDirectories); Assert.Single(restoredAttachmentFiles); } finally { await target.DisposeAsync(); } } [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(); const string previousSchema = "20260924182209_AddProjectIcons"; await sourceDb.GetService().MigrateAsync(previousSchema); var originalWorkspace = await sourceDb.Workspaces.AsNoTracking().SingleAsync(); var backup = await scope.ServiceProvider.GetRequiredService().CreateBackupAsync(CancellationToken.None); Assert.Equal(previousSchema, backup.SchemaVersion); await using var target = await RestoreTargetDatabase.CreateAsync(_postgres); var options = new DbContextOptionsBuilder() .UseNpgsql(target.ConnectionString).UseSnakeCaseNamingConvention().Options; await using var db = new KnowledgeHubDbContext(options); await db.GetService().MigrateAsync(previousSchema); var report = await scope.ServiceProvider.GetRequiredService().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().CreateBackupAsync(CancellationToken.None); await using var target = await RestoreTargetDatabase.CreateAsync(_postgres); var options = new DbContextOptionsBuilder() .UseNpgsql(target.ConnectionString).UseSnakeCaseNamingConvention().Options; await using var db = new KnowledgeHubDbContext(options); await db.GetService().MigrateAsync("20260924182209_AddProjectIcons"); var exception = await Assert.ThrowsAsync(() => scope.ServiceProvider.GetRequiredService().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(); 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(); await Assert.ThrowsAsync(() => restore.RestoreAsync( backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None)); var receipt = await sourceScope.ServiceProvider.GetRequiredService() .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(); 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(); var exception = await Assert.ThrowsAsync(() => 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() .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(); 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(); 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(); 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 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() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Bron voor niet-schone test", "WikiPage", "I.", null)); using var sourceScope = Factory.Services.CreateScope(); var backupService = sourceScope.ServiceProvider.GetRequiredService(); var backup = await backupService.CreateBackupAsync(CancellationToken.None); await using var target = await RestoreTargetDatabase.CreateAsync(_postgres); try { // Seed the "clean" target with a pre-existing row so it is not actually empty. await using (var connection = new NpgsqlConnection(target.ConnectionString)) { await connection.OpenAsync(); await using var seed = new NpgsqlCommand( "INSERT INTO \"public\".\"installations\" (id, instance_id, primary_locale, time_zone, product_version, schema_version) " + "VALUES (gen_random_uuid(), gen_random_uuid(), 'nl-BE', 'Europe/Brussels', '0.0.0', 'm7')", connection); await seed.ExecuteNonQueryAsync(); } var restoreService = sourceScope.ServiceProvider.GetRequiredService(); await Assert.ThrowsAsync(() => restoreService.RestoreAsync( backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None)); } finally { await target.DisposeAsync(); } } } /// A second, freshly migrated, empty logical database on the same shared /// Testcontainers Postgres server — a genuine "clean target", never the source /// database under test. internal sealed class RestoreTargetDatabase : IAsyncDisposable { private readonly PostgresContainerFixture _postgres; private readonly string _databaseName; public string ConnectionString { get; } public string AttachmentsDirectory { get; } private RestoreTargetDatabase(PostgresContainerFixture postgres, string databaseName, string connectionString, string attachmentsDirectory) { _postgres = postgres; _databaseName = databaseName; ConnectionString = connectionString; AttachmentsDirectory = attachmentsDirectory; } public static async Task CreateAsync(PostgresContainerFixture postgres) { var databaseName = $"restore_target_{Guid.NewGuid():N}"; await using (var admin = new NpgsqlConnection(postgres.AdminConnectionString)) { await admin.OpenAsync(); await using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin); await create.ExecuteNonQueryAsync(); } var connectionString = new NpgsqlConnectionStringBuilder(postgres.AdminConnectionString) { Database = databaseName }.ConnectionString; var options = new DbContextOptionsBuilder() .UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name)) .UseSnakeCaseNamingConvention() .Options; await using (var db = new KnowledgeHubDbContext(options)) { await db.Database.MigrateAsync(); } var attachmentsDirectory = Path.Combine(Path.GetTempPath(), "ikh-tests-restore-target", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(attachmentsDirectory); return new RestoreTargetDatabase(postgres, databaseName, connectionString, attachmentsDirectory); } public async ValueTask DisposeAsync() { if (Directory.Exists(AttachmentsDirectory)) { Directory.Delete(AttachmentsDirectory, recursive: true); } try { await using var admin = new NpgsqlConnection(_postgres.AdminConnectionString); await admin.OpenAsync(); await using (var terminate = admin.CreateCommand()) { terminate.CommandText = "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = @name AND pid <> pg_backend_pid()"; terminate.Parameters.AddWithValue("name", _databaseName); await terminate.ExecuteNonQueryAsync(); } await using var drop = admin.CreateCommand(); drop.CommandText = $"DROP DATABASE IF EXISTS \"{_databaseName}\""; await drop.ExecuteNonQueryAsync(); } catch (NpgsqlException) { // Best-effort cleanup, same rationale as KnowledgeHubIntegrationFactory's // own teardown. } } }