359 lines
13 KiB
C#
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);
|
|
}
|
|
}
|