Files
itworx-knowledge-hub-Public/tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/BackupEvidenceTests.cs
T

186 lines
8.1 KiB
C#

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<IOException>(() => 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<BackupManifest>(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);
}
}