product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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