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")));
}
}