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; /// Proves M7-A03 ("A full backup captures database, attachments, manifest and /// integrity hashes") against a real running application and real PostgreSQL, not a /// mock. [Collection(PostgresTestGroup.Name)] public sealed class BackupServiceTests : OwnerSetupTestBase { private readonly PostgresContainerFixture _postgres; public BackupServiceTests(PostgresContainerFixture postgres) : base(postgres, new Dictionary { ["KnowledgeHub:Backup:RetainedCount"] = "2" }) { _postgres = postgres; } [Fact] public async Task A_backup_captures_every_table_and_verifies_its_own_hashes() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Back-uptestpagina", "WikiPage", "Inhoud.", null)); using var scope = Factory.Services.CreateScope(); var backups = scope.ServiceProvider.GetRequiredService(); var database = scope.ServiceProvider.GetRequiredService(); var appliedMigrations = await database.Database.GetAppliedMigrationsAsync(); Assert.NotEmpty(appliedMigrations); var latestAppliedMigration = appliedMigrations.Last(); var summary = await backups.CreateBackupAsync(CancellationToken.None); Assert.True(summary.TableCount > 0); Assert.True(summary.TotalRowCount > 0); 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().GetAsync(summary.BackupId, CancellationToken.None); Assert.NotNull(receipt!.IntegrityVerifiedAt); Assert.Null(receipt.RestoreVerifiedAt); AssertBackupFilesArePrivate( scope.ServiceProvider.GetRequiredService(), 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(); 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] public async Task A_backup_includes_uploaded_attachments_with_verified_hashes() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var pageResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Pagina met bijlage", "WikiPage", "Inhoud.", null)); var page = await pageResponse.Content.ReadFromJsonAsync(); Assert.NotNull(page); var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync( client, $"/api/v1/pages/{page.Id}/attachments", "Testbestand voor back-up."u8.ToArray(), "backup-test.txt", "text/plain"); Assert.True(uploadResponse.IsSuccessStatusCode); using var scope = Factory.Services.CreateScope(); var backups = scope.ServiceProvider.GetRequiredService(); var summary = await backups.CreateBackupAsync(CancellationToken.None); Assert.Equal(1, summary.AttachmentCount); var manifest = await backups.GetManifestAsync(summary.BackupId, CancellationToken.None); Assert.NotNull(manifest); Assert.Single(manifest.Attachments); } [Fact] public async Task Listing_backups_returns_newest_first_and_retention_prunes_older_ones() { using var scope = Factory.Services.CreateScope(); var backups = scope.ServiceProvider.GetRequiredService(); var first = await backups.CreateBackupAsync(CancellationToken.None); var second = await backups.CreateBackupAsync(CancellationToken.None); var third = await backups.CreateBackupAsync(CancellationToken.None); var list = await backups.ListBackupsAsync(CancellationToken.None); // Configured KnowledgeHub:Backup:RetainedCount = 2 for this test class. Assert.Equal(2, list.Count); Assert.Equal(third.BackupId, list[0].BackupId); 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(); var evidence = scope.ServiceProvider.GetRequiredService(); 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"))); } }