using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using ItWorx.KnowledgeHub.Application.Operations; using ItWorx.KnowledgeHub.Infrastructure.Operations; namespace ItWorx.KnowledgeHub.IntegrationTests.Operations; public sealed class BackupEvidenceTests : IDisposable { private readonly string root = Path.Combine(Path.GetTempPath(), "kh-evidence-" + Guid.NewGuid().ToString("N")); private string BackupsRoot => Path.Combine(root, "backups"); private string KeysRoot => Path.Combine(root, "keys"); [Fact] public async Task Historical_manifest_has_unknown_integrity_and_restore_until_actual_evidence_is_recorded() { var store = CreateStore("backup-one"); var initial = await store.GetAsync("backup-one", CancellationToken.None); Assert.NotNull(initial); Assert.Null(initial.IntegrityVerifiedAt); Assert.Null(initial.RestoreVerifiedAt); Assert.False(initial.Protected); await WriteValidIntegrityAsync("backup-one"); await store.RecordRestoreAsync("backup-one", new RestoreReport("backup-one", true, [new RestoreTableReport("pages", 3, 3)], 1, 1, [], ManifestHash("backup-one")), CancellationToken.None); var verified = await store.GetAsync("backup-one", CancellationToken.None); Assert.NotNull(verified!.RestoreVerifiedAt); // Changing the manifest invalidates previously verified evidence. await File.AppendAllTextAsync(Path.Combine(BackupsRoot, "backup-one", "manifest.json"), " "); Assert.Null((await store.GetAsync("backup-one", CancellationToken.None))!.RestoreVerifiedAt); } [Fact] public async Task Manifest_changed_during_restore_cannot_receive_successful_receipt() { var store = CreateStore("backup-changed"); await WriteValidIntegrityAsync("backup-changed"); var digest = ManifestHash("backup-changed"); await File.AppendAllTextAsync(Path.Combine(BackupsRoot, "backup-changed", "manifest.json"), " "); await Assert.ThrowsAsync(() => store.RecordRestoreAsync("backup-changed", new RestoreReport("backup-changed", true, [], 0, 0, [], digest), CancellationToken.None)); Assert.Null((await store.GetAsync("backup-changed", CancellationToken.None))!.RestoreVerifiedAt); } [Fact] public async Task Integrity_evidence_is_bound_to_the_exact_manifest_hash_used_for_staging() { const string backupId = "backup-bound-manifest"; var store = CreateStore(backupId); await WriteValidIntegrityAsync(backupId); var manifestHash = ManifestHash(backupId); var matching = await store.GetAsync(backupId, CancellationToken.None, manifestHash); var mismatching = await store.GetAsync(backupId, CancellationToken.None, new string('0', 64)); Assert.NotNull(matching); Assert.NotNull(matching.IntegrityVerifiedAt); Assert.NotNull(mismatching); Assert.Null(mismatching.IntegrityVerifiedAt); } [Fact] public async Task Forged_manifest_and_matching_unsigned_receipt_do_not_verify() { const string backupId = "backup-forged"; var store = CreateStore(backupId); await WriteValidIntegrityAsync(backupId); var manifestPath = Path.Combine(BackupsRoot, backupId, "manifest.json"); var manifest = JsonSerializer.Deserialize(await File.ReadAllTextAsync(manifestPath)); Assert.NotNull(manifest); await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest with { ProductVersion = "forged" })); var changedHash = ManifestHash(backupId); var forgedReceipt = new { Version = 1, BackupId = backupId, ManifestSha256 = changedHash, CompletedAt = DateTimeOffset.UtcNow, Success = true, HmacSha256 = new string('0', 64) }; await File.WriteAllTextAsync(Path.Combine(BackupsRoot, backupId, ".integrity.json"), JsonSerializer.Serialize(forgedReceipt)); var evidence = await store.GetAsync(backupId, CancellationToken.None); Assert.NotNull(evidence); Assert.Null(evidence.IntegrityVerifiedAt); } [Fact] public async Task Missing_integrity_key_invalidates_a_previously_signed_receipt() { const string backupId = "backup-key-lost"; var store = CreateStore(backupId); await WriteValidIntegrityAsync(backupId); Assert.NotNull((await store.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt); File.Delete(Path.Combine(KeysRoot, "backup-integrity.key")); Assert.Null((await store.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt); } [Fact] public async Task Existing_unix_integrity_key_is_restricted_to_owner_read_write_before_use() { if (OperatingSystem.IsWindows()) return; const string backupId = "backup-key-mode"; var store = CreateStore(backupId); await WriteValidIntegrityAsync(backupId); var keyPath = Path.Combine(KeysRoot, "backup-integrity.key"); File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); var evidence = await store.GetAsync(backupId, CancellationToken.None); Assert.NotNull(evidence); Assert.NotNull(evidence.IntegrityVerifiedAt); Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(keyPath)); } [Fact] public async Task Protection_is_versioned_and_stale_write_cannot_remove_it() { var store = CreateStore("backup-two"); var protectedBackup = await store.SetProtectionAsync("backup-two", true, 0, Guid.NewGuid(), CancellationToken.None); Assert.Equal(BackupProtectionResult.Updated, protectedBackup); Assert.Equal(BackupProtectionResult.Conflict, await store.SetProtectionAsync("backup-two", false, 0, Guid.NewGuid(), CancellationToken.None)); var state = await store.GetAsync("backup-two", CancellationToken.None); Assert.True(state!.Protected); Assert.Equal(1, state.ProtectionVersion); Assert.Null(await store.GetAsync("../escape", CancellationToken.None)); } private FileBackupEvidenceStore CreateStore(string id) { Directory.CreateDirectory(Path.Combine(BackupsRoot, id)); File.WriteAllText(Path.Combine(BackupsRoot, id, "manifest.json"), JsonSerializer.Serialize( new BackupManifest(id, DateTimeOffset.UtcNow, "Hub", "1.0", "development", "schema", [], [], 0))); return new FileBackupEvidenceStore(new BackupPaths(BackupsRoot, Path.Combine(root, "attachments"), KeysRoot), TimeProvider.System); } private string ManifestHash(string id) => Convert.ToHexStringLower( SHA256.HashData(File.ReadAllBytes(Path.Combine(BackupsRoot, id, "manifest.json")))); private async Task WriteValidIntegrityAsync(string id) { Directory.CreateDirectory(KeysRoot); var keyPath = Path.Combine(KeysRoot, "backup-integrity.key"); if (!File.Exists(keyPath)) await File.WriteAllBytesAsync(keyPath, RandomNumberGenerator.GetBytes(32)); var key = await File.ReadAllBytesAsync(keyPath); var completedAt = DateTimeOffset.UtcNow; var manifestHash = ManifestHash(id); var payload = string.Join('\n', "itworx-knowledge-hub-backup-evidence-v1", "integrity", id, manifestHash, completedAt.UtcTicks.ToString(CultureInfo.InvariantCulture), "1"); var record = new { Version = 1, BackupId = id, ManifestSha256 = manifestHash, CompletedAt = completedAt, Success = true, HmacSha256 = Convert.ToHexStringLower(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(payload))) }; await File.WriteAllTextAsync(Path.Combine(BackupsRoot, id, ".integrity.json"), JsonSerializer.Serialize(record)); } public void Dispose() { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } }