product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleExportImportTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0004_airgap_tests
|
||||
// Tasks: AIRGAP-5100-001, AIRGAP-5100-002, AIRGAP-5100-003, AIRGAP-5100-004
|
||||
// Description: L0 unit tests for bundle export/import and determinism tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// L0 Unit Tests for Bundle Export/Import
|
||||
/// Task AIRGAP-5100-001: Unit tests for bundle export (data → bundle → verify structure)
|
||||
/// Task AIRGAP-5100-002: Unit tests for bundle import (bundle → data → verify integrity)
|
||||
/// Task AIRGAP-5100-003: Determinism test (same inputs → same bundle hash)
|
||||
/// Task AIRGAP-5100-004: Determinism test (export → import → re-export → identical bundle)
|
||||
/// </summary>
|
||||
public sealed class BundleExportImportTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public BundleExportImportTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"airgap-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
try { Directory.Delete(_tempRoot, recursive: true); }
|
||||
catch { /* Ignore cleanup errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
#region AIRGAP-5100-001: Bundle Export Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Export_CreatesValidBundleStructure()
|
||||
{
|
||||
// Arrange
|
||||
var feedFile = await CreateTestFileAsync("feeds", "nvd.json", """{"vulnerabilities":[]}""");
|
||||
var builder = new BundleBuilder();
|
||||
var request = CreateBuildRequest("export-test", "1.0.0", feedFile);
|
||||
var outputPath = Path.Combine(_tempRoot, "output");
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
Directory.Exists(outputPath).Should().BeTrue("Output directory should be created");
|
||||
File.Exists(Path.Combine(outputPath, "feeds", "nvd.json")).Should().BeTrue("Feed file should be copied");
|
||||
manifest.Should().NotBeNull();
|
||||
manifest.Feeds.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_SetsCorrectManifestFields()
|
||||
{
|
||||
// Arrange
|
||||
var feedFile = await CreateTestFileAsync("feeds", "test-feed.json", """{"data":"test"}""");
|
||||
var builder = new BundleBuilder();
|
||||
var request = CreateBuildRequest("manifest-test", "2.0.0", feedFile);
|
||||
var outputPath = Path.Combine(_tempRoot, "manifest-output");
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Name.Should().Be("manifest-test");
|
||||
manifest.Version.Should().Be("2.0.0");
|
||||
manifest.SchemaVersion.Should().Be("1.0.0");
|
||||
manifest.BundleId.Should().NotBeNullOrEmpty();
|
||||
manifest.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_ComputesCorrectFileDigests()
|
||||
{
|
||||
// Arrange
|
||||
var content = """{"content":"digest-test"}""";
|
||||
var feedFile = await CreateTestFileAsync("feeds", "digest-feed.json", content);
|
||||
var builder = new BundleBuilder();
|
||||
var request = CreateBuildRequest("digest-test", "1.0.0", feedFile);
|
||||
var outputPath = Path.Combine(_tempRoot, "digest-output");
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Feeds.Should().ContainSingle();
|
||||
var feedDigest = manifest.Feeds[0].Digest;
|
||||
feedDigest.Should().NotBeNullOrEmpty();
|
||||
feedDigest.Should().HaveLength(64, "SHA-256 hex digest should be 64 characters");
|
||||
|
||||
// Verify digest manually
|
||||
var expectedDigest = ComputeSha256Hex(content);
|
||||
feedDigest.Should().Be(expectedDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_ComputesCorrectBundleDigest()
|
||||
{
|
||||
// Arrange
|
||||
var feedFile = await CreateTestFileAsync("feeds", "bundle-digest.json", """{"data":"bundle"}""");
|
||||
var builder = new BundleBuilder();
|
||||
var request = CreateBuildRequest("bundle-digest-test", "1.0.0", feedFile);
|
||||
var outputPath = Path.Combine(_tempRoot, "bundle-digest-output");
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.BundleDigest.Should().NotBeNullOrEmpty();
|
||||
manifest.BundleDigest.Should().HaveLength(64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_TracksCorrectFileSizes()
|
||||
{
|
||||
// Arrange
|
||||
var content = new string('x', 1024); // 1KB of data
|
||||
var feedFile = await CreateTestFileAsync("feeds", "size-test.json", content);
|
||||
var builder = new BundleBuilder();
|
||||
var request = CreateBuildRequest("size-test", "1.0.0", feedFile);
|
||||
var outputPath = Path.Combine(_tempRoot, "size-output");
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Feeds[0].SizeBytes.Should().Be(1024);
|
||||
manifest.TotalSizeBytes.Should().Be(1024);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIRGAP-5100-002: Bundle Import Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Import_LoadsManifestCorrectly()
|
||||
{
|
||||
// Arrange - First export a bundle
|
||||
var feedFile = await CreateTestFileAsync("feeds", "import-test.json", """{"import":"test"}""");
|
||||
var builder = new BundleBuilder();
|
||||
var request = CreateBuildRequest("import-test", "1.0.0", feedFile);
|
||||
var bundlePath = Path.Combine(_tempRoot, "import-bundle");
|
||||
var manifest = await builder.BuildAsync(request, bundlePath);
|
||||
|
||||
// Write manifest to bundle
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, BundleManifestSerializer.Serialize(manifest));
|
||||
|
||||
// Act - Load the bundle
|
||||
var loader = new BundleLoader();
|
||||
var loaded = await loader.LoadAsync(bundlePath);
|
||||
|
||||
// Assert
|
||||
loaded.Should().NotBeNull();
|
||||
loaded.Name.Should().Be("import-test");
|
||||
loaded.Version.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_VerifiesFileIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var feedContent = """{"integrity":"test"}""";
|
||||
var feedFile = await CreateTestFileAsync("feeds", "integrity.json", feedContent);
|
||||
var builder = new BundleBuilder();
|
||||
var request = CreateBuildRequest("integrity-test", "1.0.0", feedFile);
|
||||
var bundlePath = Path.Combine(_tempRoot, "integrity-bundle");
|
||||
var manifest = await builder.BuildAsync(request, bundlePath);
|
||||
|
||||
// Write manifest
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, BundleManifestSerializer.Serialize(manifest));
|
||||
|
||||
// Act
|
||||
var loader = new BundleLoader();
|
||||
var loaded = await loader.LoadAsync(bundlePath);
|
||||
|
||||
// Assert - Verify file exists and digest matches
|
||||
var feedPath = Path.Combine(bundlePath, "feeds", "nvd.json");
|
||||
File.Exists(feedPath).Should().BeTrue();
|
||||
|
||||
var actualContent = await File.ReadAllTextAsync(feedPath);
|
||||
var actualDigest = ComputeSha256Hex(actualContent);
|
||||
loaded.Feeds[0].Digest.Should().Be(actualDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_FailsOnCorruptedFile()
|
||||
{
|
||||
// Arrange
|
||||
var feedFile = await CreateTestFileAsync("feeds", "corrupt.json", """{"original":"data"}""");
|
||||
var builder = new BundleBuilder();
|
||||
var request = CreateBuildRequest("corrupt-test", "1.0.0", feedFile);
|
||||
var bundlePath = Path.Combine(_tempRoot, "corrupt-bundle");
|
||||
var manifest = await builder.BuildAsync(request, bundlePath);
|
||||
|
||||
// Write manifest
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, BundleManifestSerializer.Serialize(manifest));
|
||||
|
||||
// Corrupt the feed file
|
||||
var corruptPath = Path.Combine(bundlePath, "feeds", "nvd.json");
|
||||
await File.WriteAllTextAsync(corruptPath, """{"corrupted":"data"}""");
|
||||
|
||||
// Act
|
||||
var loader = new BundleLoader();
|
||||
var loaded = await loader.LoadAsync(bundlePath);
|
||||
|
||||
// Assert - File content has changed, digest no longer matches
|
||||
var actualContent = await File.ReadAllTextAsync(corruptPath);
|
||||
var actualDigest = ComputeSha256Hex(actualContent);
|
||||
loaded.Feeds[0].Digest.Should().NotBe(actualDigest, "Digest was computed before corruption");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIRGAP-5100-003: Determinism Tests (Same Inputs → Same Hash)
|
||||
|
||||
[Fact]
|
||||
public async Task Determinism_SameInputs_ProduceSameBundleDigest()
|
||||
{
|
||||
// Arrange
|
||||
var feedContent = """{"determinism":"test-001"}""";
|
||||
|
||||
// Create two identical source files
|
||||
var feedFile1 = await CreateTestFileAsync("source1", "feed.json", feedContent);
|
||||
var feedFile2 = await CreateTestFileAsync("source2", "feed.json", feedContent);
|
||||
|
||||
var builder = new BundleBuilder();
|
||||
|
||||
// Create identical requests (except file paths)
|
||||
var request1 = new BundleBuildRequest(
|
||||
"determinism-test",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedFile1, "feeds/nvd.json", DateTimeOffset.Parse("2025-01-01T00:00:00Z"), FeedFormat.StellaOpsNative) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
var request2 = new BundleBuildRequest(
|
||||
"determinism-test",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedFile2, "feeds/nvd.json", DateTimeOffset.Parse("2025-01-01T00:00:00Z"), FeedFormat.StellaOpsNative) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
var outputPath1 = Path.Combine(_tempRoot, "determinism-output1");
|
||||
var outputPath2 = Path.Combine(_tempRoot, "determinism-output2");
|
||||
|
||||
// Act
|
||||
var manifest1 = await builder.BuildAsync(request1, outputPath1);
|
||||
var manifest2 = await builder.BuildAsync(request2, outputPath2);
|
||||
|
||||
// Assert - File digests should be identical (content-based)
|
||||
manifest1.Feeds[0].Digest.Should().Be(manifest2.Feeds[0].Digest,
|
||||
"Same content should produce same file digest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Determinism_DifferentInputs_ProduceDifferentDigests()
|
||||
{
|
||||
// Arrange
|
||||
var feedFile1 = await CreateTestFileAsync("diff1", "feed.json", """{"version":1}""");
|
||||
var feedFile2 = await CreateTestFileAsync("diff2", "feed.json", """{"version":2}""");
|
||||
|
||||
var builder = new BundleBuilder();
|
||||
var request1 = CreateBuildRequest("diff-test", "1.0.0", feedFile1);
|
||||
var request2 = CreateBuildRequest("diff-test", "1.0.0", feedFile2);
|
||||
|
||||
var outputPath1 = Path.Combine(_tempRoot, "diff-output1");
|
||||
var outputPath2 = Path.Combine(_tempRoot, "diff-output2");
|
||||
|
||||
// Act
|
||||
var manifest1 = await builder.BuildAsync(request1, outputPath1);
|
||||
var manifest2 = await builder.BuildAsync(request2, outputPath2);
|
||||
|
||||
// Assert
|
||||
manifest1.Feeds[0].Digest.Should().NotBe(manifest2.Feeds[0].Digest,
|
||||
"Different content should produce different digests");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Determinism_ManifestSerialization_IsStable()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
// Act - Serialize multiple times
|
||||
var json1 = BundleManifestSerializer.Serialize(manifest);
|
||||
var json2 = BundleManifestSerializer.Serialize(manifest);
|
||||
var json3 = BundleManifestSerializer.Serialize(manifest);
|
||||
|
||||
// Assert
|
||||
json1.Should().Be(json2);
|
||||
json2.Should().Be(json3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIRGAP-5100-004: Roundtrip Determinism (Export → Import → Re-export)
|
||||
|
||||
[Fact]
|
||||
public async Task Roundtrip_ExportImportReexport_ProducesIdenticalFileDigests()
|
||||
{
|
||||
// Arrange - Initial export
|
||||
var feedContent = """{"roundtrip":"determinism-test"}""";
|
||||
var feedFile = await CreateTestFileAsync("roundtrip", "feed.json", feedContent);
|
||||
var builder = new BundleBuilder();
|
||||
var request = CreateBuildRequest("roundtrip-test", "1.0.0", feedFile);
|
||||
var bundlePath1 = Path.Combine(_tempRoot, "roundtrip1");
|
||||
|
||||
// Act - Export first time
|
||||
var manifest1 = await builder.BuildAsync(request, bundlePath1);
|
||||
var digest1 = manifest1.Feeds[0].Digest;
|
||||
|
||||
// Import by loading manifest
|
||||
var manifestJson = BundleManifestSerializer.Serialize(manifest1);
|
||||
await File.WriteAllTextAsync(Path.Combine(bundlePath1, "manifest.json"), manifestJson);
|
||||
|
||||
var loader = new BundleLoader();
|
||||
var imported = await loader.LoadAsync(bundlePath1);
|
||||
|
||||
// Re-export using the imported bundle's files
|
||||
var reexportFeedFile = Path.Combine(bundlePath1, "feeds", "nvd.json");
|
||||
var reexportRequest = new BundleBuildRequest(
|
||||
imported.Name,
|
||||
imported.Version,
|
||||
imported.ExpiresAt,
|
||||
new[] { new FeedBuildConfig(
|
||||
imported.Feeds[0].FeedId,
|
||||
imported.Feeds[0].Name,
|
||||
imported.Feeds[0].Version,
|
||||
reexportFeedFile,
|
||||
imported.Feeds[0].RelativePath,
|
||||
imported.Feeds[0].SnapshotAt,
|
||||
imported.Feeds[0].Format) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
var bundlePath2 = Path.Combine(_tempRoot, "roundtrip2");
|
||||
var manifest2 = await builder.BuildAsync(reexportRequest, bundlePath2);
|
||||
var digest2 = manifest2.Feeds[0].Digest;
|
||||
|
||||
// Assert
|
||||
digest1.Should().Be(digest2, "Roundtrip should produce identical file digests");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_ManifestSerialization_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateTestManifest();
|
||||
|
||||
// Act
|
||||
var json = BundleManifestSerializer.Serialize(original);
|
||||
var deserialized = BundleManifestSerializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().BeEquivalentTo(original);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private async Task<string> CreateTestFileAsync(string subdir, string filename, string content)
|
||||
{
|
||||
var dir = Path.Combine(_tempRoot, subdir);
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, filename);
|
||||
await File.WriteAllTextAsync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static BundleBuildRequest CreateBuildRequest(string name, string version, string feedSourcePath)
|
||||
{
|
||||
return new BundleBuildRequest(
|
||||
name,
|
||||
version,
|
||||
null,
|
||||
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedSourcePath, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
}
|
||||
|
||||
private static BundleManifest CreateTestManifest()
|
||||
{
|
||||
return new BundleManifest
|
||||
{
|
||||
BundleId = "test-bundle-123",
|
||||
SchemaVersion = "1.0.0",
|
||||
Name = "test-bundle",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.Parse("2025-06-15T12:00:00Z"),
|
||||
Feeds = ImmutableArray.Create(new FeedComponent(
|
||||
"feed-1",
|
||||
"nvd",
|
||||
"v1",
|
||||
"feeds/nvd.json",
|
||||
"abcd1234" + new string('0', 56),
|
||||
1024,
|
||||
DateTimeOffset.Parse("2025-06-15T12:00:00Z"),
|
||||
FeedFormat.StellaOpsNative)),
|
||||
Policies = ImmutableArray<PolicyComponent>.Empty,
|
||||
CryptoMaterials = ImmutableArray<CryptoComponent>.Empty,
|
||||
TotalSizeBytes = 1024,
|
||||
BundleDigest = "digest1234" + new string('0', 54)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for bundle export: data → bundle → verify structure.
|
||||
/// Tests that bundle export produces correct structure with all components.
|
||||
/// </summary>
|
||||
public sealed class BundleExportTests : IAsyncLifetime
|
||||
{
|
||||
private string _tempRoot = null!;
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"bundle-export-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#region L0 Export Structure Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Export_EmptyBundle_CreatesValidManifest()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "empty");
|
||||
var request = new BundleBuildRequest(
|
||||
"empty-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert - Structure valid
|
||||
manifest.Should().NotBeNull();
|
||||
manifest.BundleId.Should().NotBeNullOrEmpty();
|
||||
manifest.Name.Should().Be("empty-bundle");
|
||||
manifest.Version.Should().Be("1.0.0");
|
||||
manifest.SchemaVersion.Should().Be("1.0.0");
|
||||
manifest.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1));
|
||||
manifest.Feeds.Should().BeEmpty();
|
||||
manifest.Policies.Should().BeEmpty();
|
||||
manifest.CryptoMaterials.Should().BeEmpty();
|
||||
manifest.TotalSizeBytes.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WithFeed_CopiesFileAndComputesDigest()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "with-feed");
|
||||
var feedContent = "{\"vulns\": []}";
|
||||
var feedFile = CreateTempFile("feed.json", feedContent);
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"feed-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new FeedBuildConfig(
|
||||
"feed-1",
|
||||
"nvd",
|
||||
"v1",
|
||||
feedFile,
|
||||
"feeds/nvd.json",
|
||||
DateTimeOffset.UtcNow,
|
||||
FeedFormat.StellaOpsNative)
|
||||
},
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert - Feed copied and hashed
|
||||
manifest.Feeds.Should().HaveCount(1);
|
||||
var feed = manifest.Feeds[0];
|
||||
feed.FeedId.Should().Be("feed-1");
|
||||
feed.Name.Should().Be("nvd");
|
||||
feed.Version.Should().Be("v1");
|
||||
feed.RelativePath.Should().Be("feeds/nvd.json");
|
||||
feed.Digest.Should().NotBeNullOrEmpty();
|
||||
feed.Digest.Should().HaveLength(64); // SHA-256 hex
|
||||
feed.SizeBytes.Should().Be(Encoding.UTF8.GetByteCount(feedContent));
|
||||
feed.Format.Should().Be(FeedFormat.StellaOpsNative);
|
||||
|
||||
// File exists in output
|
||||
File.Exists(Path.Combine(outputPath, "feeds/nvd.json")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WithPolicy_CopiesFileAndComputesDigest()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "with-policy");
|
||||
var policyContent = "package policy\ndefault allow = false";
|
||||
var policyFile = CreateTempFile("default.rego", policyContent);
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"policy-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
new[]
|
||||
{
|
||||
new PolicyBuildConfig(
|
||||
"policy-1",
|
||||
"default",
|
||||
"1.0",
|
||||
policyFile,
|
||||
"policies/default.rego",
|
||||
PolicyType.OpaRego)
|
||||
},
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Policies.Should().HaveCount(1);
|
||||
var policy = manifest.Policies[0];
|
||||
policy.PolicyId.Should().Be("policy-1");
|
||||
policy.Name.Should().Be("default");
|
||||
policy.Version.Should().Be("1.0");
|
||||
policy.RelativePath.Should().Be("policies/default.rego");
|
||||
policy.Digest.Should().HaveLength(64);
|
||||
policy.Type.Should().Be(PolicyType.OpaRego);
|
||||
|
||||
File.Exists(Path.Combine(outputPath, "policies/default.rego")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WithCryptoMaterial_CopiesFileAndComputesDigest()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "with-crypto");
|
||||
var certContent = "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----";
|
||||
var certFile = CreateTempFile("root.pem", certContent);
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"crypto-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
new[]
|
||||
{
|
||||
new CryptoBuildConfig(
|
||||
"crypto-1",
|
||||
"trust-root",
|
||||
certFile,
|
||||
"certs/root.pem",
|
||||
CryptoComponentType.TrustRoot,
|
||||
DateTimeOffset.UtcNow.AddYears(10))
|
||||
});
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.CryptoMaterials.Should().HaveCount(1);
|
||||
var crypto = manifest.CryptoMaterials[0];
|
||||
crypto.ComponentId.Should().Be("crypto-1");
|
||||
crypto.Name.Should().Be("trust-root");
|
||||
crypto.RelativePath.Should().Be("certs/root.pem");
|
||||
crypto.Digest.Should().HaveLength(64);
|
||||
crypto.Type.Should().Be(CryptoComponentType.TrustRoot);
|
||||
crypto.ExpiresAt.Should().NotBeNull();
|
||||
|
||||
File.Exists(Path.Combine(outputPath, "certs/root.pem")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_MultipleComponents_CalculatesTotalSize()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "multi");
|
||||
|
||||
var feed1 = CreateTempFile("feed1.json", new string('a', 100));
|
||||
var feed2 = CreateTempFile("feed2.json", new string('b', 200));
|
||||
var policy = CreateTempFile("policy.rego", new string('c', 50));
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"multi-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new FeedBuildConfig("f1", "nvd", "v1", feed1, "feeds/f1.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative),
|
||||
new FeedBuildConfig("f2", "ghsa", "v1", feed2, "feeds/f2.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative)
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new PolicyBuildConfig("p1", "default", "1.0", policy, "policies/default.rego", PolicyType.OpaRego)
|
||||
},
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Feeds.Should().HaveCount(2);
|
||||
manifest.Policies.Should().HaveCount(1);
|
||||
manifest.TotalSizeBytes.Should().Be(100 + 200 + 50);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Digest Computation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Export_DigestComputation_MatchesSha256()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "digest");
|
||||
var content = "test content for hashing";
|
||||
var feedFile = CreateTempFile("test.json", content);
|
||||
|
||||
var expectedDigest = ComputeSha256(content);
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"digest-test",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new FeedBuildConfig("f1", "test", "v1", feedFile, "feeds/test.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative)
|
||||
},
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Feeds[0].Digest.Should().BeEquivalentTo(expectedDigest, options => options.IgnoringCase());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_BundleDigest_ComputedFromManifest()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "bundle-digest");
|
||||
var feedFile = CreateTempFile("feed.json", "{}");
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"bundle-digest-test",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new FeedBuildConfig("f1", "test", "v1", feedFile, "feeds/test.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative)
|
||||
},
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert - Bundle digest is computed
|
||||
manifest.BundleDigest.Should().NotBeNullOrEmpty();
|
||||
manifest.BundleDigest.Should().HaveLength(64);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Directory Structure Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Export_CreatesNestedDirectories()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "nested");
|
||||
var feedFile = CreateTempFile("feed.json", "{}");
|
||||
var policyFile = CreateTempFile("policy.rego", "package test");
|
||||
var certFile = CreateTempFile("cert.pem", "cert");
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"nested-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new FeedBuildConfig("f1", "nvd", "v1", feedFile, "feeds/nvd/v1/data.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative)
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new PolicyBuildConfig("p1", "default", "1.0", policyFile, "policies/rego/default.rego", PolicyType.OpaRego)
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new CryptoBuildConfig("c1", "root", certFile, "crypto/certs/ca/root.pem", CryptoComponentType.TrustRoot, null)
|
||||
});
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert - All nested directories created
|
||||
Directory.Exists(Path.Combine(outputPath, "feeds", "nvd", "v1")).Should().BeTrue();
|
||||
Directory.Exists(Path.Combine(outputPath, "policies", "rego")).Should().BeTrue();
|
||||
Directory.Exists(Path.Combine(outputPath, "crypto", "certs", "ca")).Should().BeTrue();
|
||||
|
||||
File.Exists(Path.Combine(outputPath, "feeds", "nvd", "v1", "data.json")).Should().BeTrue();
|
||||
File.Exists(Path.Combine(outputPath, "policies", "rego", "default.rego")).Should().BeTrue();
|
||||
File.Exists(Path.Combine(outputPath, "crypto", "certs", "ca", "root.pem")).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feed Format Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(FeedFormat.StellaOpsNative)]
|
||||
[InlineData(FeedFormat.TrivyDb)]
|
||||
[InlineData(FeedFormat.GrypeDb)]
|
||||
[InlineData(FeedFormat.OsvJson)]
|
||||
public async Task Export_FeedFormat_Preserved(FeedFormat format)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, $"format-{format}");
|
||||
var feedFile = CreateTempFile("feed.json", "{}");
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"format-test",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new FeedBuildConfig("f1", "test", "v1", feedFile, "feeds/test.json", DateTimeOffset.UtcNow, format)
|
||||
},
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Feeds[0].Format.Should().Be(format);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Type Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(PolicyType.OpaRego)]
|
||||
[InlineData(PolicyType.LatticeRules)]
|
||||
[InlineData(PolicyType.UnknownBudgets)]
|
||||
[InlineData(PolicyType.ScoringWeights)]
|
||||
public async Task Export_PolicyType_Preserved(PolicyType type)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, $"policy-{type}");
|
||||
var policyFile = CreateTempFile("policy", "content");
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"policy-type-test",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
new[]
|
||||
{
|
||||
new PolicyBuildConfig("p1", "test", "1.0", policyFile, "policies/test", type)
|
||||
},
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Policies[0].Type.Should().Be(type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Crypto Component Type Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(CryptoComponentType.TrustRoot)]
|
||||
[InlineData(CryptoComponentType.IntermediateCa)]
|
||||
[InlineData(CryptoComponentType.TimestampRoot)]
|
||||
[InlineData(CryptoComponentType.SigningKey)]
|
||||
[InlineData(CryptoComponentType.FulcioRoot)]
|
||||
public async Task Export_CryptoType_Preserved(CryptoComponentType type)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, $"crypto-{type}");
|
||||
var certFile = CreateTempFile("cert", "content");
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"crypto-type-test",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
new[]
|
||||
{
|
||||
new CryptoBuildConfig("c1", "test", certFile, "certs/test", type, null)
|
||||
});
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.CryptoMaterials[0].Type.Should().Be(type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Expiration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WithExpiration_PreservesExpiryDate()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "expiry");
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddDays(30);
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"expiry-test",
|
||||
"1.0.0",
|
||||
expiresAt,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.ExpiresAt.Should().BeCloseTo(expiresAt, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_CryptoWithExpiration_PreservesComponentExpiry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new BundleBuilder();
|
||||
var outputPath = Path.Combine(_tempRoot, "crypto-expiry");
|
||||
var certFile = CreateTempFile("cert.pem", "cert");
|
||||
var componentExpiry = DateTimeOffset.UtcNow.AddYears(5);
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"crypto-expiry-test",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
new[]
|
||||
{
|
||||
new CryptoBuildConfig("c1", "root", certFile, "certs/root.pem", CryptoComponentType.TrustRoot, componentExpiry)
|
||||
});
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.CryptoMaterials[0].ExpiresAt.Should().BeCloseTo(componentExpiry, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private string CreateTempFile(string name, string content)
|
||||
{
|
||||
var path = Path.Combine(_tempRoot, "source", name);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user