Files
git.stella-ops.org/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/BundleImportEvidenceTests.cs

359 lines
13 KiB
C#

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<BundleImportOutputFile>
{
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<BundleImportTranscriptEntry>
{
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<BundleImportOutputFile>();
var transcript = new List<BundleImportTranscriptEntry>();
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<BundleImportEvidenceService>.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<BundleImportEvidenceService>.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<BundleImportEvidenceService>.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<PackRunTimelineEventEmitter>.Instance);
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.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<BundleImportEvidenceService>.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<BundleImportEvidenceService>.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<BundleImportEvidenceService>.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<BundleImportEvidenceService>.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<BundleImportOutputFile>
{
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<BundleImportTranscriptEntry>
{
new(now.AddMinutes(-5), "info", "import.started", "Bundle import started", new Dictionary<string, string>
{
["sourcePath"] = "/path/to/bundle.tar.gz"
}),
new(now, "info", "import.completed", "Bundle import completed successfully", new Dictionary<string, string>
{
["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);
}
}