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:
master
2025-12-23 18:56:12 +02:00
committed by StellaOps Bot
parent 7ac70ece71
commit 491e883653
409 changed files with 23797 additions and 17779 deletions

View File

@@ -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>