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,322 @@
// -----------------------------------------------------------------------------
// AirGapStorageIntegrationTests.cs
// Sprint: SPRINT_5100_0010_0004_airgap_tests
// Tasks: AIRGAP-5100-007, AIRGAP-5100-008, AIRGAP-5100-009
// Description: S1 Storage tests - migrations, idempotency, query determinism
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Storage.Postgres.Repositories;
using StellaOps.AirGap.Time.Models;
using StellaOps.Infrastructure.Postgres.Options;
using Xunit;
namespace StellaOps.AirGap.Storage.Postgres.Tests;
/// <summary>
/// S1 Storage Layer Tests for AirGap
/// Task AIRGAP-5100-007: Migration tests (apply from scratch, apply from N-1)
/// Task AIRGAP-5100-008: Idempotency tests (same bundle imported twice → no duplicates)
/// Task AIRGAP-5100-009: Query determinism tests (explicit ORDER BY checks)
/// </summary>
[Collection(AirGapPostgresCollection.Name)]
public sealed class AirGapStorageIntegrationTests : IAsyncLifetime
{
private readonly AirGapPostgresFixture _fixture;
private readonly PostgresAirGapStateStore _store;
private readonly AirGapDataSource _dataSource;
public AirGapStorageIntegrationTests(AirGapPostgresFixture fixture)
{
_fixture = fixture;
var options = Options.Create(new PostgresOptions
{
ConnectionString = fixture.ConnectionString,
SchemaName = AirGapDataSource.DefaultSchemaName,
AutoMigrate = false
});
_dataSource = new AirGapDataSource(options, NullLogger<AirGapDataSource>.Instance);
_store = new PostgresAirGapStateStore(_dataSource, NullLogger<PostgresAirGapStateStore>.Instance);
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
public async Task DisposeAsync()
{
await _dataSource.DisposeAsync();
}
#region AIRGAP-5100-007: Migration Tests
[Fact]
public async Task Migration_SchemaContainsRequiredTables()
{
// Arrange
var expectedTables = new[]
{
"airgap_state",
"airgap_bundles",
"airgap_import_log"
};
// Act
var tables = await _fixture.GetTableNamesAsync();
// Assert
foreach (var expectedTable in expectedTables)
{
tables.Should().Contain(t => t.Contains(expectedTable, StringComparison.OrdinalIgnoreCase),
$"Table '{expectedTable}' should exist in schema");
}
}
[Fact]
public async Task Migration_AirGapStateHasRequiredColumns()
{
// Arrange
var expectedColumns = new[] { "tenant_id", "sealed", "policy_hash", "time_anchor", "created_at", "updated_at" };
// Act
var columns = await _fixture.GetColumnNamesAsync("airgap_state");
// Assert
foreach (var expectedColumn in expectedColumns)
{
columns.Should().Contain(c => c.Contains(expectedColumn, StringComparison.OrdinalIgnoreCase),
$"Column '{expectedColumn}' should exist in airgap_state");
}
}
[Fact]
public async Task Migration_IsIdempotent()
{
// Act - Running migrations again should not fail
var act = async () =>
{
await _fixture.EnsureMigrationsRunAsync();
};
// Assert
await act.Should().NotThrowAsync("Running migrations multiple times should be idempotent");
}
[Fact]
public async Task Migration_HasTenantIndex()
{
// Act
var indexes = await _fixture.GetIndexNamesAsync("airgap_state");
// Assert
indexes.Should().Contain(i => i.Contains("tenant", StringComparison.OrdinalIgnoreCase),
"airgap_state should have tenant index for multi-tenant queries");
}
#endregion
#region AIRGAP-5100-008: Idempotency Tests
[Fact]
public async Task Idempotency_SetStateTwice_NoException()
{
// Arrange
var tenantId = $"tenant-idem-{Guid.NewGuid():N}";
var state = CreateTestState(tenantId);
// Act - Set state twice
await _store.SetAsync(state);
var act = async () => await _store.SetAsync(state);
// Assert
await act.Should().NotThrowAsync("Setting state twice should be idempotent");
}
[Fact]
public async Task Idempotency_SetStateTwice_SingleRecord()
{
// Arrange
var tenantId = $"tenant-single-{Guid.NewGuid():N}";
var state1 = CreateTestState(tenantId, sealed_: true, policyHash: "sha256:policy-v1");
var state2 = CreateTestState(tenantId, sealed_: true, policyHash: "sha256:policy-v2");
// Act
await _store.SetAsync(state1);
await _store.SetAsync(state2);
var fetched = await _store.GetAsync(tenantId);
// Assert - Should have latest value, not duplicate
fetched.PolicyHash.Should().Be("sha256:policy-v2", "Second set should update, not duplicate");
}
[Fact]
public async Task Idempotency_ConcurrentSets_NoDataCorruption()
{
// Arrange
var tenantId = $"tenant-concurrent-{Guid.NewGuid():N}";
var tasks = new List<Task>();
// Act - Concurrent sets
for (int i = 0; i < 10; i++)
{
var iteration = i;
tasks.Add(Task.Run(async () =>
{
var state = CreateTestState(tenantId, sealed_: iteration % 2 == 0, policyHash: $"sha256:policy-{iteration}");
await _store.SetAsync(state);
}));
}
await Task.WhenAll(tasks);
// Assert - Should have valid state (no corruption)
var fetched = await _store.GetAsync(tenantId);
fetched.Should().NotBeNull();
fetched.TenantId.Should().Be(tenantId);
fetched.PolicyHash.Should().StartWith("sha256:policy-");
}
[Fact]
public async Task Idempotency_SameBundleIdTwice_NoException()
{
// Arrange
var tenantId = $"tenant-bundle-{Guid.NewGuid():N}";
var bundleId = Guid.NewGuid().ToString("N");
// Create state with bundle reference
var state = CreateTestState(tenantId, sealed_: true);
// Act - Set same state twice (simulating duplicate bundle import)
await _store.SetAsync(state);
var act = async () => await _store.SetAsync(state);
// Assert
await act.Should().NotThrowAsync("Importing same bundle twice should be idempotent");
}
#endregion
#region AIRGAP-5100-009: Query Determinism Tests
[Fact]
public async Task QueryDeterminism_SameInput_SameOutput()
{
// Arrange
var tenantId = $"tenant-det-{Guid.NewGuid():N}";
var state = CreateTestState(tenantId);
await _store.SetAsync(state);
// Act - Query multiple times
var result1 = await _store.GetAsync(tenantId);
var result2 = await _store.GetAsync(tenantId);
var result3 = await _store.GetAsync(tenantId);
// Assert - All results should be equivalent
result1.Should().BeEquivalentTo(result2);
result2.Should().BeEquivalentTo(result3);
}
[Fact]
public async Task QueryDeterminism_ContentBudgets_ReturnInConsistentOrder()
{
// Arrange
var tenantId = $"tenant-budgets-{Guid.NewGuid():N}";
var state = CreateTestState(tenantId);
state.ContentBudgets = new Dictionary<string, StalenessBudget>
{
["zebra"] = new StalenessBudget(100, 200),
["alpha"] = new StalenessBudget(300, 400),
["middle"] = new StalenessBudget(500, 600)
};
await _store.SetAsync(state);
// Act - Query multiple times
var results = new List<IReadOnlyDictionary<string, StalenessBudget>>();
for (int i = 0; i < 5; i++)
{
var fetched = await _store.GetAsync(tenantId);
results.Add(fetched.ContentBudgets);
}
// Assert - All queries should return same keys
var keys1 = results[0].Keys.OrderBy(k => k).ToList();
foreach (var result in results.Skip(1))
{
var keys = result.Keys.OrderBy(k => k).ToList();
keys.Should().BeEquivalentTo(keys1, options => options.WithStrictOrdering());
}
}
[Fact]
public async Task QueryDeterminism_TimeAnchor_PreservesAllFields()
{
// Arrange
var tenantId = $"tenant-anchor-{Guid.NewGuid():N}";
var timestamp = DateTimeOffset.Parse("2025-06-15T12:00:00Z");
var state = CreateTestState(tenantId);
state.TimeAnchor = new TimeAnchor(
timestamp,
"tsa.example.com",
"RFC3161",
"sha256:fingerprint",
"sha256:tokendigest");
await _store.SetAsync(state);
// Act
var fetched1 = await _store.GetAsync(tenantId);
var fetched2 = await _store.GetAsync(tenantId);
// Assert
fetched1.TimeAnchor.Should().BeEquivalentTo(fetched2.TimeAnchor);
fetched1.TimeAnchor.Timestamp.Should().Be(timestamp);
fetched1.TimeAnchor.Source.Should().Be("tsa.example.com");
}
[Fact]
public async Task QueryDeterminism_MultipleTenants_IsolatedResults()
{
// Arrange
var tenant1 = $"tenant-iso1-{Guid.NewGuid():N}";
var tenant2 = $"tenant-iso2-{Guid.NewGuid():N}";
await _store.SetAsync(CreateTestState(tenant1, sealed_: true, policyHash: "sha256:tenant1-policy"));
await _store.SetAsync(CreateTestState(tenant2, sealed_: false, policyHash: "sha256:tenant2-policy"));
// Act
var result1 = await _store.GetAsync(tenant1);
var result2 = await _store.GetAsync(tenant2);
// Assert
result1.Sealed.Should().BeTrue();
result1.PolicyHash.Should().Be("sha256:tenant1-policy");
result2.Sealed.Should().BeFalse();
result2.PolicyHash.Should().Be("sha256:tenant2-policy");
}
#endregion
#region Helpers
private static AirGapState CreateTestState(string tenantId, bool sealed_ = false, string? policyHash = null)
{
return new AirGapState
{
Id = Guid.NewGuid().ToString("N"),
TenantId = tenantId,
Sealed = sealed_,
PolicyHash = policyHash,
TimeAnchor = null,
LastTransitionAt = DateTimeOffset.UtcNow,
StalenessBudget = new StalenessBudget(1800, 3600),
DriftBaselineSeconds = 5,
ContentBudgets = new Dictionary<string, StalenessBudget>()
};
}
#endregion
}

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
}