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
|
||||
}
|
||||
Reference in New Issue
Block a user