// ----------------------------------------------------------------------------- // 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; /// /// 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) /// [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.Instance); _store = new PostgresAirGapStateStore(_dataSource, NullLogger.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(); // 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 { ["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>(); 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() }; } #endregion }