part #2
This commit is contained in:
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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*");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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*");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user