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