This commit is contained in:
master
2026-02-04 19:59:20 +02:00
parent 557feefdc3
commit 5548cf83bf
1479 changed files with 53557 additions and 40339 deletions

View File

@@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.AirGap.Persistence.Extensions;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.AirGap.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed class AirGapPersistenceExtensionsTests
{
[Fact]
[Intent(TestIntents.Operational, "Ensures AirGap persistence services register expected types.")]
[Trait("Category", TestCategories.Unit)]
public void AddAirGapPersistence_RegistersServicesFromOptions()
{
var services = new TestServiceCollection();
services.AddAirGapPersistence(options =>
{
options.ConnectionString = "Host=localhost;Database=airgap";
options.SchemaName = "airgap";
});
services.Any(sd => sd.ServiceType == typeof(AirGapDataSource)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IHostedService))
.Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IAirGapStateStore) &&
sd.ImplementationType == typeof(PostgresAirGapStateStore))
.Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IBundleVersionStore) &&
sd.ImplementationType == typeof(PostgresBundleVersionStore))
.Should().BeTrue();
}
private sealed class TestServiceCollection : List<ServiceDescriptor>, IServiceCollection
{
}
}

View File

@@ -0,0 +1,13 @@
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
/// <summary>
/// Collection definition for AirGap PostgreSQL integration tests.
/// Tests in this collection share a single PostgreSQL container instance.
/// </summary>
[CollectionDefinition(Name)]
public sealed class AirGapPostgresCollection : ICollectionFixture<AirGapPostgresFixture>
{
public const string Name = "AirGapPostgres";
}

View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Threading;
using Npgsql;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class AirGapPostgresFixture
{
/// <summary>
/// Gets all index names for a specific table in the test schema.
/// </summary>
public async Task<IReadOnlyList<string>> GetIndexNamesAsync(
string tableName,
CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(
"""
SELECT indexname FROM pg_indexes
WHERE schemaname = @schema AND tablename = @table;
""",
connection);
cmd.Parameters.AddWithValue("schema", SchemaName);
cmd.Parameters.AddWithValue("table", tableName);
var indexes = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
indexes.Add(reader.GetString(0));
}
return indexes;
}
/// <summary>
/// Ensures migrations have been run. This is idempotent and safe to call multiple times.
/// </summary>
public async Task EnsureMigrationsRunAsync(CancellationToken cancellationToken = default)
{
var migrationAssembly = GetMigrationAssembly();
if (migrationAssembly is null)
{
return;
}
await Fixture.RunMigrationsFromAssemblyAsync(
migrationAssembly,
GetModuleName(),
GetResourcePrefix(),
cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Threading;
using Npgsql;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class AirGapPostgresFixture
{
/// <summary>
/// Gets all table names in the test schema.
/// </summary>
public async Task<IReadOnlyList<string>> GetTableNamesAsync(CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(
"""
SELECT table_name FROM information_schema.tables
WHERE table_schema = @schema AND table_type = 'BASE TABLE';
""",
connection);
cmd.Parameters.AddWithValue("schema", SchemaName);
var tables = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
tables.Add(reader.GetString(0));
}
return tables;
}
/// <summary>
/// Gets all column names for a specific table in the test schema.
/// </summary>
public async Task<IReadOnlyList<string>> GetColumnNamesAsync(
string tableName,
CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(
"""
SELECT column_name FROM information_schema.columns
WHERE table_schema = @schema AND table_name = @table;
""",
connection);
cmd.Parameters.AddWithValue("schema", SchemaName);
cmd.Parameters.AddWithValue("table", tableName);
var columns = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
columns.Add(reader.GetString(0));
}
return columns;
}
}

View File

@@ -1,5 +1,4 @@
using System.Reflection;
using Npgsql;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
@@ -10,118 +9,11 @@ namespace StellaOps.AirGap.Persistence.Tests;
/// PostgreSQL integration test fixture for the AirGap module.
/// Runs migrations from embedded resources and provides test isolation.
/// </summary>
public sealed class AirGapPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<AirGapPostgresFixture>
public sealed partial class AirGapPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<AirGapPostgresFixture>
{
protected override Assembly? GetMigrationAssembly()
=> typeof(AirGapDataSource).Assembly;
protected override Assembly? GetMigrationAssembly() => typeof(AirGapDataSource).Assembly;
protected override string GetModuleName() => "AirGap";
protected override string? GetResourcePrefix() => null;
/// <summary>
/// Gets all table names in the test schema.
/// </summary>
public async Task<IReadOnlyList<string>> GetTableNamesAsync(CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(
"""
SELECT table_name FROM information_schema.tables
WHERE table_schema = @schema AND table_type = 'BASE TABLE';
""",
connection);
cmd.Parameters.AddWithValue("schema", SchemaName);
var tables = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
tables.Add(reader.GetString(0));
}
return tables;
}
/// <summary>
/// Gets all column names for a specific table in the test schema.
/// </summary>
public async Task<IReadOnlyList<string>> GetColumnNamesAsync(string tableName, CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(
"""
SELECT column_name FROM information_schema.columns
WHERE table_schema = @schema AND table_name = @table;
""",
connection);
cmd.Parameters.AddWithValue("schema", SchemaName);
cmd.Parameters.AddWithValue("table", tableName);
var columns = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
columns.Add(reader.GetString(0));
}
return columns;
}
/// <summary>
/// Gets all index names for a specific table in the test schema.
/// </summary>
public async Task<IReadOnlyList<string>> GetIndexNamesAsync(string tableName, CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(
"""
SELECT indexname FROM pg_indexes
WHERE schemaname = @schema AND tablename = @table;
""",
connection);
cmd.Parameters.AddWithValue("schema", SchemaName);
cmd.Parameters.AddWithValue("table", tableName);
var indexes = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
indexes.Add(reader.GetString(0));
}
return indexes;
}
/// <summary>
/// Ensures migrations have been run. This is idempotent and safe to call multiple times.
/// </summary>
public async Task EnsureMigrationsRunAsync(CancellationToken cancellationToken = default)
{
var migrationAssembly = GetMigrationAssembly();
if (migrationAssembly != null)
{
await Fixture.RunMigrationsFromAssemblyAsync(
migrationAssembly,
GetModuleName(),
GetResourcePrefix(),
cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// Collection definition for AirGap PostgreSQL integration tests.
/// Tests in this collection share a single PostgreSQL container instance.
/// </summary>
[CollectionDefinition(Name)]
public sealed class AirGapPostgresCollection : ICollectionFixture<AirGapPostgresFixture>
{
public const string Name = "AirGapPostgres";
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using StellaOps.AirGap.Time.Models;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class AirGapStorageIntegrationTests
{
[Fact]
[Intent(TestIntents.Operational, "Ensures content budgets are returned with stable keys.")]
[Trait("Category", TestCategories.QueryDeterminism)]
public async Task QueryDeterminism_ContentBudgets_ReturnInConsistentOrderAsync()
{
var tenantId = "tenant-det-02";
var state = CreateTestState(tenantId, "state-det-02", contentBudgets: new Dictionary<string, StalenessBudget>
{
["zebra"] = new StalenessBudget(100, 200),
["alpha"] = new StalenessBudget(300, 400),
["middle"] = new StalenessBudget(500, 600)
});
await _store.SetAsync(state);
var results = new List<IReadOnlyDictionary<string, StalenessBudget>>();
for (var i = 0; i < 5; i++)
{
var fetched = await _store.GetAsync(tenantId);
results.Add(fetched.ContentBudgets);
}
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]
[Intent(TestIntents.Operational, "Confirms tenant isolation for AirGap state reads.")]
[Trait("Category", TestCategories.QueryDeterminism)]
public async Task QueryDeterminism_MultipleTenants_IsolatedResultsAsync()
{
var tenant1 = "tenant-det-04a";
var tenant2 = "tenant-det-04b";
await _store.SetAsync(CreateTestState(tenant1, "state-det-04a", sealed_: true, policyHash: "sha256:tenant1"));
await _store.SetAsync(CreateTestState(tenant2, "state-det-04b", sealed_: false, policyHash: "sha256:tenant2"));
var result1 = await _store.GetAsync(tenant1);
var result2 = await _store.GetAsync(tenant2);
result1.Sealed.Should().BeTrue();
result1.PolicyHash.Should().Be("sha256:tenant1");
result2.Sealed.Should().BeFalse();
result2.PolicyHash.Should().Be("sha256:tenant2");
}
}

View File

@@ -0,0 +1,50 @@
using FluentAssertions;
using StellaOps.AirGap.Time.Models;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class AirGapStorageIntegrationTests
{
[Fact]
[Intent(TestIntents.Operational, "Verifies repeated reads return consistent state.")]
[Trait("Category", TestCategories.QueryDeterminism)]
public async Task QueryDeterminism_SameInput_SameOutputAsync()
{
var tenantId = "tenant-det-01";
var state = CreateTestState(tenantId, "state-det-01");
await _store.SetAsync(state);
var result1 = await _store.GetAsync(tenantId);
var result2 = await _store.GetAsync(tenantId);
var result3 = await _store.GetAsync(tenantId);
result1.Should().BeEquivalentTo(result2);
result2.Should().BeEquivalentTo(result3);
}
[Fact]
[Intent(TestIntents.Operational, "Verifies time anchor fields round trip correctly.")]
[Trait("Category", TestCategories.QueryDeterminism)]
public async Task QueryDeterminism_TimeAnchor_PreservesAllFieldsAsync()
{
var tenantId = "tenant-det-03";
var anchorTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var state = CreateTestState(tenantId, "state-det-03", timeAnchor: new TimeAnchor(
anchorTime,
"tsa.example.com",
"RFC3161",
"sha256:fingerprint",
"sha256:tokendigest"));
await _store.SetAsync(state);
var fetched1 = await _store.GetAsync(tenantId);
var fetched2 = await _store.GetAsync(tenantId);
fetched1.TimeAnchor.Should().BeEquivalentTo(fetched2.TimeAnchor);
fetched1.TimeAnchor.AnchorTime.Should().Be(anchorTime);
fetched1.TimeAnchor.Source.Should().Be("tsa.example.com");
}
}

View File

@@ -0,0 +1,81 @@
using System.Collections.Generic;
using FluentAssertions;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class AirGapStorageIntegrationTests
{
[Fact]
[Intent(TestIntents.Operational, "Confirms repeated writes do not fail for the same tenant.")]
[Trait("Category", TestCategories.StorageIdempotency)]
public async Task Idempotency_SetStateTwice_NoExceptionAsync()
{
var tenantId = "tenant-idem-01";
var state = CreateTestState(tenantId, "state-idem-01");
await _store.SetAsync(state);
var act = async () => await _store.SetAsync(state);
await act.Should().NotThrowAsync("Setting state twice should be idempotent");
}
[Fact]
[Intent(TestIntents.Operational, "Ensures the latest write wins without creating duplicates.")]
[Trait("Category", TestCategories.StorageIdempotency)]
public async Task Idempotency_SetStateTwice_SingleRecordAsync()
{
var tenantId = "tenant-idem-02";
var state1 = CreateTestState(tenantId, "state-idem-02a", sealed_: true, policyHash: "sha256:policy-v1");
var state2 = CreateTestState(tenantId, "state-idem-02b", sealed_: true, policyHash: "sha256:policy-v2");
await _store.SetAsync(state1);
await _store.SetAsync(state2);
var fetched = await _store.GetAsync(tenantId);
fetched.PolicyHash.Should().Be("sha256:policy-v2", "Second set should update, not duplicate");
}
[Fact]
[Intent(TestIntents.Operational, "Validates concurrent writes keep state consistent.")]
[Trait("Category", TestCategories.StorageConcurrency)]
[Trait("Category", TestCategories.StorageIdempotency)]
public async Task Idempotency_ConcurrentSets_NoDataCorruptionAsync()
{
var tenantId = "tenant-idem-03";
var tasks = new List<Task>();
for (var i = 0; i < 10; i++)
{
var state = CreateTestState(
tenantId,
$"state-idem-03-{i}",
sealed_: i % 2 == 0,
policyHash: $"sha256:policy-{i}");
tasks.Add(_store.SetAsync(state));
}
await Task.WhenAll(tasks);
var fetched = await _store.GetAsync(tenantId);
fetched.Should().NotBeNull();
fetched.TenantId.Should().Be(tenantId);
fetched.PolicyHash.Should().StartWith("sha256:policy-");
}
[Fact]
[Intent(TestIntents.Operational, "Ensures repeat imports do not fail for identical state payloads.")]
[Trait("Category", TestCategories.StorageIdempotency)]
public async Task Idempotency_SetSameStateTwice_NoExceptionAsync()
{
var tenantId = "tenant-idem-04";
var state = CreateTestState(tenantId, "state-idem-04", sealed_: true);
await _store.SetAsync(state);
var act = async () => await _store.SetAsync(state);
await act.Should().NotThrowAsync("Importing identical state twice should be idempotent");
}
}

View File

@@ -0,0 +1,67 @@
using FluentAssertions;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class AirGapStorageIntegrationTests
{
[Fact]
[Intent(TestIntents.Operational, "Validates AirGap storage migrations create required tables.")]
[Trait("Category", TestCategories.StorageMigration)]
public async Task Migration_SchemaContainsRequiredTablesAsync()
{
var expectedTables = new[]
{
"state",
"bundle_versions",
"bundle_version_history"
};
var tables = await _fixture.GetTableNamesAsync();
foreach (var expectedTable in expectedTables)
{
tables.Should().Contain(t => t.Contains(expectedTable, StringComparison.OrdinalIgnoreCase),
$"Table '{expectedTable}' should exist in schema");
}
}
[Fact]
[Intent(TestIntents.Operational, "Verifies required columns exist after AirGap migrations.")]
[Trait("Category", TestCategories.StorageMigration)]
public async Task Migration_AirGapStateHasRequiredColumnsAsync()
{
var expectedColumns = new[] { "tenant_id", "sealed", "policy_hash", "time_anchor", "created_at", "updated_at" };
var columns = await _fixture.GetColumnNamesAsync("state");
foreach (var expectedColumn in expectedColumns)
{
columns.Should().Contain(c => c.Contains(expectedColumn, StringComparison.OrdinalIgnoreCase),
$"Column '{expectedColumn}' should exist in airgap_state");
}
}
[Fact]
[Intent(TestIntents.Operational, "Ensures AirGap migrations are idempotent.")]
[Trait("Category", TestCategories.StorageMigration)]
public async Task Migration_IsIdempotentAsync()
{
var act = async () => await _fixture.EnsureMigrationsRunAsync();
await act.Should().NotThrowAsync("Running migrations multiple times should be idempotent");
}
[Fact]
[Intent(TestIntents.Operational, "Ensures tenant lookup indexes exist for AirGap state.")]
[Trait("Category", TestCategories.StorageMigration)]
public async Task Migration_HasTenantIndexAsync()
{
var indexes = await _fixture.GetIndexNamesAsync("state");
indexes.Should().Contain(i => i.Contains("tenant", StringComparison.OrdinalIgnoreCase),
"airgap_state should have tenant index for multi-tenant queries");
}
}

View File

@@ -1,11 +1,5 @@
// -----------------------------------------------------------------------------
// 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 System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Controller.Domain;
@@ -21,12 +15,20 @@ namespace StellaOps.AirGap.Persistence.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-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
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Persistence)]
public sealed partial class AirGapStorageIntegrationTests : IAsyncLifetime
{
private static readonly DateTimeOffset DefaultTransitionAt =
new(2025, 1, 15, 0, 0, 0, TimeSpan.Zero);
private static readonly StalenessBudget DefaultStalenessBudget =
new(1800, 3600);
private readonly AirGapPostgresFixture _fixture;
private readonly PostgresAirGapStateStore _store;
private readonly AirGapDataSource _dataSource;
@@ -55,288 +57,26 @@ public sealed class AirGapStorageIntegrationTests : IAsyncLifetime
await _dataSource.DisposeAsync();
}
#region AIRGAP-5100-007: Migration Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Migration_SchemaContainsRequiredTables()
{
// Arrange
var expectedTables = new[]
{
"state",
"bundle_versions",
"bundle_version_history"
};
// 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");
}
}
[Trait("Category", TestCategories.Unit)]
[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("state");
// Assert
foreach (var expectedColumn in expectedColumns)
{
columns.Should().Contain(c => c.Contains(expectedColumn, StringComparison.OrdinalIgnoreCase),
$"Column '{expectedColumn}' should exist in airgap_state");
}
}
[Trait("Category", TestCategories.Unit)]
[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");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Migration_HasTenantIndex()
{
// Act
var indexes = await _fixture.GetIndexNamesAsync("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
[Trait("Category", TestCategories.Unit)]
[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");
}
[Trait("Category", TestCategories.Unit)]
[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");
}
[Trait("Category", TestCategories.Unit)]
[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-");
}
[Trait("Category", TestCategories.Unit)]
[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
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QueryDeterminism_ContentBudgets_ReturnInConsistentOrder()
{
// Arrange
var tenantId = $"tenant-budgets-{Guid.NewGuid():N}";
var state = CreateTestState(tenantId) with
{
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());
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QueryDeterminism_TimeAnchor_PreservesAllFields()
{
// Arrange
var tenantId = $"tenant-anchor-{Guid.NewGuid():N}";
var anchorTime = DateTimeOffset.Parse("2025-06-15T12:00:00Z");
var state = CreateTestState(tenantId) with
{
TimeAnchor = new TimeAnchor(
anchorTime,
"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.AnchorTime.Should().Be(anchorTime);
fetched1.TimeAnchor.Source.Should().Be("tsa.example.com");
}
[Trait("Category", TestCategories.Unit)]
[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)
private static AirGapState CreateTestState(
string tenantId,
string stateId,
bool sealed_ = false,
string? policyHash = null,
IReadOnlyDictionary<string, StalenessBudget>? contentBudgets = null,
TimeAnchor? timeAnchor = null)
{
return new AirGapState
{
Id = Guid.NewGuid().ToString("N"),
Id = stateId,
TenantId = tenantId,
Sealed = sealed_,
PolicyHash = policyHash,
LastTransitionAt = DateTimeOffset.UtcNow,
StalenessBudget = new StalenessBudget(1800, 3600),
TimeAnchor = timeAnchor ?? TimeAnchor.Unknown,
LastTransitionAt = DefaultTransitionAt,
StalenessBudget = DefaultStalenessBudget,
DriftBaselineSeconds = 5,
ContentBudgets = new Dictionary<string, StalenessBudget>()
ContentBudgets = contentBudgets ??
new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase)
};
}
#endregion
}

View File

@@ -0,0 +1,20 @@
using FluentAssertions;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class PostgresAirGapStateStoreTests
{
[Fact]
[Intent(TestIntents.Operational, "Returns default state for tenants with no stored record.")]
public async Task GetAsync_ReturnsDefaultStateForNewTenantAsync()
{
var state = await _store.GetAsync(TenantId);
state.Should().NotBeNull();
state.TenantId.Should().Be(TenantId);
state.Sealed.Should().BeFalse();
state.PolicyHash.Should().BeNull();
}
}

View File

@@ -0,0 +1,93 @@
using System.Collections.Generic;
using FluentAssertions;
using StellaOps.AirGap.Time.Models;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class PostgresAirGapStateStoreTests
{
[Fact]
[Intent(TestIntents.Operational, "Ensures stored state round trips with all fields intact.")]
public async Task SetAndGet_RoundTripsStateAsync()
{
var timeAnchor = new TimeAnchor(
AnchorTime,
"tsa.example.com",
"RFC3161",
"sha256:fingerprint123",
"sha256:tokendigest456");
var budgets = new Dictionary<string, StalenessBudget>
{
["advisories"] = new StalenessBudget(7200, 14400),
["vex"] = new StalenessBudget(3600, 7200)
};
var state = CreateState(
TenantId,
"state-store-01",
sealed_: true,
policyHash: "sha256:policy789",
budgets: budgets,
timeAnchor: timeAnchor);
await _store.SetAsync(state);
var fetched = await _store.GetAsync(TenantId);
fetched.Should().NotBeNull();
fetched.Sealed.Should().BeTrue();
fetched.PolicyHash.Should().Be("sha256:policy789");
fetched.TimeAnchor.Source.Should().Be("tsa.example.com");
fetched.TimeAnchor.Format.Should().Be("RFC3161");
fetched.StalenessBudget.WarningSeconds.Should().Be(1800);
fetched.StalenessBudget.BreachSeconds.Should().Be(3600);
fetched.DriftBaselineSeconds.Should().Be(5);
fetched.ContentBudgets.Should().HaveCount(2);
fetched.ContentBudgets["advisories"].WarningSeconds.Should().Be(7200);
}
[Fact]
[Intent(TestIntents.Operational, "Confirms existing state updates rather than duplicating.")]
[Trait("Category", TestCategories.StorageIdempotency)]
public async Task SetAsync_UpdatesExistingStateAsync()
{
var state1 = CreateState(TenantId, "state-store-02a");
var state2 = CreateState(
TenantId,
"state-store-02b",
sealed_: true,
policyHash: "sha256:updated",
timeAnchor: new TimeAnchor(AnchorTime, "updated-source", "rfc3161", "", ""));
await _store.SetAsync(state1);
await _store.SetAsync(state2);
var fetched = await _store.GetAsync(TenantId);
fetched.Sealed.Should().BeTrue();
fetched.PolicyHash.Should().Be("sha256:updated");
fetched.TimeAnchor.Source.Should().Be("updated-source");
fetched.StalenessBudget.WarningSeconds.Should().Be(1800);
}
[Fact]
[Intent(TestIntents.Operational, "Stores and retrieves per-content staleness budgets.")]
public async Task SetAsync_PersistsContentBudgetsAsync()
{
var budgets = new Dictionary<string, StalenessBudget>
{
["advisories"] = new StalenessBudget(3600, 7200),
["vex"] = new StalenessBudget(1800, 3600),
["policy"] = new StalenessBudget(900, 1800)
};
var state = CreateState(TenantId, "state-store-03", budgets: budgets);
await _store.SetAsync(state);
var fetched = await _store.GetAsync(TenantId);
fetched.ContentBudgets.Should().HaveCount(3);
fetched.ContentBudgets.Should().ContainKey("advisories");
fetched.ContentBudgets.Should().ContainKey("vex");
fetched.ContentBudgets.Should().ContainKey("policy");
}
}

View File

@@ -1,4 +1,5 @@
using FluentAssertions;
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Controller.Domain;
@@ -6,18 +7,27 @@ using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.AirGap.Persistence.Postgres.Repositories;
using StellaOps.AirGap.Time.Models;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.TestKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Persistence.Tests;
[Collection(AirGapPostgresCollection.Name)]
public sealed class PostgresAirGapStateStoreTests : IAsyncLifetime
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Persistence)]
public sealed partial class PostgresAirGapStateStoreTests : IAsyncLifetime
{
private static readonly DateTimeOffset AnchorTime =
new(2025, 5, 1, 8, 30, 0, TimeSpan.Zero);
private static readonly DateTimeOffset TransitionTime =
new(2025, 5, 1, 8, 45, 0, TimeSpan.Zero);
private readonly AirGapPostgresFixture _fixture;
private readonly PostgresAirGapStateStore _store;
private readonly AirGapDataSource _dataSource;
private readonly string _tenantId = "tenant-" + Guid.NewGuid().ToString("N")[..8];
private const string TenantId = "tenant-store-01";
public PostgresAirGapStateStoreTests(AirGapPostgresFixture fixture)
{
@@ -43,133 +53,26 @@ public sealed class PostgresAirGapStateStoreTests : IAsyncLifetime
await _dataSource.DisposeAsync();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAsync_ReturnsDefaultStateForNewTenant()
private static AirGapState CreateState(
string tenantId,
string stateId,
bool sealed_ = false,
string? policyHash = null,
IReadOnlyDictionary<string, StalenessBudget>? budgets = null,
TimeAnchor? timeAnchor = null)
{
// Act
var state = await _store.GetAsync(_tenantId);
// Assert
state.Should().NotBeNull();
state.TenantId.Should().Be(_tenantId);
state.Sealed.Should().BeFalse();
state.PolicyHash.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SetAndGet_RoundTripsState()
{
// Arrange
var timeAnchor = new TimeAnchor(
DateTimeOffset.UtcNow,
"tsa.example.com",
"RFC3161",
"sha256:fingerprint123",
"sha256:tokendigest456");
var state = new AirGapState
return new AirGapState
{
Id = Guid.NewGuid().ToString("N"),
TenantId = _tenantId,
Sealed = true,
PolicyHash = "sha256:policy789",
TimeAnchor = timeAnchor,
LastTransitionAt = DateTimeOffset.UtcNow,
Id = stateId,
TenantId = tenantId,
Sealed = sealed_,
PolicyHash = policyHash,
TimeAnchor = timeAnchor ?? TimeAnchor.Unknown,
LastTransitionAt = TransitionTime,
StalenessBudget = new StalenessBudget(1800, 3600),
DriftBaselineSeconds = 5,
ContentBudgets = new Dictionary<string, StalenessBudget>
{
["advisories"] = new StalenessBudget(7200, 14400),
["vex"] = new StalenessBudget(3600, 7200)
}
ContentBudgets = budgets ??
new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase)
};
// Act
await _store.SetAsync(state);
var fetched = await _store.GetAsync(_tenantId);
// Assert
fetched.Should().NotBeNull();
fetched.Sealed.Should().BeTrue();
fetched.PolicyHash.Should().Be("sha256:policy789");
fetched.TimeAnchor.Source.Should().Be("tsa.example.com");
fetched.TimeAnchor.Format.Should().Be("RFC3161");
fetched.StalenessBudget.WarningSeconds.Should().Be(1800);
fetched.StalenessBudget.BreachSeconds.Should().Be(3600);
fetched.DriftBaselineSeconds.Should().Be(5);
fetched.ContentBudgets.Should().HaveCount(2);
fetched.ContentBudgets["advisories"].WarningSeconds.Should().Be(7200);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SetAsync_UpdatesExistingState()
{
// Arrange
var state1 = new AirGapState
{
Id = Guid.NewGuid().ToString("N"),
TenantId = _tenantId,
Sealed = false,
TimeAnchor = TimeAnchor.Unknown,
StalenessBudget = StalenessBudget.Default
};
var state2 = new AirGapState
{
Id = state1.Id,
TenantId = _tenantId,
Sealed = true,
PolicyHash = "sha256:updated",
TimeAnchor = new TimeAnchor(DateTimeOffset.UtcNow, "updated-source", "rfc3161", "", ""),
LastTransitionAt = DateTimeOffset.UtcNow,
StalenessBudget = new StalenessBudget(600, 1200)
};
// Act
await _store.SetAsync(state1);
await _store.SetAsync(state2);
var fetched = await _store.GetAsync(_tenantId);
// Assert
fetched.Sealed.Should().BeTrue();
fetched.PolicyHash.Should().Be("sha256:updated");
fetched.TimeAnchor.Source.Should().Be("updated-source");
fetched.StalenessBudget.WarningSeconds.Should().Be(600);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SetAsync_PersistsContentBudgets()
{
// Arrange
var state = new AirGapState
{
Id = Guid.NewGuid().ToString("N"),
TenantId = _tenantId,
TimeAnchor = TimeAnchor.Unknown,
StalenessBudget = StalenessBudget.Default,
ContentBudgets = new Dictionary<string, StalenessBudget>
{
["advisories"] = new StalenessBudget(3600, 7200),
["vex"] = new StalenessBudget(1800, 3600),
["policy"] = new StalenessBudget(900, 1800)
}
};
// Act
await _store.SetAsync(state);
var fetched = await _store.GetAsync(_tenantId);
// Assert
fetched.ContentBudgets.Should().HaveCount(3);
fetched.ContentBudgets.Should().ContainKey("advisories");
fetched.ContentBudgets.Should().ContainKey("vex");
fetched.ContentBudgets.Should().ContainKey("policy");
}
}

View File

@@ -0,0 +1,43 @@
using FluentAssertions;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class PostgresBundleVersionStoreTests
{
[Fact]
[Intent(TestIntents.Operational, "Allows explicit force activation of older versions.")]
public async Task UpsertAsync_AllowsForceActivateOlderVersionAsync()
{
var tenantId = "tenant-bundle-06";
var current = CreateRecord(
tenantId,
"advisory",
major: 2,
minor: 0,
patch: 0,
createdAt: BaseCreatedAt,
activatedAt: BaseActivatedAt);
await _store.UpsertAsync(current);
var forced = CreateRecord(
tenantId,
"advisory",
major: 1,
minor: 0,
patch: 0,
createdAt: BaseCreatedAt.AddHours(1),
activatedAt: BaseActivatedAt.AddHours(1),
wasForceActivated: true,
forceActivateReason: "manual-override");
await _store.UpsertAsync(forced);
var currentAfter = await _store.GetCurrentAsync(tenantId, "advisory");
currentAfter.Should().NotBeNull();
currentAfter!.VersionString.Should().Be("1.0.0");
currentAfter.WasForceActivated.Should().BeTrue();
currentAfter.ForceActivateReason.Should().Be("manual-override");
}
}

View File

@@ -0,0 +1,79 @@
using FluentAssertions;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class PostgresBundleVersionStoreTests
{
[Fact]
[Intent(TestIntents.Operational, "Records activation history for bundle versions.")]
[Trait("Category", TestCategories.QueryDeterminism)]
public async Task UpsertAsync_RecordsHistoryAsync()
{
var tenantId = "tenant-bundle-04";
var v1 = CreateRecord(
tenantId,
"advisory",
major: 1,
minor: 0,
patch: 0,
createdAt: BaseCreatedAt,
activatedAt: BaseActivatedAt);
var v2 = CreateRecord(
tenantId,
"advisory",
major: 1,
minor: 1,
patch: 0,
createdAt: BaseCreatedAt.AddHours(1),
activatedAt: BaseActivatedAt.AddHours(1));
await _store.UpsertAsync(v1);
await _store.UpsertAsync(v2);
var history = await _store.GetHistoryAsync(tenantId, "advisory");
history.Should().HaveCount(2);
history[0].VersionString.Should().Be("1.1.0");
history[1].VersionString.Should().Be("1.0.0");
}
[Fact]
[Intent(TestIntents.Operational, "Applies history limits for bundle version queries.")]
public async Task GetHistoryAsync_RespectsLimitAsync()
{
var tenantId = "tenant-bundle-05";
await _store.UpsertAsync(CreateRecord(
tenantId,
"advisory",
major: 1,
minor: 0,
patch: 0,
createdAt: BaseCreatedAt,
activatedAt: BaseActivatedAt));
await _store.UpsertAsync(CreateRecord(
tenantId,
"advisory",
major: 1,
minor: 1,
patch: 0,
createdAt: BaseCreatedAt.AddHours(1),
activatedAt: BaseActivatedAt.AddHours(1)));
await _store.UpsertAsync(CreateRecord(
tenantId,
"advisory",
major: 1,
minor: 2,
patch: 0,
createdAt: BaseCreatedAt.AddHours(2),
activatedAt: BaseActivatedAt.AddHours(2)));
var history = await _store.GetHistoryAsync(tenantId, "advisory", limit: 2);
history.Should().HaveCount(2);
history[0].VersionString.Should().Be("1.2.0");
history[1].VersionString.Should().Be("1.1.0");
}
}

View File

@@ -0,0 +1,17 @@
using FluentAssertions;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class PostgresBundleVersionStoreTests
{
[Fact]
[Intent(TestIntents.Operational, "Returns null when no bundle version exists for a tenant.")]
public async Task GetCurrentAsync_ReturnsNullWhenMissingAsync()
{
var result = await _store.GetCurrentAsync("tenant-bundle-01", "advisory");
result.Should().BeNull();
}
}

View File

@@ -0,0 +1,60 @@
using FluentAssertions;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
public sealed partial class PostgresBundleVersionStoreTests
{
[Fact]
[Intent(TestIntents.Operational, "Persists the current bundle version for a tenant.")]
public async Task UpsertAsync_PersistsCurrentVersionAsync()
{
var tenantId = "tenant-bundle-02";
var record = CreateRecord(
tenantId,
"advisory",
major: 1,
minor: 0,
patch: 0,
createdAt: BaseCreatedAt,
activatedAt: BaseActivatedAt);
await _store.UpsertAsync(record);
var current = await _store.GetCurrentAsync(tenantId, "advisory");
current.Should().NotBeNull();
current!.VersionString.Should().Be("1.0.0");
current.BundleDigest.Should().Be("sha256:bundle-1-0-0");
current.ActivatedAt.Should().Be(BaseActivatedAt);
}
[Fact]
[Intent(TestIntents.Operational, "Rejects non-monotonic bundle version updates.")]
public async Task UpsertAsync_RejectsNonMonotonicVersionAsync()
{
var tenantId = "tenant-bundle-03";
var current = CreateRecord(
tenantId,
"advisory",
major: 2,
minor: 0,
patch: 0,
createdAt: BaseCreatedAt,
activatedAt: BaseActivatedAt);
await _store.UpsertAsync(current);
var older = CreateRecord(
tenantId,
"advisory",
major: 1,
minor: 0,
patch: 0,
createdAt: BaseCreatedAt.AddHours(1),
activatedAt: BaseActivatedAt.AddHours(1));
var act = async () => await _store.UpsertAsync(older);
await act.Should().ThrowAsync<InvalidOperationException>();
}
}

View File

@@ -0,0 +1,82 @@
using System;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.AirGap.Persistence.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
[Collection(AirGapPostgresCollection.Name)]
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Persistence)]
public sealed partial class PostgresBundleVersionStoreTests : IAsyncLifetime
{
private static readonly DateTimeOffset BaseCreatedAt =
new(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset BaseActivatedAt =
new(2025, 2, 1, 0, 10, 0, TimeSpan.Zero);
private readonly AirGapPostgresFixture _fixture;
private readonly PostgresBundleVersionStore _store;
private readonly AirGapDataSource _dataSource;
public PostgresBundleVersionStoreTests(AirGapPostgresFixture fixture)
{
_fixture = fixture;
var options = Options.Create(new PostgresOptions
{
ConnectionString = fixture.ConnectionString,
SchemaName = fixture.SchemaName,
AutoMigrate = false
});
_dataSource = new AirGapDataSource(options, NullLogger<AirGapDataSource>.Instance);
_store = new PostgresBundleVersionStore(_dataSource, NullLogger<PostgresBundleVersionStore>.Instance);
}
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync();
}
private static BundleVersionRecord CreateRecord(
string tenantId,
string bundleType,
int major,
int minor,
int patch,
DateTimeOffset createdAt,
DateTimeOffset activatedAt,
string? prerelease = null,
bool wasForceActivated = false,
string? forceActivateReason = null)
{
var versionString = prerelease is null
? $"{major}.{minor}.{patch}"
: $"{major}.{minor}.{patch}-{prerelease}";
return new BundleVersionRecord(
TenantId: tenantId,
BundleType: bundleType,
VersionString: versionString,
Major: major,
Minor: minor,
Patch: patch,
Prerelease: prerelease,
BundleCreatedAt: createdAt,
BundleDigest: $"sha256:bundle-{major}-{minor}-{patch}",
ActivatedAt: activatedAt,
WasForceActivated: wasForceActivated,
ForceActivateReason: forceActivateReason);
}
}

View File

@@ -1,10 +1,8 @@
# AirGap Persistence Tests Task Board
# StellaOps.AirGap.Persistence.Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0029-M | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
| AUDIT-0029-T | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
| AUDIT-0029-A | DONE | Waived (test project; revalidated 2026-01-06). |
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/__Tests/StellaOps.AirGap.Persistence.Tests/StellaOps.AirGap.Persistence.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,22 @@
// <copyright file="AirGapBundleDsseSignerTests.Errors.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class AirGapBundleDsseSignerTests
{
[Fact]
public async Task SignAsync_WithMissingSecret_ThrowsInvalidOperationAsync()
{
var signer = CreateSigner("hmac", secretBase64: null);
var bundle = CreateTestBundle();
var act = async () => await signer.SignAsync(bundle);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*SecretBase64*");
}
}

View File

@@ -0,0 +1,62 @@
// <copyright file="AirGapBundleDsseSignerTests.Sign.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class AirGapBundleDsseSignerTests
{
[Fact]
public async Task SignAsync_WhenDisabled_ReturnsNullAsync()
{
var signer = CreateSigner("none");
var bundle = CreateTestBundle();
var result = await signer.SignAsync(bundle);
result.Should().BeNull();
signer.IsEnabled.Should().BeFalse();
}
[Fact]
public async Task SignAsync_WhenEnabled_ReturnsValidSignatureAsync()
{
var signer = CreateSigner("hmac", TestSecretBase64, "test-key");
var bundle = CreateTestBundle();
var result = await signer.SignAsync(bundle);
result.Should().NotBeNull();
result!.KeyId.Should().Be("test-key");
result.Signature.Should().NotBeEmpty();
result.SignatureBase64.Should().NotBeNullOrWhiteSpace();
signer.IsEnabled.Should().BeTrue();
}
[Fact]
public async Task SignAsync_DeterministicForSameInputAsync()
{
var signer = CreateSigner("hmac", TestSecretBase64);
var bundle = CreateTestBundle();
var result1 = await signer.SignAsync(bundle);
var result2 = await signer.SignAsync(bundle);
result1!.SignatureBase64.Should().Be(result2!.SignatureBase64);
}
[Fact]
public async Task SignAsync_DifferentForDifferentManifestAsync()
{
var signer = CreateSigner("hmac", TestSecretBase64);
var bundle1 = CreateTestBundle(manifestDigest: "sha256:aaa");
var bundle2 = CreateTestBundle(manifestDigest: "sha256:bbb");
var result1 = await signer.SignAsync(bundle1);
var result2 = await signer.SignAsync(bundle2);
result1!.SignatureBase64.Should().NotBe(result2!.SignatureBase64);
}
}

View File

@@ -0,0 +1,76 @@
// <copyright file="AirGapBundleDsseSignerTests.Verify.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Services;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class AirGapBundleDsseSignerTests
{
[Fact]
public async Task VerifyAsync_WhenDisabled_ReturnsSigningDisabledAsync()
{
var signer = CreateSigner("none");
var bundle = CreateTestBundle();
var result = await signer.VerifyAsync(bundle);
result.Should().Be(AirGapBundleVerificationResult.SigningDisabled);
}
[Fact]
public async Task VerifyAsync_WhenNoSignature_ReturnsMissingSignatureAsync()
{
var signer = CreateSigner("hmac", TestSecretBase64);
var bundle = CreateTestBundle(signature: null);
var result = await signer.VerifyAsync(bundle);
result.Should().Be(AirGapBundleVerificationResult.MissingSignature);
}
[Fact]
public async Task VerifyAsync_WithValidSignature_ReturnsValidAsync()
{
var signer = CreateSigner("hmac", TestSecretBase64);
var bundle = CreateTestBundle();
var signResult = await signer.SignAsync(bundle);
var signedBundle = bundle with { Signature = signResult!.SignatureBase64, SignedBy = signResult.KeyId };
var verifyResult = await signer.VerifyAsync(signedBundle);
verifyResult.Should().Be(AirGapBundleVerificationResult.Valid);
}
[Fact]
public async Task VerifyAsync_WithTamperedSignature_ReturnsInvalidAsync()
{
var signer = CreateSigner("hmac", TestSecretBase64);
var bundle = CreateTestBundle();
var signResult = await signer.SignAsync(bundle);
var tamperedBundle = bundle with
{
Signature = signResult!.SignatureBase64,
ManifestDigest = "sha256:tampered"
};
var verifyResult = await signer.VerifyAsync(tamperedBundle);
verifyResult.Should().Be(AirGapBundleVerificationResult.InvalidSignature);
}
[Fact]
public async Task VerifyAsync_WithInvalidBase64Signature_ReturnsInvalidAsync()
{
var signer = CreateSigner("hmac", TestSecretBase64);
var bundle = CreateTestBundle(signature: "not-valid-base64!!!");
var verifyResult = await signer.VerifyAsync(bundle);
verifyResult.Should().Be(AirGapBundleVerificationResult.InvalidSignature);
}
}

View File

@@ -1,227 +1,42 @@
// <copyright file="AirGapBundleDsseSignerTests.cs" company="StellaOps">
// <copyright file="AirGapBundleDsseSignerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using System.Security.Cryptography;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
/// <summary>
/// Unit tests for <see cref="AirGapBundleDsseSigner"/>.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class AirGapBundleDsseSignerTests
[Intent(TestIntents.Safety, "Validates deterministic DSSE signing and verification.")]
public sealed partial class AirGapBundleDsseSignerTests
{
private static readonly string TestSecretBase64 = Convert.ToBase64String(
RandomNumberGenerator.GetBytes(32));
[Fact]
public async Task SignAsync_WhenDisabled_ReturnsNull()
private static readonly byte[] TestSecret = new byte[]
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions { Mode = "none" });
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20
};
// Act
var result = await signer.SignAsync(bundle);
private static readonly string TestSecretBase64 = Convert.ToBase64String(TestSecret);
private static readonly DateTimeOffset DefaultCreatedAt =
new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
// Assert
result.Should().BeNull();
signer.IsEnabled.Should().BeFalse();
}
[Fact]
public async Task SignAsync_WhenEnabled_ReturnsValidSignature()
private static AirGapBundleDsseSigner CreateSigner(string mode, string? secretBase64 = null, string? keyId = null)
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64,
KeyId = "test-key"
Mode = mode,
SecretBase64 = secretBase64,
KeyId = keyId ?? "test-key"
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Act
var result = await signer.SignAsync(bundle);
// Assert
result.Should().NotBeNull();
result!.KeyId.Should().Be("test-key");
result.Signature.Should().NotBeEmpty();
result.SignatureBase64.Should().NotBeNullOrWhiteSpace();
signer.IsEnabled.Should().BeTrue();
}
[Fact]
public async Task SignAsync_DeterministicForSameInput()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Act
var result1 = await signer.SignAsync(bundle);
var result2 = await signer.SignAsync(bundle);
// Assert
result1!.SignatureBase64.Should().Be(result2!.SignatureBase64);
}
[Fact]
public async Task SignAsync_DifferentForDifferentManifest()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle1 = CreateTestBundle(manifestDigest: "sha256:aaa");
var bundle2 = CreateTestBundle(manifestDigest: "sha256:bbb");
// Act
var result1 = await signer.SignAsync(bundle1);
var result2 = await signer.SignAsync(bundle2);
// Assert
result1!.SignatureBase64.Should().NotBe(result2!.SignatureBase64);
}
[Fact]
public async Task VerifyAsync_WhenDisabled_ReturnsSigningDisabled()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions { Mode = "none" });
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Act
var result = await signer.VerifyAsync(bundle);
// Assert
result.Should().Be(AirGapBundleVerificationResult.SigningDisabled);
}
[Fact]
public async Task VerifyAsync_WhenNoSignature_ReturnsMissingSignature()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle(signature: null);
// Act
var result = await signer.VerifyAsync(bundle);
// Assert
result.Should().Be(AirGapBundleVerificationResult.MissingSignature);
}
[Fact]
public async Task VerifyAsync_WithValidSignature_ReturnsValid()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Sign the bundle first
var signResult = await signer.SignAsync(bundle);
var signedBundle = bundle with { Signature = signResult!.SignatureBase64, SignedBy = signResult.KeyId };
// Act
var verifyResult = await signer.VerifyAsync(signedBundle);
// Assert
verifyResult.Should().Be(AirGapBundleVerificationResult.Valid);
}
[Fact]
public async Task VerifyAsync_WithTamperedSignature_ReturnsInvalid()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Sign and then tamper
var signResult = await signer.SignAsync(bundle);
var tamperedBundle = bundle with
{
Signature = signResult!.SignatureBase64,
ManifestDigest = "sha256:tampered"
};
// Act
var verifyResult = await signer.VerifyAsync(tamperedBundle);
// Assert
verifyResult.Should().Be(AirGapBundleVerificationResult.InvalidSignature);
}
[Fact]
public async Task VerifyAsync_WithInvalidBase64Signature_ReturnsInvalid()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = TestSecretBase64
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle(signature: "not-valid-base64!!!");
// Act
var verifyResult = await signer.VerifyAsync(bundle);
// Assert
verifyResult.Should().Be(AirGapBundleVerificationResult.InvalidSignature);
}
[Fact]
public void SignAsync_WithMissingSecret_ThrowsInvalidOperation()
{
// Arrange
var options = Options.Create(new AirGapBundleDsseOptions
{
Mode = "hmac",
SecretBase64 = null
});
var signer = new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
var bundle = CreateTestBundle();
// Act & Assert
var act = async () => await signer.SignAsync(bundle);
act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*SecretBase64*");
return new AirGapBundleDsseSigner(options, NullLogger<AirGapBundleDsseSigner>.Instance);
}
private static AirGapBundle CreateTestBundle(
@@ -232,7 +47,7 @@ public sealed class AirGapBundleDsseSignerTests
{
BundleId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
TenantId = "test-tenant",
CreatedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z"),
CreatedAt = DefaultCreatedAt,
CreatedByNodeId = "test-node",
JobLogs = new List<NodeJobLog>(),
ManifestDigest = manifestDigest ?? "sha256:abc123def456",

View File

@@ -0,0 +1,45 @@
// <copyright file="AirGapSyncServiceCollectionExtensionsTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AirGap.Sync.Services;
using StellaOps.AirGap.Sync.Stores;
using StellaOps.Determinism;
using StellaOps.HybridLogicalClock;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
[Trait("Category", TestCategories.Unit)]
[Intent(TestIntents.Operational, "Ensures AirGap sync registrations include core services and time provider.")]
public sealed class AirGapSyncServiceCollectionExtensionsTests
{
[Fact]
public void AddAirGapSyncServices_RegistersExpectedServices()
{
var services = new TestServiceCollection();
services.AddAirGapSyncServices("node-a");
services.Any(sd => sd.ServiceType == typeof(TimeProvider)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IHybridLogicalClock)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IHlcStateStore)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IConflictResolver)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IHlcMergeService)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IAirGapBundleImporter)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IAirGapBundleExporter)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IOfflineJobLogStore)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IOfflineHlcManager)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IGuidProvider)).Should().BeTrue();
services.Any(sd => sd.ServiceType == typeof(IAirGapBundleDsseSigner)).Should().BeTrue();
}
private sealed class TestServiceCollection : List<ServiceDescriptor>, IServiceCollection
{
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="ConflictResolverTests.DuplicateTimestamp.Multi.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class ConflictResolverTests
{
[Fact]
public void Resolve_ThreeEntriesSamePayload_TakesEarliestDropsTwo()
{
var jobId = Guid.Parse("44444444-4444-4444-4444-444444444444");
var payloadHash = CreatePayloadHash(0xCC);
var entryA = CreateEntryWithPayloadHash("node-a", 150, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash);
var entryC = CreateEntryWithPayloadHash("node-c", 200, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB),
("node-c", entryC)
};
var result = _sut.Resolve(jobId, conflicting);
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entryB);
result.DroppedEntries.Should().HaveCount(2);
}
}

View File

@@ -0,0 +1,53 @@
// <copyright file="ConflictResolverTests.DuplicateTimestamp.Tiebreakers.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class ConflictResolverTests
{
[Fact]
public void Resolve_SamePhysicalTime_UsesLogicalCounter()
{
var jobId = Guid.Parse("55555555-5555-5555-5555-555555555555");
var payloadHash = CreatePayloadHash(0xDD);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 2, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 100, 1, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
var result = _sut.Resolve(jobId, conflicting);
result.SelectedEntry.Should().Be(entryB);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryA);
}
[Fact]
public void Resolve_SamePhysicalTimeAndCounter_UsesNodeId()
{
var jobId = Guid.Parse("66666666-6666-6666-6666-666666666666");
var payloadHash = CreatePayloadHash(0xEE);
var entryA = CreateEntryWithPayloadHash("alpha-node", 100, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("beta-node", 100, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("beta-node", entryB),
("alpha-node", entryA)
};
var result = _sut.Resolve(jobId, conflicting);
result.SelectedEntry.Should().Be(entryA);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryB);
}
}

View File

@@ -0,0 +1,57 @@
// <copyright file="ConflictResolverTests.DuplicateTimestamp.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class ConflictResolverTests
{
[Fact]
public void Resolve_TwoEntriesSamePayload_TakesEarliest()
{
var jobId = Guid.Parse("22222222-2222-2222-2222-222222222222");
var payloadHash = CreatePayloadHash(0xAA);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
var result = _sut.Resolve(jobId, conflicting);
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entryA);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryB);
}
[Fact]
public void Resolve_TwoEntriesSamePayload_TakesEarliest_WhenSecondComesFirst()
{
var jobId = Guid.Parse("33333333-3333-3333-3333-333333333333");
var payloadHash = CreatePayloadHash(0xBB);
var entryA = CreateEntryWithPayloadHash("node-a", 200, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
var result = _sut.Resolve(jobId, conflicting);
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entryB);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryA);
}
}

View File

@@ -0,0 +1,34 @@
// <copyright file="ConflictResolverTests.EdgeCases.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class ConflictResolverTests
{
[Fact]
public void Resolve_NullConflicting_ThrowsArgumentNullException()
{
var jobId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var act = () => _sut.Resolve(jobId, null!);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("conflicting");
}
[Fact]
public void Resolve_EmptyConflicting_ThrowsArgumentException()
{
var jobId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>();
var act = () => _sut.Resolve(jobId, conflicting);
act.Should().Throw<ArgumentException>()
.WithParameterName("conflicting");
}
}

View File

@@ -0,0 +1,55 @@
// <copyright file="ConflictResolverTests.Helpers.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using StellaOps.AirGap.Sync.Models;
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class ConflictResolverTests
{
private static byte[] CreatePayloadHash(byte prefix)
{
var hash = new byte[32];
hash[0] = prefix;
return hash;
}
private static OfflineJobLogEntry CreateEntry(
string nodeId,
long physicalTime,
int logicalCounter,
Guid jobId)
{
var payloadHash = new byte[32];
jobId.ToByteArray().CopyTo(payloadHash, 0);
return CreateEntryWithPayloadHash(nodeId, physicalTime, logicalCounter, jobId, payloadHash);
}
private static OfflineJobLogEntry CreateEntryWithPayloadHash(
string nodeId,
long physicalTime,
int logicalCounter,
Guid jobId,
byte[] payloadHash)
{
var hlc = new HlcTimestamp
{
PhysicalTime = physicalTime,
NodeId = nodeId,
LogicalCounter = logicalCounter
};
return new OfflineJobLogEntry
{
NodeId = nodeId,
THlc = hlc,
JobId = jobId,
Payload = $"{{\"id\":\"{jobId}\"}}",
PayloadHash = payloadHash,
Link = new byte[32],
EnqueuedAt = FixedEnqueuedAt
};
}
}

View File

@@ -0,0 +1,85 @@
// <copyright file="ConflictResolverTests.PayloadMismatch.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class ConflictResolverTests
{
[Fact]
public void Resolve_DifferentPayloads_ReturnsError()
{
var jobId = Guid.Parse("77777777-7777-7777-7777-777777777777");
var payloadHashA = CreatePayloadHash(0x01);
var payloadHashB = CreatePayloadHash(0x02);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHashA);
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, payloadHashB);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
var result = _sut.Resolve(jobId, conflicting);
result.Type.Should().Be(ConflictType.PayloadMismatch);
result.Resolution.Should().Be(ResolutionStrategy.Error);
result.Error.Should().NotBeNullOrEmpty();
result.Error.Should().Contain(jobId.ToString());
result.Error.Should().Contain("conflicting payloads");
result.SelectedEntry.Should().BeNull();
result.DroppedEntries.Should().BeNull();
}
[Fact]
public void Resolve_ThreeDifferentPayloads_ReturnsError()
{
var jobId = Guid.Parse("88888888-8888-8888-8888-888888888888");
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, CreatePayloadHash(0x01));
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, CreatePayloadHash(0x02));
var entryC = CreateEntryWithPayloadHash("node-c", 300, 0, jobId, CreatePayloadHash(0x03));
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB),
("node-c", entryC)
};
var result = _sut.Resolve(jobId, conflicting);
result.Type.Should().Be(ConflictType.PayloadMismatch);
result.Resolution.Should().Be(ResolutionStrategy.Error);
}
[Fact]
public void Resolve_TwoSameOneUnique_ReturnsError()
{
var jobId = Guid.Parse("99999999-9999-9999-9999-999999999999");
var sharedPayload = CreatePayloadHash(0xAA);
var uniquePayload = CreatePayloadHash(0xBB);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, sharedPayload);
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, sharedPayload);
var entryC = CreateEntryWithPayloadHash("node-c", 300, 0, jobId, uniquePayload);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB),
("node-c", entryC)
};
var result = _sut.Resolve(jobId, conflicting);
result.Type.Should().Be(ConflictType.PayloadMismatch);
result.Resolution.Should().Be(ResolutionStrategy.Error);
}
}

View File

@@ -0,0 +1,30 @@
// <copyright file="ConflictResolverTests.SingleEntry.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class ConflictResolverTests
{
[Fact]
public void Resolve_SingleEntry_ReturnsDuplicateTimestampWithTakeEarliest()
{
var jobId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var entry = CreateEntry("node-a", 100, 0, jobId);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entry)
};
var result = _sut.Resolve(jobId, conflicting);
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entry);
result.DroppedEntries.Should().BeEmpty();
result.Error.Should().BeNull();
}
}

View File

@@ -1,342 +1,25 @@
// <copyright file="ConflictResolverTests.cs" company="StellaOps">
// <copyright file="ConflictResolverTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
using StellaOps.HybridLogicalClock;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
/// <summary>
/// Unit tests for <see cref="ConflictResolver"/>.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class ConflictResolverTests
[Intent(TestIntents.Operational, "Validates conflict resolution ordering and payload checks.")]
public sealed partial class ConflictResolverTests
{
private static readonly DateTimeOffset FixedEnqueuedAt =
new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
private readonly ConflictResolver _sut;
public ConflictResolverTests()
{
_sut = new ConflictResolver(NullLogger<ConflictResolver>.Instance);
}
#region Single Entry Tests
[Fact]
public void Resolve_SingleEntry_ReturnsDuplicateTimestampWithTakeEarliest()
{
// Arrange
var jobId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var entry = CreateEntry("node-a", 100, 0, jobId);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entry)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entry);
result.DroppedEntries.Should().BeEmpty();
result.Error.Should().BeNull();
}
#endregion
#region Duplicate Timestamp Tests (Same Payload)
[Fact]
public void Resolve_TwoEntriesSamePayload_TakesEarliest()
{
// Arrange
var jobId = Guid.Parse("22222222-2222-2222-2222-222222222222");
var payloadHash = CreatePayloadHash(0xAA);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entryA);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryB);
}
[Fact]
public void Resolve_TwoEntriesSamePayload_TakesEarliest_WhenSecondComesFirst()
{
// Arrange - Earlier entry is second in list
var jobId = Guid.Parse("33333333-3333-3333-3333-333333333333");
var payloadHash = CreatePayloadHash(0xBB);
var entryA = CreateEntryWithPayloadHash("node-a", 200, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash); // Earlier
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert - Should take entryB (earlier)
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entryB);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryA);
}
[Fact]
public void Resolve_ThreeEntriesSamePayload_TakesEarliestDropsTwo()
{
// Arrange
var jobId = Guid.Parse("44444444-4444-4444-4444-444444444444");
var payloadHash = CreatePayloadHash(0xCC);
var entryA = CreateEntryWithPayloadHash("node-a", 150, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash); // Earliest
var entryC = CreateEntryWithPayloadHash("node-c", 200, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB),
("node-c", entryC)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entryB);
result.DroppedEntries.Should().HaveCount(2);
}
[Fact]
public void Resolve_SamePhysicalTime_UsesLogicalCounter()
{
// Arrange
var jobId = Guid.Parse("55555555-5555-5555-5555-555555555555");
var payloadHash = CreatePayloadHash(0xDD);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 2, jobId, payloadHash); // Higher counter
var entryB = CreateEntryWithPayloadHash("node-b", 100, 1, jobId, payloadHash); // Earlier
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.SelectedEntry.Should().Be(entryB); // Lower logical counter
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryA);
}
[Fact]
public void Resolve_SamePhysicalTimeAndCounter_UsesNodeId()
{
// Arrange
var jobId = Guid.Parse("66666666-6666-6666-6666-666666666666");
var payloadHash = CreatePayloadHash(0xEE);
var entryA = CreateEntryWithPayloadHash("alpha-node", 100, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("beta-node", 100, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("beta-node", entryB),
("alpha-node", entryA)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert - "alpha-node" < "beta-node" alphabetically
result.SelectedEntry.Should().Be(entryA);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryB);
}
#endregion
#region Payload Mismatch Tests
[Fact]
public void Resolve_DifferentPayloads_ReturnsError()
{
// Arrange
var jobId = Guid.Parse("77777777-7777-7777-7777-777777777777");
var payloadHashA = CreatePayloadHash(0x01);
var payloadHashB = CreatePayloadHash(0x02);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHashA);
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, payloadHashB);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.PayloadMismatch);
result.Resolution.Should().Be(ResolutionStrategy.Error);
result.Error.Should().NotBeNullOrEmpty();
result.Error.Should().Contain(jobId.ToString());
result.Error.Should().Contain("conflicting payloads");
result.SelectedEntry.Should().BeNull();
result.DroppedEntries.Should().BeNull();
}
[Fact]
public void Resolve_ThreeDifferentPayloads_ReturnsError()
{
// Arrange
var jobId = Guid.Parse("88888888-8888-8888-8888-888888888888");
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, CreatePayloadHash(0x01));
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, CreatePayloadHash(0x02));
var entryC = CreateEntryWithPayloadHash("node-c", 300, 0, jobId, CreatePayloadHash(0x03));
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB),
("node-c", entryC)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.PayloadMismatch);
result.Resolution.Should().Be(ResolutionStrategy.Error);
}
[Fact]
public void Resolve_TwoSameOneUnique_ReturnsError()
{
// Arrange - 2 entries with same payload, 1 with different
var jobId = Guid.Parse("99999999-9999-9999-9999-999999999999");
var sharedPayload = CreatePayloadHash(0xAA);
var uniquePayload = CreatePayloadHash(0xBB);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, sharedPayload);
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, sharedPayload);
var entryC = CreateEntryWithPayloadHash("node-c", 300, 0, jobId, uniquePayload);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB),
("node-c", entryC)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert - Should be error due to different payloads
result.Type.Should().Be(ConflictType.PayloadMismatch);
result.Resolution.Should().Be(ResolutionStrategy.Error);
}
#endregion
#region Edge Cases
[Fact]
public void Resolve_NullConflicting_ThrowsArgumentNullException()
{
// Arrange
var jobId = Guid.NewGuid();
// Act & Assert
var act = () => _sut.Resolve(jobId, null!);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("conflicting");
}
[Fact]
public void Resolve_EmptyConflicting_ThrowsArgumentException()
{
// Arrange
var jobId = Guid.NewGuid();
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>();
// Act & Assert
var act = () => _sut.Resolve(jobId, conflicting);
act.Should().Throw<ArgumentException>()
.WithParameterName("conflicting");
}
#endregion
#region Helper Methods
private static byte[] CreatePayloadHash(byte prefix)
{
var hash = new byte[32];
hash[0] = prefix;
return hash;
}
private static OfflineJobLogEntry CreateEntry(string nodeId, long physicalTime, int logicalCounter, Guid jobId)
{
var payloadHash = new byte[32];
jobId.ToByteArray().CopyTo(payloadHash, 0);
return CreateEntryWithPayloadHash(nodeId, physicalTime, logicalCounter, jobId, payloadHash);
}
private static OfflineJobLogEntry CreateEntryWithPayloadHash(
string nodeId, long physicalTime, int logicalCounter, Guid jobId, byte[] payloadHash)
{
var hlc = new HlcTimestamp
{
PhysicalTime = physicalTime,
NodeId = nodeId,
LogicalCounter = logicalCounter
};
return new OfflineJobLogEntry
{
NodeId = nodeId,
THlc = hlc,
JobId = jobId,
Payload = $"{{\"id\":\"{jobId}\"}}",
PayloadHash = payloadHash,
Link = new byte[32],
EnqueuedAt = DateTimeOffset.UtcNow
};
}
#endregion
}

View File

@@ -0,0 +1,36 @@
// <copyright file="FileBasedJobSyncTransportTests.List.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Tests.TestUtilities;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class FileBasedJobSyncTransportTests
{
[Fact]
public async Task ListAvailableBundlesAsync_ReturnsBundlesOrderedByCreatedAt()
{
using var outputRoot = new TempDirectory("airgap-sync-out");
using var inputRoot = new TempDirectory("airgap-sync-in");
var exporter = new StubBundleExporter();
var importer = new StubBundleImporter();
var sut = CreateTransport(outputRoot, inputRoot, exporter, importer);
var firstId = Guid.Parse("55555555-5555-5555-5555-555555555555");
var secondId = Guid.Parse("44444444-4444-4444-4444-444444444444");
var firstCreated = new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero);
var secondCreated = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
WriteBundleJson(inputRoot.Path, firstId, firstCreated, 2);
WriteBundleJson(inputRoot.Path, secondId, secondCreated, 1);
var result = await sut.ListAvailableBundlesAsync(inputRoot.Path);
result.Should().HaveCount(2);
result[0].BundleId.Should().Be(secondId);
result[0].EntryCount.Should().Be(1);
result[1].BundleId.Should().Be(firstId);
result[1].EntryCount.Should().Be(2);
}
}

View File

@@ -0,0 +1,28 @@
// <copyright file="FileBasedJobSyncTransportTests.ReceiveRejectsEscaping.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Tests.TestUtilities;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class FileBasedJobSyncTransportTests
{
[Fact]
public async Task ReceiveBundleAsync_RejectsPathOutsideRoot()
{
using var outputRoot = new TempDirectory("airgap-sync-out");
using var inputRoot = new TempDirectory("airgap-sync-in");
var exporter = new StubBundleExporter();
var importer = new StubBundleImporter();
var sut = CreateTransport(outputRoot, inputRoot, exporter, importer);
var source = $"..{Path.DirectorySeparatorChar}escape";
var result = await sut.ReceiveBundleAsync(source);
result.Should().BeNull();
importer.ImportFromFileCalls.Should().Be(0);
importer.LastPath.Should().BeNull();
}
}

View File

@@ -0,0 +1,35 @@
// <copyright file="FileBasedJobSyncTransportTests.Send.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Tests.TestUtilities;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class FileBasedJobSyncTransportTests
{
[Fact]
public async Task SendBundleAsync_WritesBundleUnderOutputRoot()
{
using var outputRoot = new TempDirectory("airgap-sync-out");
using var inputRoot = new TempDirectory("airgap-sync-in");
var exporter = new StubBundleExporter { Payload = "{\"ok\":true}" };
var importer = new StubBundleImporter();
var bundleId = Guid.Parse("77777777-7777-7777-7777-777777777777");
var bundle = CreateBundle(bundleId);
var now = new DateTimeOffset(2026, 1, 7, 12, 30, 0, TimeSpan.Zero);
var sut = CreateTransport(outputRoot, inputRoot, exporter, importer, new FixedTimeProvider(now));
var result = await sut.SendBundleAsync(bundle, "exports");
result.Success.Should().BeTrue();
result.TransmittedAt.Should().Be(now);
result.Destination.Should().NotBeNullOrWhiteSpace();
exporter.LastPath.Should().NotBeNull();
exporter.LastPath!.Should().StartWith(outputRoot.Path);
exporter.LastPath.Should().Contain($"job-sync-{bundleId:N}.json");
result.Destination.Should().Be(exporter.LastPath);
result.SizeBytes.Should().Be(new FileInfo(exporter.LastPath).Length);
}
}

View File

@@ -0,0 +1,32 @@
// <copyright file="FileBasedJobSyncTransportTests.SendRejectsEscaping.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Tests.TestUtilities;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class FileBasedJobSyncTransportTests
{
[Fact]
public async Task SendBundleAsync_RejectsPathOutsideRoot()
{
using var outputRoot = new TempDirectory("airgap-sync-out");
using var inputRoot = new TempDirectory("airgap-sync-in");
var exporter = new StubBundleExporter();
var importer = new StubBundleImporter();
var bundle = CreateBundle(Guid.Parse("66666666-6666-6666-6666-666666666666"));
var now = new DateTimeOffset(2026, 1, 7, 12, 45, 0, TimeSpan.Zero);
var sut = CreateTransport(outputRoot, inputRoot, exporter, importer, new FixedTimeProvider(now));
var destination = $"..{Path.DirectorySeparatorChar}escape";
var result = await sut.SendBundleAsync(bundle, destination);
result.Success.Should().BeFalse();
result.Error.Should().Contain("escapes configured root");
result.TransmittedAt.Should().Be(now);
exporter.ExportToFileCalls.Should().Be(0);
exporter.LastPath.Should().BeNull();
}
}

View File

@@ -0,0 +1,88 @@
// <copyright file="FileBasedJobSyncTransportTests.Stubs.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
namespace StellaOps.AirGap.Sync.Tests;
internal sealed class StubBundleExporter : IAirGapBundleExporter
{
public string? LastPath { get; private set; }
public AirGapBundle? LastBundle { get; private set; }
public int ExportToFileCalls { get; private set; }
public string Payload { get; set; } = "{}";
public Task<AirGapBundle> ExportAsync(
string tenantId,
IReadOnlyList<string>? nodeIds = null,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new AirGapBundle
{
BundleId = Guid.Parse("99999999-9999-9999-9999-999999999999"),
TenantId = tenantId,
CreatedAt = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
CreatedByNodeId = "node-a",
JobLogs = Array.Empty<NodeJobLog>(),
ManifestDigest = "sha256:stub-export"
});
}
public Task ExportToFileAsync(
AirGapBundle bundle,
string outputPath,
CancellationToken cancellationToken = default)
{
ExportToFileCalls++;
LastBundle = bundle;
LastPath = outputPath;
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
File.WriteAllText(outputPath, Payload);
return Task.CompletedTask;
}
public Task<string> ExportToStringAsync(
AirGapBundle bundle,
CancellationToken cancellationToken = default)
{
LastBundle = bundle;
return Task.FromResult(Payload);
}
}
internal sealed class StubBundleImporter : IAirGapBundleImporter
{
public string? LastPath { get; private set; }
public int ImportFromFileCalls { get; private set; }
public AirGapBundle Result { get; set; } = new AirGapBundle
{
BundleId = Guid.Parse("88888888-8888-8888-8888-888888888888"),
TenantId = "test-tenant",
CreatedAt = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
CreatedByNodeId = "node-a",
JobLogs = Array.Empty<NodeJobLog>(),
ManifestDigest = "sha256:stub-import"
};
public Task<AirGapBundle> ImportFromFileAsync(
string inputPath,
CancellationToken cancellationToken = default)
{
ImportFromFileCalls++;
LastPath = inputPath;
return Task.FromResult(Result);
}
public BundleValidationResult Validate(AirGapBundle bundle)
=> new()
{
IsValid = true,
Issues = Array.Empty<string>()
};
public Task<AirGapBundle> ImportFromStringAsync(
string json,
CancellationToken cancellationToken = default)
=> Task.FromResult(Result);
}

View File

@@ -0,0 +1,71 @@
// <copyright file="FileBasedJobSyncTransportTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Tests.TestUtilities;
using StellaOps.AirGap.Sync.Transport;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
[Trait("Category", TestCategories.Unit)]
[Intent(TestIntents.Operational, "Validates file-based transport path resolution and timestamps.")]
public sealed partial class FileBasedJobSyncTransportTests
{
private static readonly DateTimeOffset FixedNow =
new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
private static AirGapBundle CreateBundle(Guid bundleId)
{
return new AirGapBundle
{
BundleId = bundleId,
TenantId = "test-tenant",
CreatedAt = FixedNow,
CreatedByNodeId = "node-a",
JobLogs = Array.Empty<NodeJobLog>(),
ManifestDigest = "sha256:test-bundle"
};
}
private static string WriteBundleJson(string directory, Guid bundleId, DateTimeOffset createdAt, int entryCount)
{
var entries = entryCount switch
{
0 => "[]",
1 => "[{}]",
2 => "[{},{}]",
_ => throw new ArgumentOutOfRangeException(nameof(entryCount))
};
var json = $"{{\"bundleId\":\"{bundleId:D}\",\"tenantId\":\"test-tenant\",\"createdByNodeId\":\"node-a\",\"createdAt\":\"{createdAt:O}\",\"jobLogs\":[{{\"entries\":{entries}}}]}}";
var path = Path.Combine(directory, $"job-sync-{bundleId:N}.json");
File.WriteAllText(path, json);
return path;
}
private static FileBasedJobSyncTransport CreateTransport(
TempDirectory outputRoot,
TempDirectory inputRoot,
StubBundleExporter exporter,
StubBundleImporter importer,
TimeProvider? timeProvider = null)
{
var options = Options.Create(new FileBasedJobSyncTransportOptions
{
OutputDirectory = outputRoot.Path,
InputDirectory = inputRoot.Path
});
return new FileBasedJobSyncTransport(
exporter,
importer,
options,
timeProvider ?? new FixedTimeProvider(FixedNow),
NullLogger<FileBasedJobSyncTransport>.Instance);
}
}

View File

@@ -0,0 +1,58 @@
// <copyright file="HlcMergeServiceTests.Determinism.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class HlcMergeServiceTests
{
[Fact]
public async Task MergeAsync_SameInput_ProducesSameOutputAsync()
{
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000")),
CreateEntry("node-a", 300, 0, Guid.Parse("aaaaaaaa-0003-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 200, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000")),
CreateEntry("node-b", 400, 0, Guid.Parse("bbbbbbbb-0004-0000-0000-000000000000"))
});
var result1 = await _sut.MergeAsync(new[] { nodeA, nodeB });
var result2 = await _sut.MergeAsync(new[] { nodeA, nodeB });
result1.MergedEntries.Should().HaveCount(result2.MergedEntries.Count);
for (var i = 0; i < result1.MergedEntries.Count; i++)
{
result1.MergedEntries[i].JobId.Should().Be(result2.MergedEntries[i].JobId);
result1.MergedEntries[i].THlc.Should().Be(result2.MergedEntries[i].THlc);
result1.MergedEntries[i].MergedLink.Should().BeEquivalentTo(result2.MergedEntries[i].MergedLink);
}
result1.MergedChainHead.Should().BeEquivalentTo(result2.MergedChainHead);
}
[Fact]
public async Task MergeAsync_InputOrderIndependent_ProducesSameOutputAsync()
{
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 200, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000"))
});
var result1 = await _sut.MergeAsync(new[] { nodeA, nodeB });
var result2 = await _sut.MergeAsync(new[] { nodeB, nodeA });
result1.MergedEntries.Select(e => e.JobId).Should()
.BeEquivalentTo(result2.MergedEntries.Select(e => e.JobId));
result1.MergedChainHead.Should().BeEquivalentTo(result2.MergedChainHead);
}
}

View File

@@ -0,0 +1,90 @@
// <copyright file="HlcMergeServiceTests.Duplicates.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class HlcMergeServiceTests
{
[Fact]
public async Task MergeAsync_DuplicateJobId_SamePayload_TakesEarliestAsync()
{
var jobId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
var payloadHash = new byte[32];
payloadHash[0] = 0xAA;
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHash)
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntryWithPayloadHash("node-b", 105, 0, jobId, payloadHash)
});
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
result.MergedEntries.Should().ContainSingle();
result.MergedEntries[0].SourceNodeId.Should().Be("node-a");
result.MergedEntries[0].THlc.PhysicalTime.Should().Be(100);
result.Duplicates.Should().ContainSingle();
result.Duplicates[0].JobId.Should().Be(jobId);
result.Duplicates[0].NodeId.Should().Be("node-b");
result.Duplicates[0].THlc.PhysicalTime.Should().Be(105);
}
[Fact]
public async Task MergeAsync_TriplicateJobId_SamePayload_TakesEarliestAsync()
{
var jobId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
var payloadHash = new byte[32];
payloadHash[0] = 0xBB;
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntryWithPayloadHash("node-a", 200, 0, jobId, payloadHash)
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash)
});
var nodeC = CreateNodeLog("node-c", new[]
{
CreateEntryWithPayloadHash("node-c", 150, 0, jobId, payloadHash)
});
var result = await _sut.MergeAsync(new[] { nodeA, nodeB, nodeC });
result.MergedEntries.Should().ContainSingle();
result.MergedEntries[0].SourceNodeId.Should().Be("node-b");
result.MergedEntries[0].THlc.PhysicalTime.Should().Be(100);
result.Duplicates.Should().HaveCount(2);
}
[Fact]
public async Task MergeAsync_DuplicateJobId_DifferentPayload_ThrowsErrorAsync()
{
var jobId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
var payloadHashA = new byte[32];
payloadHashA[0] = 0x01;
var payloadHashB = new byte[32];
payloadHashB[0] = 0x02;
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHashA)
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntryWithPayloadHash("node-b", 105, 0, jobId, payloadHashB)
});
var act = () => _sut.MergeAsync(new[] { nodeA, nodeB });
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*conflicting payloads*");
}
}

View File

@@ -0,0 +1,87 @@
// <copyright file="HlcMergeServiceTests.Helpers.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using StellaOps.AirGap.Sync.Models;
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class HlcMergeServiceTests
{
private static NodeJobLog CreateNodeLog(string nodeId, IEnumerable<OfflineJobLogEntry> entries)
{
var entryList = entries.ToList();
var lastEntry = entryList.LastOrDefault();
return new NodeJobLog
{
NodeId = nodeId,
Entries = entryList,
LastHlc = lastEntry?.THlc ?? new HlcTimestamp
{
PhysicalTime = 0,
NodeId = nodeId,
LogicalCounter = 0
},
ChainHead = lastEntry?.Link ?? new byte[32]
};
}
private static OfflineJobLogEntry CreateEntry(string nodeId, long physicalTime, int logicalCounter, Guid jobId)
{
var payloadHash = new byte[32];
jobId.ToByteArray().CopyTo(payloadHash, 0);
var hlc = new HlcTimestamp
{
PhysicalTime = physicalTime,
NodeId = nodeId,
LogicalCounter = logicalCounter
};
return new OfflineJobLogEntry
{
NodeId = nodeId,
THlc = hlc,
JobId = jobId,
Payload = $"{{\"id\":\"{jobId}\"}}",
PayloadHash = payloadHash,
Link = new byte[32],
EnqueuedAt = FixedEnqueuedAt
};
}
private static OfflineJobLogEntry CreateEntryWithPayloadHash(
string nodeId,
long physicalTime,
int logicalCounter,
Guid jobId,
byte[] payloadHash)
{
var hlc = new HlcTimestamp
{
PhysicalTime = physicalTime,
NodeId = nodeId,
LogicalCounter = logicalCounter
};
return new OfflineJobLogEntry
{
NodeId = nodeId,
THlc = hlc,
JobId = jobId,
Payload = $"{{\"id\":\"{jobId}\"}}",
PayloadHash = payloadHash,
Link = new byte[32],
EnqueuedAt = FixedEnqueuedAt
};
}
private static Guid CreateDeterministicGuid(int nodeIndex, int entryIndex)
{
var bytes = new byte[16];
bytes[0] = (byte)nodeIndex;
bytes[1] = (byte)entryIndex;
return new Guid(bytes);
}
}

View File

@@ -0,0 +1,44 @@
// <copyright file="HlcMergeServiceTests.MergeBasics.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class HlcMergeServiceTests
{
[Fact]
public async Task MergeAsync_EmptyInput_ReturnsEmptyResultAsync()
{
var nodeLogs = new List<NodeJobLog>();
var result = await _sut.MergeAsync(nodeLogs);
result.MergedEntries.Should().BeEmpty();
result.Duplicates.Should().BeEmpty();
result.SourceNodes.Should().BeEmpty();
result.MergedChainHead.Should().BeNull();
}
[Fact]
public async Task MergeAsync_SingleNode_PreservesOrderAsync()
{
var nodeLog = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("11111111-1111-1111-1111-111111111111")),
CreateEntry("node-a", 200, 0, Guid.Parse("22222222-2222-2222-2222-222222222222")),
CreateEntry("node-a", 300, 0, Guid.Parse("33333333-3333-3333-3333-333333333333"))
});
var result = await _sut.MergeAsync(new[] { nodeLog });
result.MergedEntries.Should().HaveCount(3);
result.MergedEntries[0].JobId.Should().Be(Guid.Parse("11111111-1111-1111-1111-111111111111"));
result.MergedEntries[1].JobId.Should().Be(Guid.Parse("22222222-2222-2222-2222-222222222222"));
result.MergedEntries[2].JobId.Should().Be(Guid.Parse("33333333-3333-3333-3333-333333333333"));
result.Duplicates.Should().BeEmpty();
result.SourceNodes.Should().ContainSingle().Which.Should().Be("node-a");
}
}

View File

@@ -0,0 +1,96 @@
// <copyright file="HlcMergeServiceTests.MergeOrdering.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class HlcMergeServiceTests
{
[Fact]
public async Task MergeAsync_TwoNodes_MergesByHlcOrderAsync()
{
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000")),
CreateEntry("node-a", 102, 0, Guid.Parse("aaaaaaaa-0003-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 101, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000")),
CreateEntry("node-b", 103, 0, Guid.Parse("bbbbbbbb-0004-0000-0000-000000000000"))
});
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
result.MergedEntries.Should().HaveCount(4);
result.MergedEntries[0].THlc.PhysicalTime.Should().Be(100);
result.MergedEntries[1].THlc.PhysicalTime.Should().Be(101);
result.MergedEntries[2].THlc.PhysicalTime.Should().Be(102);
result.MergedEntries[3].THlc.PhysicalTime.Should().Be(103);
result.SourceNodes.Should().HaveCount(2);
}
[Fact]
public async Task MergeAsync_SamePhysicalTime_OrdersByLogicalCounterAsync()
{
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001")),
CreateEntry("node-a", 100, 2, Guid.Parse("aaaaaaaa-0000-0000-0000-000000000003"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 100, 1, Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")),
CreateEntry("node-b", 100, 3, Guid.Parse("bbbbbbbb-0000-0000-0000-000000000004"))
});
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
result.MergedEntries.Should().HaveCount(4);
result.MergedEntries[0].THlc.LogicalCounter.Should().Be(0);
result.MergedEntries[1].THlc.LogicalCounter.Should().Be(1);
result.MergedEntries[2].THlc.LogicalCounter.Should().Be(2);
result.MergedEntries[3].THlc.LogicalCounter.Should().Be(3);
}
[Fact]
public async Task MergeAsync_SameTimeAndCounter_OrdersByNodeIdAsync()
{
var nodeA = CreateNodeLog("alpha-node", new[]
{
CreateEntry("alpha-node", 100, 0, Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"))
});
var nodeB = CreateNodeLog("beta-node", new[]
{
CreateEntry("beta-node", 100, 0, Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002"))
});
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
result.MergedEntries.Should().HaveCount(2);
result.MergedEntries[0].SourceNodeId.Should().Be("alpha-node");
result.MergedEntries[1].SourceNodeId.Should().Be("beta-node");
}
[Fact]
public async Task MergeAsync_RecomputesUnifiedChainAsync()
{
var nodeLog = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("11111111-1111-1111-1111-111111111111")),
CreateEntry("node-a", 200, 0, Guid.Parse("22222222-2222-2222-2222-222222222222"))
});
var result = await _sut.MergeAsync(new[] { nodeLog });
result.MergedEntries.Should().HaveCount(2);
result.MergedEntries[0].MergedLink.Should().NotBeNull();
result.MergedEntries[1].MergedLink.Should().NotBeNull();
result.MergedChainHead.Should().NotBeNull();
result.MergedEntries[0].MergedLink.Should().HaveCount(32);
result.MergedChainHead.Should().BeEquivalentTo(result.MergedEntries[1].MergedLink);
}
}

View File

@@ -0,0 +1,59 @@
// <copyright file="HlcMergeServiceTests.MultiNode.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using StellaOps.AirGap.Sync.Models;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
public sealed partial class HlcMergeServiceTests
{
[Fact]
public async Task MergeAsync_ThreeNodes_MergesCorrectlyAsync()
{
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000")),
CreateEntry("node-a", 400, 0, Guid.Parse("aaaaaaaa-0007-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 200, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000")),
CreateEntry("node-b", 500, 0, Guid.Parse("bbbbbbbb-0008-0000-0000-000000000000"))
});
var nodeC = CreateNodeLog("node-c", new[]
{
CreateEntry("node-c", 300, 0, Guid.Parse("cccccccc-0003-0000-0000-000000000000")),
CreateEntry("node-c", 600, 0, Guid.Parse("cccccccc-0009-0000-0000-000000000000"))
});
var result = await _sut.MergeAsync(new[] { nodeA, nodeB, nodeC });
result.MergedEntries.Should().HaveCount(6);
result.MergedEntries.Select(e => e.THlc.PhysicalTime).Should().BeInAscendingOrder();
result.MergedEntries.Select(e => e.THlc.PhysicalTime).Should()
.ContainInOrder(100L, 200L, 300L, 400L, 500L, 600L);
result.SourceNodes.Should().HaveCount(3);
}
[Fact]
public async Task MergeAsync_ManyNodes_PreservesTotalOrderAsync()
{
var nodes = new List<NodeJobLog>();
for (var i = 0; i < 5; i++)
{
var nodeId = $"node-{i:D2}";
nodes.Add(CreateNodeLog(nodeId, new[]
{
CreateEntry(nodeId, 100 + i * 10, 0, CreateDeterministicGuid(i, 0)),
CreateEntry(nodeId, 150 + i * 10, 0, CreateDeterministicGuid(i, 1))
}));
}
var result = await _sut.MergeAsync(nodes);
result.MergedEntries.Should().HaveCount(10);
result.MergedEntries.Select(e => e.THlc.PhysicalTime).Should().BeInAscendingOrder();
}
}

View File

@@ -1,23 +1,21 @@
// <copyright file="HlcMergeServiceTests.cs" company="StellaOps">
// <copyright file="HlcMergeServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
using StellaOps.HybridLogicalClock;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
/// <summary>
/// Unit tests for <see cref="HlcMergeService"/>.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class HlcMergeServiceTests
[Intent(TestIntents.Operational, "Validates HLC merge ordering, duplicates, and determinism.")]
public sealed partial class HlcMergeServiceTests
{
private static readonly DateTimeOffset FixedEnqueuedAt =
new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
private readonly HlcMergeService _sut;
private readonly ConflictResolver _conflictResolver;
@@ -26,426 +24,4 @@ public sealed class HlcMergeServiceTests
_conflictResolver = new ConflictResolver(NullLogger<ConflictResolver>.Instance);
_sut = new HlcMergeService(_conflictResolver, NullLogger<HlcMergeService>.Instance);
}
#region OMP-014: Merge Algorithm Correctness
[Fact]
public async Task MergeAsync_EmptyInput_ReturnsEmptyResult()
{
// Arrange
var nodeLogs = new List<NodeJobLog>();
// Act
var result = await _sut.MergeAsync(nodeLogs);
// Assert
result.MergedEntries.Should().BeEmpty();
result.Duplicates.Should().BeEmpty();
result.SourceNodes.Should().BeEmpty();
result.MergedChainHead.Should().BeNull();
}
[Fact]
public async Task MergeAsync_SingleNode_PreservesOrder()
{
// Arrange
var nodeLog = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("11111111-1111-1111-1111-111111111111")),
CreateEntry("node-a", 200, 0, Guid.Parse("22222222-2222-2222-2222-222222222222")),
CreateEntry("node-a", 300, 0, Guid.Parse("33333333-3333-3333-3333-333333333333"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeLog });
// Assert
result.MergedEntries.Should().HaveCount(3);
result.MergedEntries[0].JobId.Should().Be(Guid.Parse("11111111-1111-1111-1111-111111111111"));
result.MergedEntries[1].JobId.Should().Be(Guid.Parse("22222222-2222-2222-2222-222222222222"));
result.MergedEntries[2].JobId.Should().Be(Guid.Parse("33333333-3333-3333-3333-333333333333"));
result.Duplicates.Should().BeEmpty();
result.SourceNodes.Should().ContainSingle().Which.Should().Be("node-a");
}
[Fact]
public async Task MergeAsync_TwoNodes_MergesByHlcOrder()
{
// Arrange - Two nodes with interleaved HLC timestamps
// Node A: T=100, T=102
// Node B: T=101, T=103
// Expected order: 100, 101, 102, 103
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000")),
CreateEntry("node-a", 102, 0, Guid.Parse("aaaaaaaa-0003-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 101, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000")),
CreateEntry("node-b", 103, 0, Guid.Parse("bbbbbbbb-0004-0000-0000-000000000000"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert
result.MergedEntries.Should().HaveCount(4);
result.MergedEntries[0].THlc.PhysicalTime.Should().Be(100);
result.MergedEntries[1].THlc.PhysicalTime.Should().Be(101);
result.MergedEntries[2].THlc.PhysicalTime.Should().Be(102);
result.MergedEntries[3].THlc.PhysicalTime.Should().Be(103);
result.SourceNodes.Should().HaveCount(2);
}
[Fact]
public async Task MergeAsync_SamePhysicalTime_OrdersByLogicalCounter()
{
// Arrange - Same physical time, different logical counters
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001")),
CreateEntry("node-a", 100, 2, Guid.Parse("aaaaaaaa-0000-0000-0000-000000000003"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 100, 1, Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")),
CreateEntry("node-b", 100, 3, Guid.Parse("bbbbbbbb-0000-0000-0000-000000000004"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert
result.MergedEntries.Should().HaveCount(4);
result.MergedEntries[0].THlc.LogicalCounter.Should().Be(0);
result.MergedEntries[1].THlc.LogicalCounter.Should().Be(1);
result.MergedEntries[2].THlc.LogicalCounter.Should().Be(2);
result.MergedEntries[3].THlc.LogicalCounter.Should().Be(3);
}
[Fact]
public async Task MergeAsync_SameTimeAndCounter_OrdersByNodeId()
{
// Arrange - Same physical time and counter, different node IDs
var nodeA = CreateNodeLog("alpha-node", new[]
{
CreateEntry("alpha-node", 100, 0, Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"))
});
var nodeB = CreateNodeLog("beta-node", new[]
{
CreateEntry("beta-node", 100, 0, Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert - "alpha-node" < "beta-node" alphabetically
result.MergedEntries.Should().HaveCount(2);
result.MergedEntries[0].SourceNodeId.Should().Be("alpha-node");
result.MergedEntries[1].SourceNodeId.Should().Be("beta-node");
}
[Fact]
public async Task MergeAsync_RecomputesUnifiedChain()
{
// Arrange
var nodeLog = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("11111111-1111-1111-1111-111111111111")),
CreateEntry("node-a", 200, 0, Guid.Parse("22222222-2222-2222-2222-222222222222"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeLog });
// Assert - Chain should be recomputed
result.MergedEntries.Should().HaveCount(2);
result.MergedEntries[0].MergedLink.Should().NotBeNull();
result.MergedEntries[1].MergedLink.Should().NotBeNull();
result.MergedChainHead.Should().NotBeNull();
// First entry's link should be computed from null prev_link
result.MergedEntries[0].MergedLink.Should().HaveCount(32);
// Chain head should equal last entry's merged link
result.MergedChainHead.Should().BeEquivalentTo(result.MergedEntries[1].MergedLink);
}
#endregion
#region OMP-015: Duplicate Detection
[Fact]
public async Task MergeAsync_DuplicateJobId_SamePayload_TakesEarliest()
{
// Arrange - Same job ID (same payload hash) from two nodes
var jobId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
var payloadHash = new byte[32];
payloadHash[0] = 0xAA;
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHash)
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntryWithPayloadHash("node-b", 105, 0, jobId, payloadHash)
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert - Should take earliest (T=100 from node-a)
result.MergedEntries.Should().ContainSingle();
result.MergedEntries[0].SourceNodeId.Should().Be("node-a");
result.MergedEntries[0].THlc.PhysicalTime.Should().Be(100);
// Should report duplicate
result.Duplicates.Should().ContainSingle();
result.Duplicates[0].JobId.Should().Be(jobId);
result.Duplicates[0].NodeId.Should().Be("node-b");
result.Duplicates[0].THlc.PhysicalTime.Should().Be(105);
}
[Fact]
public async Task MergeAsync_TriplicateJobId_SamePayload_TakesEarliest()
{
// Arrange - Same job ID from three nodes
var jobId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
var payloadHash = new byte[32];
payloadHash[0] = 0xBB;
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntryWithPayloadHash("node-a", 200, 0, jobId, payloadHash)
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash) // Earliest
});
var nodeC = CreateNodeLog("node-c", new[]
{
CreateEntryWithPayloadHash("node-c", 150, 0, jobId, payloadHash)
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB, nodeC });
// Assert - Should take earliest (T=100 from node-b)
result.MergedEntries.Should().ContainSingle();
result.MergedEntries[0].SourceNodeId.Should().Be("node-b");
result.MergedEntries[0].THlc.PhysicalTime.Should().Be(100);
// Should report two duplicates
result.Duplicates.Should().HaveCount(2);
}
[Fact]
public async Task MergeAsync_DuplicateJobId_DifferentPayload_ThrowsError()
{
// Arrange - Same job ID but different payload hashes (indicates bug)
var jobId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
var payloadHashA = new byte[32];
payloadHashA[0] = 0x01;
var payloadHashB = new byte[32];
payloadHashB[0] = 0x02;
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHashA)
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntryWithPayloadHash("node-b", 105, 0, jobId, payloadHashB)
});
// Act & Assert - Should throw because payloads differ
var act = () => _sut.MergeAsync(new[] { nodeA, nodeB });
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*conflicting payloads*");
}
#endregion
#region OMP-018: Multi-Node Merge
[Fact]
public async Task MergeAsync_ThreeNodes_MergesCorrectly()
{
// Arrange - Three nodes with various timestamps
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000")),
CreateEntry("node-a", 400, 0, Guid.Parse("aaaaaaaa-0007-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 200, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000")),
CreateEntry("node-b", 500, 0, Guid.Parse("bbbbbbbb-0008-0000-0000-000000000000"))
});
var nodeC = CreateNodeLog("node-c", new[]
{
CreateEntry("node-c", 300, 0, Guid.Parse("cccccccc-0003-0000-0000-000000000000")),
CreateEntry("node-c", 600, 0, Guid.Parse("cccccccc-0009-0000-0000-000000000000"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB, nodeC });
// Assert
result.MergedEntries.Should().HaveCount(6);
result.MergedEntries.Select(e => e.THlc.PhysicalTime).Should()
.BeInAscendingOrder();
result.MergedEntries.Select(e => e.THlc.PhysicalTime).Should()
.ContainInOrder(100L, 200L, 300L, 400L, 500L, 600L);
result.SourceNodes.Should().HaveCount(3);
}
[Fact]
public async Task MergeAsync_ManyNodes_PreservesTotalOrder()
{
// Arrange - 5 nodes with 2 entries each
var nodes = new List<NodeJobLog>();
for (int i = 0; i < 5; i++)
{
var nodeId = $"node-{i:D2}";
nodes.Add(CreateNodeLog(nodeId, new[]
{
CreateEntry(nodeId, 100 + i * 10, 0, Guid.NewGuid()),
CreateEntry(nodeId, 150 + i * 10, 0, Guid.NewGuid())
}));
}
// Act
var result = await _sut.MergeAsync(nodes);
// Assert
result.MergedEntries.Should().HaveCount(10);
result.MergedEntries.Select(e => e.THlc.PhysicalTime).Should()
.BeInAscendingOrder();
}
#endregion
#region OMP-019: Determinism Tests
[Fact]
public async Task MergeAsync_SameInput_ProducesSameOutput()
{
// Arrange
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000")),
CreateEntry("node-a", 300, 0, Guid.Parse("aaaaaaaa-0003-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 200, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000")),
CreateEntry("node-b", 400, 0, Guid.Parse("bbbbbbbb-0004-0000-0000-000000000000"))
});
// Act - Run merge twice
var result1 = await _sut.MergeAsync(new[] { nodeA, nodeB });
var result2 = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert - Results should be identical
result1.MergedEntries.Should().HaveCount(result2.MergedEntries.Count);
for (int i = 0; i < result1.MergedEntries.Count; i++)
{
result1.MergedEntries[i].JobId.Should().Be(result2.MergedEntries[i].JobId);
result1.MergedEntries[i].THlc.Should().Be(result2.MergedEntries[i].THlc);
result1.MergedEntries[i].MergedLink.Should().BeEquivalentTo(result2.MergedEntries[i].MergedLink);
}
result1.MergedChainHead.Should().BeEquivalentTo(result2.MergedChainHead);
}
[Fact]
public async Task MergeAsync_InputOrderIndependent_ProducesSameOutput()
{
// Arrange
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 200, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000"))
});
// Act - Merge in different orders
var result1 = await _sut.MergeAsync(new[] { nodeA, nodeB });
var result2 = await _sut.MergeAsync(new[] { nodeB, nodeA });
// Assert - Results should be identical regardless of input order
result1.MergedEntries.Select(e => e.JobId).Should()
.BeEquivalentTo(result2.MergedEntries.Select(e => e.JobId));
result1.MergedChainHead.Should().BeEquivalentTo(result2.MergedChainHead);
}
#endregion
#region Helper Methods
private static NodeJobLog CreateNodeLog(string nodeId, IEnumerable<OfflineJobLogEntry> entries)
{
var entryList = entries.ToList();
var lastEntry = entryList.LastOrDefault();
return new NodeJobLog
{
NodeId = nodeId,
Entries = entryList,
LastHlc = lastEntry?.THlc ?? new HlcTimestamp { PhysicalTime = 0, NodeId = nodeId, LogicalCounter = 0 },
ChainHead = lastEntry?.Link ?? new byte[32]
};
}
private static OfflineJobLogEntry CreateEntry(string nodeId, long physicalTime, int logicalCounter, Guid jobId)
{
var payloadHash = new byte[32];
jobId.ToByteArray().CopyTo(payloadHash, 0);
var hlc = new HlcTimestamp
{
PhysicalTime = physicalTime,
NodeId = nodeId,
LogicalCounter = logicalCounter
};
return new OfflineJobLogEntry
{
NodeId = nodeId,
THlc = hlc,
JobId = jobId,
Payload = $"{{\"id\":\"{jobId}\"}}",
PayloadHash = payloadHash,
Link = new byte[32],
EnqueuedAt = DateTimeOffset.UtcNow
};
}
private static OfflineJobLogEntry CreateEntryWithPayloadHash(
string nodeId, long physicalTime, int logicalCounter, Guid jobId, byte[] payloadHash)
{
var hlc = new HlcTimestamp
{
PhysicalTime = physicalTime,
NodeId = nodeId,
LogicalCounter = logicalCounter
};
return new OfflineJobLogEntry
{
NodeId = nodeId,
THlc = hlc,
JobId = jobId,
Payload = $"{{\"id\":\"{jobId}\"}}",
PayloadHash = payloadHash,
Link = new byte[32],
EnqueuedAt = DateTimeOffset.UtcNow
};
}
#endregion
}

View File

@@ -0,0 +1,68 @@
// <copyright file="OfflineHlcManagerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
using StellaOps.AirGap.Sync.Tests.TestUtilities;
using StellaOps.Determinism;
using StellaOps.HybridLogicalClock;
using StellaOps.TestKit;
using StellaOps.TestKit.Traits;
using System.Security.Cryptography;
using System.Text;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
[Trait("Category", TestCategories.Unit)]
[Intent(TestIntents.Operational, "Ensures offline enqueue uses deterministic IDs and time provider.")]
public sealed class OfflineHlcManagerTests
{
private static readonly DateTimeOffset FixedNow =
new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task EnqueueOfflineAsync_UsesTimeProviderAndDeterministicJobIdAsync()
{
var hlcTimestamp = new HlcTimestamp
{
PhysicalTime = 100,
NodeId = "node-a",
LogicalCounter = 1
};
var hlc = new TestHybridLogicalClock("node-a", hlcTimestamp);
var store = new InMemoryOfflineJobLogStore();
var timeProvider = new FixedTimeProvider(FixedNow);
var manager = new OfflineHlcManager(
hlc,
store,
SystemGuidProvider.Instance,
timeProvider,
NullLogger<OfflineHlcManager>.Instance);
var result = await manager.EnqueueOfflineAsync(
new { Name = "payload" },
"job-key");
var expectedJobId = ComputeExpectedJobId("job-key");
result.JobId.Should().Be(expectedJobId);
result.NodeId.Should().Be("node-a");
result.Link.Should().NotBeNull();
result.THlc.Should().Be(hlcTimestamp);
var stored = await store.GetEntriesAsync("node-a");
stored.Should().ContainSingle();
stored[0].EnqueuedAt.Should().Be(FixedNow);
stored[0].JobId.Should().Be(expectedJobId);
}
private static Guid ComputeExpectedJobId(string idempotencyKey)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(idempotencyKey));
return new Guid(hash.AsSpan(0, 16));
}
}

View File

@@ -1,10 +1,9 @@
# AirGap Sync Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0793-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0793-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0793-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/__Tests/StellaOps.AirGap.Sync.Tests/StellaOps.AirGap.Sync.Tests.md. |
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |

View File

@@ -0,0 +1,16 @@
// <copyright file="FixedTimeProvider.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
namespace StellaOps.AirGap.Sync.Tests.TestUtilities;
internal sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}

View File

@@ -0,0 +1,89 @@
// <copyright file="InMemoryOfflineJobLogStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Stores;
namespace StellaOps.AirGap.Sync.Tests.TestUtilities;
internal sealed class InMemoryOfflineJobLogStore : IOfflineJobLogStore
{
private readonly Dictionary<string, List<OfflineJobLogEntry>> _entries =
new(StringComparer.Ordinal);
public Task AppendAsync(OfflineJobLogEntry entry, CancellationToken cancellationToken = default)
{
if (!_entries.TryGetValue(entry.NodeId, out var list))
{
list = new List<OfflineJobLogEntry>();
_entries[entry.NodeId] = list;
}
list.Add(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<OfflineJobLogEntry>> GetEntriesAsync(
string nodeId,
CancellationToken cancellationToken = default)
{
if (!_entries.TryGetValue(nodeId, out var list))
{
return Task.FromResult<IReadOnlyList<OfflineJobLogEntry>>(Array.Empty<OfflineJobLogEntry>());
}
return Task.FromResult<IReadOnlyList<OfflineJobLogEntry>>(
list.OrderBy(e => e.THlc).ToList());
}
public async Task<byte[]?> GetLastLinkAsync(string nodeId, CancellationToken cancellationToken = default)
{
var entries = await GetEntriesAsync(nodeId, cancellationToken);
return entries.Count > 0 ? entries[^1].Link : null;
}
public async Task<NodeJobLog?> GetNodeJobLogAsync(string nodeId, CancellationToken cancellationToken = default)
{
var entries = await GetEntriesAsync(nodeId, cancellationToken);
if (entries.Count == 0)
{
return null;
}
var lastEntry = entries[^1];
return new NodeJobLog
{
NodeId = nodeId,
LastHlc = lastEntry.THlc,
ChainHead = lastEntry.Link,
Entries = entries
};
}
public async Task<int> ClearEntriesAsync(
string nodeId,
string upToHlc,
CancellationToken cancellationToken = default)
{
if (!_entries.TryGetValue(nodeId, out var list))
{
return 0;
}
var remaining = list
.Where(e => string.CompareOrdinal(e.THlc.ToSortableString(), upToHlc) > 0)
.ToList();
var cleared = list.Count - remaining.Count;
if (remaining.Count == 0)
{
_entries.Remove(nodeId);
}
else
{
_entries[nodeId] = remaining;
}
return await Task.FromResult(cleared);
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="TempDirectory.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
namespace StellaOps.AirGap.Sync.Tests.TestUtilities;
internal sealed class TempDirectory : IDisposable
{
private static int _counter;
public TempDirectory(string? prefix = null)
{
var id = Interlocked.Increment(ref _counter);
var name = $"{prefix ?? "airgap-sync-test"}-{id:D4}";
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), name);
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, true);
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
}

View File

@@ -0,0 +1,33 @@
// <copyright file="TestHybridLogicalClock.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Tests.TestUtilities;
internal sealed class TestHybridLogicalClock : IHybridLogicalClock
{
private HlcTimestamp _timestamp;
private readonly string _nodeId;
public TestHybridLogicalClock(string nodeId, HlcTimestamp timestamp)
{
_nodeId = nodeId;
_timestamp = timestamp;
}
public List<HlcTimestamp> Received { get; } = new();
public HlcTimestamp Current => _timestamp;
public string NodeId => _nodeId;
public HlcTimestamp Tick() => _timestamp;
public HlcTimestamp Receive(HlcTimestamp remote)
{
Received.Add(remote);
_timestamp = remote;
return remote;
}
}

View File

@@ -0,0 +1,36 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public sealed partial class Rfc3161VerifierTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_HandlesExceptionsGracefully()
{
var token = new byte[256];
new Random(42).NextBytes(token);
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReportsDecodeErrorForMalformedCms()
{
var token = new byte[] { 0x30, 0x82, 0x00, 0x10, 0x06, 0x09 };
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
Assert.NotNull(result.Reason);
Assert.Contains("rfc3161-", result.Reason);
}
}

View File

@@ -0,0 +1,60 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public sealed partial class Rfc3161VerifierTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
{
var token = new byte[] { 0x01, 0x02, 0x03 };
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
Assert.False(result.IsValid);
Assert.Equal("rfc3161-trust-roots-required", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTokenEmpty()
{
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("rfc3161-token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenInvalidAsn1Structure()
{
var token = new byte[] { 0x01, 0x02, 0x03 };
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ProducesTokenDigest()
{
var token = new byte[] { 0x30, 0x00 };
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
}
}

View File

@@ -1,101 +1,13 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for Rfc3161Verifier with real SignedCms verification.
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
/// </summary>
public class Rfc3161VerifierTests
public sealed partial class Rfc3161VerifierTests
{
private readonly Rfc3161Verifier _verifier = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
{
var token = new byte[] { 0x01, 0x02, 0x03 };
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
Assert.False(result.IsValid);
Assert.Equal("rfc3161-trust-roots-required", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTokenEmpty()
{
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("rfc3161-token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenInvalidAsn1Structure()
{
var token = new byte[] { 0x01, 0x02, 0x03 }; // Invalid ASN.1
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ProducesTokenDigest()
{
var token = new byte[] { 0x30, 0x00 }; // Empty SEQUENCE (minimal valid ASN.1)
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out _);
// Should fail on CMS decode but attempt was made
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_HandlesExceptionsGracefully()
{
// Create bytes that might cause internal exceptions
var token = new byte[256];
new Random(42).NextBytes(token);
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out var anchor);
// Should not throw, should return failure result
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReportsDecodeErrorForMalformedCms()
{
// Create something that looks like CMS but isn't valid
var token = new byte[] { 0x30, 0x82, 0x00, 0x10, 0x06, 0x09 };
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
// Should report either decode or error
Assert.NotNull(result.Reason);
Assert.Contains("rfc3161-", result.Reason);
}
}

View File

@@ -0,0 +1,98 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public sealed partial class RoughtimeVerifierTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
{
var token = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-trust-roots-required", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTokenEmpty()
{
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTokenTooShort()
{
var token = new byte[] { 0x01, 0x02, 0x03 };
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
Assert.Equal("roughtime-message-too-short", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenInvalidTagCount()
{
var token = new byte[8];
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)0);
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
Assert.Equal("roughtime-invalid-tag-count", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenNonEd25519Algorithm()
{
var token = CreateMinimalRoughtimeToken();
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "rsa") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
Assert.Contains("roughtime-", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenKeyLengthWrong()
{
var token = CreateMinimalRoughtimeToken();
var trust = new[] { new TimeTrustRoot("root1", new byte[16], "ed25519") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
Assert.Contains("roughtime-", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ProducesTokenDigest()
{
var token = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
}
}

View File

@@ -0,0 +1,39 @@
namespace StellaOps.AirGap.Time.Tests;
public sealed partial class RoughtimeVerifierTests
{
private static byte[] CreateMinimalRoughtimeToken()
{
const uint tagSig = 0x00474953;
const uint tagSrep = 0x50455253;
var sigValue = new byte[64];
var srepValue = CreateMinimalSrep();
var headerSize = 4 + 4 + 8;
var token = new byte[headerSize + sigValue.Length + srepValue.Length];
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)2);
BitConverter.TryWriteBytes(token.AsSpan(4, 4), (uint)64);
BitConverter.TryWriteBytes(token.AsSpan(8, 4), tagSig);
BitConverter.TryWriteBytes(token.AsSpan(12, 4), tagSrep);
sigValue.CopyTo(token.AsSpan(16));
srepValue.CopyTo(token.AsSpan(16 + 64));
return token;
}
private static byte[] CreateMinimalSrep()
{
const uint tagMidp = 0x5044494D;
var headerSize = 4 + 4;
var srepValue = new byte[headerSize + 8];
BitConverter.TryWriteBytes(srepValue.AsSpan(0, 4), (uint)1);
BitConverter.TryWriteBytes(srepValue.AsSpan(4, 4), tagMidp);
BitConverter.TryWriteBytes(srepValue.AsSpan(8, 8), 1735689600000000L);
return srepValue;
}
}

View File

@@ -1,158 +1,13 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for RoughtimeVerifier with real Ed25519 signature verification.
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
/// </summary>
public class RoughtimeVerifierTests
public sealed partial class RoughtimeVerifierTests
{
private readonly RoughtimeVerifier _verifier = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
{
var token = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-trust-roots-required", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTokenEmpty()
{
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTokenTooShort()
{
var token = new byte[] { 0x01, 0x02, 0x03 };
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-message-too-short", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenInvalidTagCount()
{
// Create a minimal wire format with invalid tag count
var token = new byte[8];
// Set num_tags to 0 (invalid)
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)0);
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-invalid-tag-count", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenNonEd25519Algorithm()
{
// Create a minimal valid-looking wire format
var token = CreateMinimalRoughtimeToken();
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "rsa") }; // Wrong algorithm
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
// Should fail either on parsing or signature verification
Assert.Contains("roughtime-", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenKeyLengthWrong()
{
var token = CreateMinimalRoughtimeToken();
var trust = new[] { new TimeTrustRoot("root1", new byte[16], "ed25519") }; // Wrong key length
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Contains("roughtime-", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ProducesTokenDigest()
{
var token = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out _);
// Even on failure, we should get a deterministic result
Assert.False(result.IsValid);
}
/// <summary>
/// Creates a minimal Roughtime wire format token for testing parsing paths.
/// Note: This will fail signature verification but tests the parsing logic.
/// </summary>
private static byte[] CreateMinimalRoughtimeToken()
{
// Roughtime wire format:
// [num_tags:u32] [offsets:u32[n-1]] [tags:u32[n]] [values...]
// We'll create 2 tags: SIG and SREP
const uint TagSig = 0x00474953; // "SIG\0"
const uint TagSrep = 0x50455253; // "SREP"
var sigValue = new byte[64]; // Ed25519 signature
var srepValue = CreateMinimalSrep();
// Header: num_tags=2, offset[0]=64 (sig length), tags=[SIG, SREP]
var headerSize = 4 + 4 + 8; // num_tags + 1 offset + 2 tags = 16 bytes
var token = new byte[headerSize + sigValue.Length + srepValue.Length];
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)2); // num_tags = 2
BitConverter.TryWriteBytes(token.AsSpan(4, 4), (uint)64); // offset[0] = 64 (sig length)
BitConverter.TryWriteBytes(token.AsSpan(8, 4), TagSig);
BitConverter.TryWriteBytes(token.AsSpan(12, 4), TagSrep);
sigValue.CopyTo(token.AsSpan(16));
srepValue.CopyTo(token.AsSpan(16 + 64));
return token;
}
private static byte[] CreateMinimalSrep()
{
// SREP with MIDP tag containing 8-byte timestamp
const uint TagMidp = 0x5044494D; // "MIDP"
// Header: num_tags=1, tags=[MIDP]
var headerSize = 4 + 4; // num_tags + 1 tag = 8 bytes
var srepValue = new byte[headerSize + 8]; // + 8 bytes for MIDP value
BitConverter.TryWriteBytes(srepValue.AsSpan(0, 4), (uint)1); // num_tags = 1
BitConverter.TryWriteBytes(srepValue.AsSpan(4, 4), TagMidp);
// MIDP value: microseconds since Unix epoch (example: 2025-01-01 00:00:00 UTC)
BitConverter.TryWriteBytes(srepValue.AsSpan(8, 8), 1735689600000000L);
return srepValue;
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Hooks;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public sealed class SealedStartupHostedServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_ThrowsWhenAnchorMissingAsync()
{
var service = Build(out _, DateTimeOffset.UnixEpoch);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(default));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_CompletesWhenAnchorFreshAsync()
{
var now = DateTimeOffset.UnixEpoch.AddSeconds(5);
var service = Build(out var statusService, now);
var anchor = new TimeAnchor(now, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
await service.StartAsync(default);
}
private static SealedStartupHostedService Build(out TimeStatusService statusService, DateTimeOffset now)
{
var store = new InMemoryTimeAnchorStore();
statusService = new TimeStatusService(
store,
new StalenessCalculator(),
new TimeTelemetry(),
new TestOptionsMonitor<AirGapOptions>(new AirGapOptions()));
var validator = new SealedStartupValidator(statusService, new FixedTimeProvider(now));
var options = Options.Create(new AirGapOptions
{
TenantId = "t1",
Staleness = new StalenessOptions { WarningSeconds = 10, BreachSeconds = 20 }
});
return new SealedStartupHostedService(validator, options, NullLogger<SealedStartupHostedService>.Instance);
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -9,7 +9,7 @@ public class SealedStartupValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FailsWhenAnchorMissing()
public async Task FailsWhenAnchorMissingAsync()
{
var validator = Build(out var statusService, DateTimeOffset.UnixEpoch);
var result = await validator.ValidateAsync("t1", StalenessBudget.Default, default);
@@ -19,7 +19,7 @@ public class SealedStartupValidatorTests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FailsWhenBreach()
public async Task FailsWhenBreachAsync()
{
var now = DateTimeOffset.UnixEpoch.AddSeconds(25);
var validator = Build(out var statusService, now);
@@ -35,7 +35,7 @@ public class SealedStartupValidatorTests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SucceedsWhenFresh()
public async Task SucceedsWhenFreshAsync()
{
var now = DateTimeOffset.UnixEpoch.AddSeconds(5);
var validator = Build(out var statusService, now);
@@ -47,7 +47,7 @@ public class SealedStartupValidatorTests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FailsOnBudgetMismatch()
public async Task FailsOnBudgetMismatchAsync()
{
var now = DateTimeOffset.UnixEpoch.AddSeconds(5);
var validator = Build(out var statusService, now);

View File

@@ -1,10 +1,12 @@
# AirGap Time Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0035-M | DONE | Revalidated maintainability for StellaOps.AirGap.Time.Tests (2026-01-06). |
| AUDIT-0035-T | DONE | Revalidated test coverage for StellaOps.AirGap.Time.Tests (2026-01-06). |
| AUDIT-0035-A | DONE | Waived (test project). |
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/__Tests/StellaOps.AirGap.Time.Tests/StellaOps.AirGap.Time.Tests.md. |
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |

View File

@@ -0,0 +1,58 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public sealed partial class TimeAnchorPolicyServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceBundleImportPolicyAsync_AllowsImport_WhenAnchorValidAsync()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.EnforceBundleImportPolicyAsync(
"tenant-1",
"bundle-123",
_fixedTimeProvider.GetUtcNow().AddMinutes(-15));
Assert.True(result.Allowed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceBundleImportPolicyAsync_BlocksImport_WhenDriftExceededAsync()
{
var options = new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 };
var service = CreateService(options);
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(86400, 172800);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var bundleTimestamp = _fixedTimeProvider.GetUtcNow().AddDays(-2);
var result = await service.EnforceBundleImportPolicyAsync(
"tenant-1",
"bundle-123",
bundleTimestamp);
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.DriftExceeded, result.ErrorCode);
}
}

View File

@@ -0,0 +1,63 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public sealed partial class TimeAnchorPolicyServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateDriftAsync_ReturnsNoDrift_WhenNoAnchorAsync()
{
var service = CreateService();
var result = await service.CalculateDriftAsync("tenant-1", _fixedTimeProvider.GetUtcNow());
Assert.False(result.HasAnchor);
Assert.Equal(TimeSpan.Zero, result.Drift);
Assert.Null(result.AnchorTime);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateDriftAsync_ReturnsDrift_WhenAnchorExistsAsync()
{
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 });
var anchorTime = _fixedTimeProvider.GetUtcNow().AddMinutes(-30);
var anchor = new TimeAnchor(anchorTime, "test", "Roughtime", "fp", "digest");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(15);
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
Assert.True(result.HasAnchor);
Assert.Equal(anchorTime, result.AnchorTime);
Assert.Equal(45, (int)result.Drift.TotalMinutes);
Assert.False(result.DriftExceedsThreshold);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateDriftAsync_DetectsExcessiveDriftAsync()
{
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 60 });
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow(),
"test",
"Roughtime",
"fp",
"digest");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(5);
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
Assert.True(result.HasAnchor);
Assert.True(result.DriftExceedsThreshold);
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for TimeAnchorPolicyService.
/// Per AIRGAP-TIME-57-001: Time-anchor policy enforcement.
/// </summary>
public sealed partial class TimeAnchorPolicyServiceTests
{
private readonly TimeProvider _fixedTimeProvider;
private readonly InMemoryTimeAnchorStore _store;
private readonly StalenessCalculator _calculator;
private readonly TimeTelemetry _telemetry;
private readonly TimeStatusService _statusService;
private readonly AirGapOptions _airGapOptions;
public TimeAnchorPolicyServiceTests()
{
_fixedTimeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
_store = new InMemoryTimeAnchorStore();
_calculator = new StalenessCalculator();
_telemetry = new TimeTelemetry();
_airGapOptions = new AirGapOptions
{
Staleness = new StalenessOptions { WarningSeconds = 3600, BreachSeconds = 7200 },
ContentBudgets = new Dictionary<string, StalenessOptions>()
};
_statusService = new TimeStatusService(_store, _calculator, _telemetry, new TestOptionsMonitor<AirGapOptions>(_airGapOptions));
}
private TimeAnchorPolicyService CreateService(TimeAnchorPolicyOptions? options = null)
{
return new TimeAnchorPolicyService(
_statusService,
Options.Create(options ?? new TimeAnchorPolicyOptions()),
NullLogger<TimeAnchorPolicyService>.Instance,
_fixedTimeProvider);
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,39 @@
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public sealed partial class TimeAnchorPolicyServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceOperationPolicyAsync_BlocksStrictOperations_WhenNoAnchorAsync()
{
var options = new TimeAnchorPolicyOptions
{
StrictOperations = new[] { "attestation.sign" }
};
var service = CreateService(options);
var result = await service.EnforceOperationPolicyAsync("tenant-1", "attestation.sign");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceOperationPolicyAsync_AllowsNonStrictOperations_InNonStrictModeAsync()
{
var options = new TimeAnchorPolicyOptions
{
StrictEnforcement = false,
StrictOperations = new[] { "attestation.sign" }
};
var service = CreateService(options);
var result = await service.EnforceOperationPolicyAsync("tenant-1", "some.other.operation");
Assert.True(result.Allowed);
}
}

View File

@@ -0,0 +1,90 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public sealed partial class TimeAnchorPolicyServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenNoAnchorAsync()
{
var service = CreateService();
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
Assert.NotNull(result.Remediation);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsSuccess_WhenAnchorValidAsync()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.True(result.Allowed);
Assert.Null(result.ErrorCode);
Assert.NotNull(result.Staleness);
Assert.False(result.Staleness.IsBreach);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsWarning_WhenAnchorStaleAsync()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddSeconds(-5000),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.True(result.Allowed);
Assert.NotNull(result.Staleness);
Assert.True(result.Staleness.IsWarning);
Assert.Contains("warning", result.Reason, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenAnchorBreachedAsync()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddSeconds(-8000),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorBreached, result.ErrorCode);
Assert.NotNull(result.Staleness);
Assert.True(result.Staleness.IsBreach);
}
}

View File

@@ -1,273 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for TimeAnchorPolicyService.
/// Per AIRGAP-TIME-57-001: Time-anchor policy enforcement.
/// </summary>
public class TimeAnchorPolicyServiceTests
{
private readonly TimeProvider _fixedTimeProvider;
private readonly InMemoryTimeAnchorStore _store;
private readonly StalenessCalculator _calculator;
private readonly TimeTelemetry _telemetry;
private readonly TimeStatusService _statusService;
private readonly AirGapOptions _airGapOptions;
public TimeAnchorPolicyServiceTests()
{
_fixedTimeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
_store = new InMemoryTimeAnchorStore();
_calculator = new StalenessCalculator();
_telemetry = new TimeTelemetry();
_airGapOptions = new AirGapOptions
{
Staleness = new StalenessOptions { WarningSeconds = 3600, BreachSeconds = 7200 },
ContentBudgets = new Dictionary<string, StalenessOptions>()
};
_statusService = new TimeStatusService(_store, _calculator, _telemetry, new TestOptionsMonitor<AirGapOptions>(_airGapOptions));
}
private TimeAnchorPolicyService CreateService(TimeAnchorPolicyOptions? options = null)
{
return new TimeAnchorPolicyService(
_statusService,
Options.Create(options ?? new TimeAnchorPolicyOptions()),
NullLogger<TimeAnchorPolicyService>.Instance,
_fixedTimeProvider);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenNoAnchor()
{
var service = CreateService();
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
Assert.NotNull(result.Remediation);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsSuccess_WhenAnchorValid()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.True(result.Allowed);
Assert.Null(result.ErrorCode);
Assert.NotNull(result.Staleness);
Assert.False(result.Staleness.IsBreach);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsWarning_WhenAnchorStale()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddSeconds(-5000), // Past warning threshold
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.True(result.Allowed); // Allowed but with warning
Assert.NotNull(result.Staleness);
Assert.True(result.Staleness.IsWarning);
Assert.Contains("warning", result.Reason, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenAnchorBreached()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddSeconds(-8000), // Past breach threshold
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorBreached, result.ErrorCode);
Assert.NotNull(result.Staleness);
Assert.True(result.Staleness.IsBreach);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceBundleImportPolicyAsync_AllowsImport_WhenAnchorValid()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.EnforceBundleImportPolicyAsync(
"tenant-1",
"bundle-123",
_fixedTimeProvider.GetUtcNow().AddMinutes(-15));
Assert.True(result.Allowed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceBundleImportPolicyAsync_BlocksImport_WhenDriftExceeded()
{
var options = new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 }; // 1 hour max
var service = CreateService(options);
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(86400, 172800); // Large budget
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var bundleTimestamp = _fixedTimeProvider.GetUtcNow().AddDays(-2); // 2 days ago
var result = await service.EnforceBundleImportPolicyAsync(
"tenant-1",
"bundle-123",
bundleTimestamp);
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.DriftExceeded, result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceOperationPolicyAsync_BlocksStrictOperations_WhenNoAnchor()
{
var options = new TimeAnchorPolicyOptions
{
StrictOperations = new[] { "attestation.sign" }
};
var service = CreateService(options);
var result = await service.EnforceOperationPolicyAsync("tenant-1", "attestation.sign");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceOperationPolicyAsync_AllowsNonStrictOperations_InNonStrictMode()
{
var options = new TimeAnchorPolicyOptions
{
StrictEnforcement = false,
StrictOperations = new[] { "attestation.sign" }
};
var service = CreateService(options);
var result = await service.EnforceOperationPolicyAsync("tenant-1", "some.other.operation");
Assert.True(result.Allowed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateDriftAsync_ReturnsNoDrift_WhenNoAnchor()
{
var service = CreateService();
var result = await service.CalculateDriftAsync("tenant-1", _fixedTimeProvider.GetUtcNow());
Assert.False(result.HasAnchor);
Assert.Equal(TimeSpan.Zero, result.Drift);
Assert.Null(result.AnchorTime);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateDriftAsync_ReturnsDrift_WhenAnchorExists()
{
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 });
var anchorTime = _fixedTimeProvider.GetUtcNow().AddMinutes(-30);
var anchor = new TimeAnchor(anchorTime, "test", "Roughtime", "fp", "digest");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(15);
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
Assert.True(result.HasAnchor);
Assert.Equal(anchorTime, result.AnchorTime);
Assert.Equal(45, (int)result.Drift.TotalMinutes); // 30 min + 15 min
Assert.False(result.DriftExceedsThreshold);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateDriftAsync_DetectsExcessiveDrift()
{
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 60 }); // 1 minute max
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow(),
"test",
"Roughtime",
"fp",
"digest");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(5); // 5 minutes drift
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
Assert.True(result.HasAnchor);
Assert.True(result.DriftExceedsThreshold);
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -9,7 +9,7 @@ public class TimeStatusServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ReturnsUnknownWhenNoAnchor()
public async Task ReturnsUnknownWhenNoAnchorAsync()
{
var svc = Build(out var telemetry);
var status = await svc.GetStatusAsync("t1", DateTimeOffset.UnixEpoch);
@@ -20,7 +20,7 @@ public class TimeStatusServiceTests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PersistsAnchorAndBudget()
public async Task PersistsAnchorAndBudgetAsync()
{
var svc = Build(out var telemetry);
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");