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