using Microsoft.Extensions.Logging.Abstractions; using StellaOps.TaskRunner.Core.Evidence; using StellaOps.TaskRunner.Core.Events; using StellaOps.TestKit; namespace StellaOps.TaskRunner.Tests; public sealed class BundleImportEvidenceTests { [Trait("Category", TestCategories.Unit)] [Fact] public void BundleImportHashChain_Compute_CreatesDeterministicHash() { var input = new BundleImportInputManifest( FormatVersion: "1.0.0", BundleId: "test-bundle", BundleVersion: "2025.10.0", CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"), CreatedBy: "test@example.com", TotalSizeBytes: 1024, ItemCount: 5, ManifestSha256: "sha256:abc123", Signature: null, SignatureValid: null); var outputs = new List { new("file1.json", "sha256:aaa", 100, "application/json", DateTimeOffset.UtcNow, "item1"), new("file2.json", "sha256:bbb", 200, "application/json", DateTimeOffset.UtcNow, "item2") }; var transcript = new List { new(DateTimeOffset.UtcNow, "info", "import.started", "Import started", null) }; var chain1 = BundleImportHashChain.Compute(input, outputs, transcript); var chain2 = BundleImportHashChain.Compute(input, outputs, transcript); Assert.Equal(chain1.RootHash, chain2.RootHash); Assert.Equal(chain1.InputsHash, chain2.InputsHash); Assert.Equal(chain1.OutputsHash, chain2.OutputsHash); Assert.StartsWith("sha256:", chain1.RootHash); } [Trait("Category", TestCategories.Unit)] [Fact] public void BundleImportHashChain_Compute_DifferentInputsProduceDifferentHashes() { var input1 = new BundleImportInputManifest( FormatVersion: "1.0.0", BundleId: "bundle-1", BundleVersion: "2025.10.0", CreatedAt: DateTimeOffset.UtcNow, CreatedBy: null, TotalSizeBytes: 1024, ItemCount: 5, ManifestSha256: "sha256:abc123", Signature: null, SignatureValid: null); var input2 = new BundleImportInputManifest( FormatVersion: "1.0.0", BundleId: "bundle-2", BundleVersion: "2025.10.0", CreatedAt: DateTimeOffset.UtcNow, CreatedBy: null, TotalSizeBytes: 1024, ItemCount: 5, ManifestSha256: "sha256:def456", Signature: null, SignatureValid: null); var outputs = new List(); var transcript = new List(); var chain1 = BundleImportHashChain.Compute(input1, outputs, transcript); var chain2 = BundleImportHashChain.Compute(input2, outputs, transcript); Assert.NotEqual(chain1.RootHash, chain2.RootHash); Assert.NotEqual(chain1.InputsHash, chain2.InputsHash); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task BundleImportEvidenceService_CaptureAsync_StoresEvidence() { var store = new InMemoryPackRunEvidenceStore(); var service = new BundleImportEvidenceService( store, NullLogger.Instance); var evidence = CreateTestEvidence(); var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.NotNull(result.Snapshot); Assert.NotNull(result.EvidencePointer); Assert.Equal(1, store.Count); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task BundleImportEvidenceService_CaptureAsync_CreatesCorrectMaterials() { var store = new InMemoryPackRunEvidenceStore(); var service = new BundleImportEvidenceService( store, NullLogger.Instance); var evidence = CreateTestEvidence(); var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken); Assert.True(result.Success); var snapshot = result.Snapshot!; // Should have: input manifest, 2 outputs, transcript, validation, hashchain = 6 materials Assert.Equal(6, snapshot.Materials.Count); Assert.Contains(snapshot.Materials, m => m.Section == "input"); Assert.Contains(snapshot.Materials, m => m.Section == "output"); Assert.Contains(snapshot.Materials, m => m.Section == "transcript"); Assert.Contains(snapshot.Materials, m => m.Section == "validation"); Assert.Contains(snapshot.Materials, m => m.Section == "hashchain"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task BundleImportEvidenceService_CaptureAsync_SetsCorrectMetadata() { var store = new InMemoryPackRunEvidenceStore(); var service = new BundleImportEvidenceService( store, NullLogger.Instance); var evidence = CreateTestEvidence(); var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken); Assert.True(result.Success); var snapshot = result.Snapshot!; Assert.Equal(evidence.JobId, snapshot.Metadata!["jobId"]); Assert.Equal(evidence.Status.ToString(), snapshot.Metadata["status"]); Assert.Equal(evidence.SourcePath, snapshot.Metadata["sourcePath"]); Assert.Equal("2", snapshot.Metadata["outputCount"]); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task BundleImportEvidenceService_CaptureAsync_EmitsTimelineEvent() { var store = new InMemoryPackRunEvidenceStore(); var timelineSink = new InMemoryPackRunTimelineEventSink(); var emitter = new PackRunTimelineEventEmitter( timelineSink, TimeProvider.System, NullLogger.Instance); var service = new BundleImportEvidenceService( store, NullLogger.Instance, emitter); var evidence = CreateTestEvidence(); var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(1, timelineSink.Count); var evt = timelineSink.GetEvents()[0]; Assert.Equal("bundle.import.evidence_captured", evt.EventType); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task BundleImportEvidenceService_GetAsync_ReturnsEvidence() { var store = new InMemoryPackRunEvidenceStore(); var service = new BundleImportEvidenceService( store, NullLogger.Instance); var evidence = CreateTestEvidence(); await service.CaptureAsync(evidence, TestContext.Current.CancellationToken); var retrieved = await service.GetAsync(evidence.JobId, TestContext.Current.CancellationToken); Assert.NotNull(retrieved); Assert.Equal(evidence.JobId, retrieved.JobId); Assert.Equal(evidence.TenantId, retrieved.TenantId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task BundleImportEvidenceService_GetAsync_ReturnsNullForMissingJob() { var store = new InMemoryPackRunEvidenceStore(); var service = new BundleImportEvidenceService( store, NullLogger.Instance); var retrieved = await service.GetAsync("non-existent-job", TestContext.Current.CancellationToken); Assert.Null(retrieved); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_CreatesFile() { var store = new InMemoryPackRunEvidenceStore(); var service = new BundleImportEvidenceService( store, NullLogger.Instance); var evidence = CreateTestEvidence(); await service.CaptureAsync(evidence, TestContext.Current.CancellationToken); var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json"); try { var result = await service.ExportToPortableBundleAsync( evidence.JobId, outputPath, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(outputPath, result.OutputPath); Assert.True(File.Exists(outputPath)); Assert.True(result.SizeBytes > 0); Assert.StartsWith("sha256:", result.BundleSha256); } finally { if (File.Exists(outputPath)) { File.Delete(outputPath); } } } [Trait("Category", TestCategories.Unit)] [Fact] public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_FailsForMissingJob() { var store = new InMemoryPackRunEvidenceStore(); var service = new BundleImportEvidenceService( store, NullLogger.Instance); var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json"); var result = await service.ExportToPortableBundleAsync( "non-existent-job", outputPath, TestContext.Current.CancellationToken); Assert.False(result.Success); Assert.Contains("No evidence found", result.Error); } [Trait("Category", TestCategories.Unit)] [Fact] public void BundleImportEvidence_RecordProperties_AreAccessible() { var evidence = CreateTestEvidence(); Assert.Equal("test-job-123", evidence.JobId); Assert.Equal("tenant-1", evidence.TenantId); Assert.Equal("/path/to/bundle.tar.gz", evidence.SourcePath); Assert.Equal(BundleImportStatus.Completed, evidence.Status); Assert.NotNull(evidence.InputManifest); Assert.Equal(2, evidence.OutputFiles.Count); Assert.Equal(2, evidence.Transcript.Count); Assert.NotNull(evidence.ValidationResult); } [Trait("Category", TestCategories.Unit)] [Fact] public void BundleImportValidationResult_RecordProperties_AreAccessible() { var result = new BundleImportValidationResult( Valid: true, ChecksumValid: true, SignatureValid: true, FormatValid: true, Errors: null, Warnings: ["Advisory data may be stale"]); Assert.True(result.Valid); Assert.True(result.ChecksumValid); Assert.True(result.SignatureValid); Assert.True(result.FormatValid); Assert.Null(result.Errors); Assert.Single(result.Warnings!); } private static BundleImportEvidence CreateTestEvidence() { var now = DateTimeOffset.UtcNow; var input = new BundleImportInputManifest( FormatVersion: "1.0.0", BundleId: "test-bundle-001", BundleVersion: "2025.10.0", CreatedAt: now.AddHours(-1), CreatedBy: "bundle-builder@example.com", TotalSizeBytes: 10240, ItemCount: 5, ManifestSha256: "sha256:abcdef1234567890", Signature: "base64sig...", SignatureValid: true); var outputs = new List { new("advisories/CVE-2025-0001.json", "sha256:output1hash", 512, "application/json", now, "item1"), new("advisories/CVE-2025-0002.json", "sha256:output2hash", 1024, "application/json", now, "item2") }; var transcript = new List { new(now.AddMinutes(-5), "info", "import.started", "Bundle import started", new Dictionary { ["sourcePath"] = "/path/to/bundle.tar.gz" }), new(now, "info", "import.completed", "Bundle import completed successfully", new Dictionary { ["itemsImported"] = "5" }) }; var validation = new BundleImportValidationResult( Valid: true, ChecksumValid: true, SignatureValid: true, FormatValid: true, Errors: null, Warnings: null); var hashChain = BundleImportHashChain.Compute(input, outputs, transcript); return new BundleImportEvidence( JobId: "test-job-123", TenantId: "tenant-1", SourcePath: "/path/to/bundle.tar.gz", StartedAt: now.AddMinutes(-5), CompletedAt: now, Status: BundleImportStatus.Completed, ErrorMessage: null, InitiatedBy: "admin@example.com", InputManifest: input, OutputFiles: outputs, Transcript: transcript, ValidationResult: validation, HashChain: hashChain); } }