Add tests for SBOM generation determinism across multiple formats
- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism. - Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions. - Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests. - Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
@@ -1,15 +1,38 @@
|
||||
using System.Reflection;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Isolation modes for PostgreSQL test fixtures.
|
||||
/// </summary>
|
||||
public enum PostgresIsolationMode
|
||||
{
|
||||
/// <summary>Each test gets its own schema. Default, most isolated.</summary>
|
||||
SchemaPerTest,
|
||||
/// <summary>Truncate all tables between tests. Faster but shared schema.</summary>
|
||||
Truncation,
|
||||
/// <summary>Each test gets its own database. Maximum isolation, slowest.</summary>
|
||||
DatabasePerTest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a migration source for PostgreSQL fixtures.
|
||||
/// </summary>
|
||||
public sealed record MigrationSource(string Module, string ScriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Test fixture for PostgreSQL database using Testcontainers.
|
||||
/// Provides an isolated PostgreSQL instance for integration tests.
|
||||
/// Provides an isolated PostgreSQL instance for integration tests with
|
||||
/// configurable isolation modes and migration support.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
private readonly List<MigrationSource> _migrations = new();
|
||||
private int _schemaCounter;
|
||||
private int _databaseCounter;
|
||||
|
||||
public PostgresFixture()
|
||||
{
|
||||
@@ -21,6 +44,11 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the isolation mode for tests.
|
||||
/// </summary>
|
||||
public PostgresIsolationMode IsolationMode { get; set; } = PostgresIsolationMode.SchemaPerTest;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the PostgreSQL container.
|
||||
/// </summary>
|
||||
@@ -51,6 +79,163 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers migrations to be applied for a module.
|
||||
/// </summary>
|
||||
public void RegisterMigrations(string module, string scriptPath)
|
||||
{
|
||||
_migrations.Add(new MigrationSource(module, scriptPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test session with appropriate isolation.
|
||||
/// </summary>
|
||||
public async Task<PostgresTestSession> CreateSessionAsync(string? testName = null)
|
||||
{
|
||||
return IsolationMode switch
|
||||
{
|
||||
PostgresIsolationMode.SchemaPerTest => await CreateSchemaSessionAsync(testName),
|
||||
PostgresIsolationMode.DatabasePerTest => await CreateDatabaseSessionAsync(testName),
|
||||
PostgresIsolationMode.Truncation => new PostgresTestSession(ConnectionString, "public", this),
|
||||
_ => throw new InvalidOperationException($"Unknown isolation mode: {IsolationMode}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a schema-isolated session for a test.
|
||||
/// </summary>
|
||||
public async Task<PostgresTestSession> CreateSchemaSessionAsync(string? testName = null)
|
||||
{
|
||||
var schemaName = $"test_{Interlocked.Increment(ref _schemaCounter):D4}_{testName ?? "anon"}";
|
||||
|
||||
await ExecuteSqlAsync($"CREATE SCHEMA IF NOT EXISTS \"{schemaName}\"");
|
||||
|
||||
// Apply migrations to the new schema
|
||||
await ApplyMigrationsAsync(schemaName);
|
||||
|
||||
var connectionString = new Npgsql.NpgsqlConnectionStringBuilder(ConnectionString)
|
||||
{
|
||||
SearchPath = schemaName
|
||||
}.ToString();
|
||||
|
||||
return new PostgresTestSession(connectionString, schemaName, this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a database-isolated session for a test.
|
||||
/// </summary>
|
||||
public async Task<PostgresTestSession> CreateDatabaseSessionAsync(string? testName = null)
|
||||
{
|
||||
var dbName = $"test_{Interlocked.Increment(ref _databaseCounter):D4}_{testName ?? "anon"}";
|
||||
|
||||
await CreateDatabaseAsync(dbName);
|
||||
|
||||
var connectionString = GetConnectionString(dbName);
|
||||
|
||||
// Apply migrations to the new database
|
||||
await ApplyMigrationsToDatabaseAsync(connectionString);
|
||||
|
||||
return new PostgresTestSession(connectionString, "public", this, dbName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates all user tables in the public schema.
|
||||
/// </summary>
|
||||
public async Task TruncateAllTablesAsync()
|
||||
{
|
||||
const string truncateSql = """
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public')
|
||||
LOOP
|
||||
EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
""";
|
||||
await ExecuteSqlAsync(truncateSql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies all registered migrations to a schema.
|
||||
/// </summary>
|
||||
public async Task ApplyMigrationsAsync(string schemaName)
|
||||
{
|
||||
foreach (var migration in _migrations)
|
||||
{
|
||||
if (File.Exists(migration.ScriptPath))
|
||||
{
|
||||
var sql = await File.ReadAllTextAsync(migration.ScriptPath);
|
||||
var schemaQualifiedSql = sql.Replace("public.", $"\"{schemaName}\".");
|
||||
await ExecuteSqlAsync(schemaQualifiedSql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies migrations from an assembly's embedded resources to a schema.
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded SQL migration resources.</param>
|
||||
/// <param name="schemaName">Target schema name.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources (e.g., "Migrations").</param>
|
||||
public async Task ApplyMigrationsFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string schemaName,
|
||||
string? resourcePrefix = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
|
||||
var resourceNames = assembly.GetManifestResourceNames()
|
||||
.Where(r => r.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(r => string.IsNullOrEmpty(resourcePrefix) || r.Contains(resourcePrefix))
|
||||
.OrderBy(r => r)
|
||||
.ToList();
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
await using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null) continue;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = await reader.ReadToEndAsync();
|
||||
|
||||
// Replace public schema with target schema
|
||||
var schemaQualifiedSql = sql.Replace("public.", $"\"{schemaName}\".");
|
||||
await ExecuteSqlAsync(schemaQualifiedSql);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies migrations from an assembly's embedded resources using a marker type.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAssemblyMarker">Type from the assembly containing migrations.</typeparam>
|
||||
/// <param name="schemaName">Target schema name.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
|
||||
public Task ApplyMigrationsFromAssemblyAsync<TAssemblyMarker>(
|
||||
string schemaName,
|
||||
string? resourcePrefix = null)
|
||||
=> ApplyMigrationsFromAssemblyAsync(typeof(TAssemblyMarker).Assembly, schemaName, resourcePrefix);
|
||||
|
||||
/// <summary>
|
||||
/// Applies all registered migrations to a database.
|
||||
/// </summary>
|
||||
private async Task ApplyMigrationsToDatabaseAsync(string connectionString)
|
||||
{
|
||||
foreach (var migration in _migrations)
|
||||
{
|
||||
if (File.Exists(migration.ScriptPath))
|
||||
{
|
||||
var sql = await File.ReadAllTextAsync(migration.ScriptPath);
|
||||
await using var conn = new Npgsql.NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new Npgsql.NpgsqlCommand(sql, conn);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a SQL command against the database.
|
||||
/// </summary>
|
||||
@@ -68,7 +253,7 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
/// </summary>
|
||||
public async Task CreateDatabaseAsync(string databaseName)
|
||||
{
|
||||
var createDbSql = $"CREATE DATABASE {databaseName}";
|
||||
var createDbSql = $"CREATE DATABASE \"{databaseName}\"";
|
||||
await ExecuteSqlAsync(createDbSql);
|
||||
}
|
||||
|
||||
@@ -77,10 +262,19 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
/// </summary>
|
||||
public async Task DropDatabaseAsync(string databaseName)
|
||||
{
|
||||
var dropDbSql = $"DROP DATABASE IF EXISTS {databaseName}";
|
||||
var dropDbSql = $"DROP DATABASE IF EXISTS \"{databaseName}\"";
|
||||
await ExecuteSqlAsync(dropDbSql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops a schema within the database.
|
||||
/// </summary>
|
||||
public async Task DropSchemaAsync(string schemaName)
|
||||
{
|
||||
var dropSchemaSql = $"DROP SCHEMA IF EXISTS \"{schemaName}\" CASCADE";
|
||||
await ExecuteSqlAsync(dropSchemaSql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a connection string for a specific database in the container.
|
||||
/// </summary>
|
||||
@@ -94,6 +288,44 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an isolated test session within PostgreSQL.
|
||||
/// </summary>
|
||||
public sealed class PostgresTestSession : IAsyncDisposable
|
||||
{
|
||||
private readonly PostgresFixture _fixture;
|
||||
private readonly string? _databaseName;
|
||||
|
||||
public PostgresTestSession(string connectionString, string schema, PostgresFixture fixture, string? databaseName = null)
|
||||
{
|
||||
ConnectionString = connectionString;
|
||||
Schema = schema;
|
||||
_fixture = fixture;
|
||||
_databaseName = databaseName;
|
||||
}
|
||||
|
||||
/// <summary>Connection string for this session.</summary>
|
||||
public string ConnectionString { get; }
|
||||
|
||||
/// <summary>Schema name for this session.</summary>
|
||||
public string Schema { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the session resources.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_databaseName != null)
|
||||
{
|
||||
await _fixture.DropDatabaseAsync(_databaseName);
|
||||
}
|
||||
else if (Schema != "public")
|
||||
{
|
||||
await _fixture.DropSchemaAsync(Schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection fixture for PostgreSQL to share the container across multiple test classes.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user