// ----------------------------------------------------------------------------- // AuditBundleWriterTests.cs // Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI) // Description: Unit tests for AuditBundleWriter. // ----------------------------------------------------------------------------- using System.Text; using System.Text.Json; using StellaOps.AuditPack.Services; namespace StellaOps.AuditPack.Tests; public class AuditBundleWriterTests : IDisposable { private readonly string _tempDir; public AuditBundleWriterTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"audit-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); } public void Dispose() { if (Directory.Exists(_tempDir)) { Directory.Delete(_tempDir, recursive: true); } } [Fact] public async Task WriteAsync_CreatesValidBundle() { // Arrange var writer = new AuditBundleWriter(); var outputPath = Path.Combine(_tempDir, "test-bundle.tar.gz"); var request = CreateValidRequest(outputPath); // Act var result = await writer.WriteAsync(request); // Assert Assert.True(result.Success, result.Error); Assert.True(File.Exists(outputPath)); Assert.NotNull(result.BundleId); Assert.NotNull(result.MerkleRoot); Assert.NotNull(result.BundleDigest); Assert.True(result.TotalSizeBytes > 0); Assert.True(result.FileCount > 0); } [Fact] public async Task WriteAsync_ComputesMerkleRoot() { // Arrange var writer = new AuditBundleWriter(); var outputPath = Path.Combine(_tempDir, "merkle-test.tar.gz"); var request = CreateValidRequest(outputPath); // Act var result = await writer.WriteAsync(request); // Assert Assert.True(result.Success); Assert.NotNull(result.MerkleRoot); Assert.StartsWith("sha256:", result.MerkleRoot); Assert.Equal(71, result.MerkleRoot.Length); // sha256: + 64 hex chars } [Fact] public async Task WriteAsync_SignsManifest_WhenSignIsTrue() { // Arrange var writer = new AuditBundleWriter(); var outputPath = Path.Combine(_tempDir, "signed-test.tar.gz"); var request = CreateValidRequest(outputPath) with { Sign = true }; // Act var result = await writer.WriteAsync(request); // Assert Assert.True(result.Success); Assert.True(result.Signed); Assert.NotNull(result.SigningKeyId); Assert.NotNull(result.SigningAlgorithm); } [Fact] public async Task WriteAsync_DoesNotSign_WhenSignIsFalse() { // Arrange var writer = new AuditBundleWriter(); var outputPath = Path.Combine(_tempDir, "unsigned-test.tar.gz"); var request = CreateValidRequest(outputPath) with { Sign = false }; // Act var result = await writer.WriteAsync(request); // Assert Assert.True(result.Success); Assert.False(result.Signed); Assert.Null(result.SigningKeyId); } [Fact] public async Task WriteAsync_FailsWithoutSbom() { // Arrange var writer = new AuditBundleWriter(); var outputPath = Path.Combine(_tempDir, "no-sbom.tar.gz"); var request = new AuditBundleWriteRequest { OutputPath = outputPath, ScanId = "scan-001", ImageRef = "test:latest", ImageDigest = "sha256:abc123", Decision = "pass", Sbom = null!, FeedsSnapshot = CreateFeedsSnapshot(), PolicyBundle = CreatePolicyBundle(), Verdict = CreateVerdict() }; // Act var result = await writer.WriteAsync(request); // Assert Assert.False(result.Success); Assert.Contains("SBOM", result.Error); } [Fact] public async Task WriteAsync_IncludesOptionalVex() { // Arrange var writer = new AuditBundleWriter(); var outputPath = Path.Combine(_tempDir, "with-vex.tar.gz"); var vexContent = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { type = "https://openvex.dev/ns/v0.2.0", statements = new[] { new { vulnerability = "CVE-2024-1234", status = "not_affected" } } })); var request = CreateValidRequest(outputPath) with { VexStatements = vexContent }; // Act var result = await writer.WriteAsync(request); // Assert Assert.True(result.Success); Assert.True(result.FileCount >= 5); // sbom, feeds, policy, verdict, vex } [Fact] public async Task WriteAsync_AddsTimeAnchor() { // Arrange var writer = new AuditBundleWriter(); var outputPath = Path.Combine(_tempDir, "with-anchor.tar.gz"); var request = CreateValidRequest(outputPath) with { TimeAnchor = new TimeAnchorInput { Timestamp = DateTimeOffset.UtcNow, Source = "local" } }; // Act var result = await writer.WriteAsync(request); // Assert Assert.True(result.Success); } [Fact] public async Task WriteAsync_DeterministicMerkleRoot() { // Arrange var writer = new AuditBundleWriter(); var sbom = CreateSbom(); var feeds = CreateFeedsSnapshot(); var policy = CreatePolicyBundle(); var verdict = CreateVerdict(); var request1 = new AuditBundleWriteRequest { OutputPath = Path.Combine(_tempDir, "det-1.tar.gz"), ScanId = "scan-001", ImageRef = "test:latest", ImageDigest = "sha256:abc123", Decision = "pass", Sbom = sbom, FeedsSnapshot = feeds, PolicyBundle = policy, Verdict = verdict, Sign = false }; var request2 = request1 with { OutputPath = Path.Combine(_tempDir, "det-2.tar.gz") }; // Act var result1 = await writer.WriteAsync(request1); var result2 = await writer.WriteAsync(request2); // Assert Assert.True(result1.Success); Assert.True(result2.Success); Assert.Equal(result1.MerkleRoot, result2.MerkleRoot); } private AuditBundleWriteRequest CreateValidRequest(string outputPath) { return new AuditBundleWriteRequest { OutputPath = outputPath, ScanId = "scan-001", ImageRef = "test:latest", ImageDigest = "sha256:abc123def456", Decision = "pass", Sbom = CreateSbom(), FeedsSnapshot = CreateFeedsSnapshot(), PolicyBundle = CreatePolicyBundle(), Verdict = CreateVerdict(), Sign = true }; } private static byte[] CreateSbom() { return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { bomFormat = "CycloneDX", specVersion = "1.6", version = 1, components = Array.Empty() })); } private static byte[] CreateFeedsSnapshot() { return Encoding.UTF8.GetBytes("{\"type\":\"feed-snapshot\"}\n"); } private static byte[] CreatePolicyBundle() { // Minimal gzip content return new byte[] { 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; } private static byte[] CreateVerdict() { return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { decision = "pass", evaluatedAt = DateTimeOffset.UtcNow })); } }