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,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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user