product advisories, stella router improval, tests streghthening

This commit is contained in:
StellaOps Bot
2025-12-24 14:20:26 +02:00
parent 5540ce9430
commit 2c2bbf1005
171 changed files with 58943 additions and 135 deletions

View File

@@ -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
}

View File

@@ -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
}