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

@@ -0,0 +1,200 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Helpers for API contract testing using OpenAPI schema snapshots.
/// </summary>
public static class ContractTestHelper
{
/// <summary>
/// Fetches and validates the OpenAPI schema against a snapshot.
/// </summary>
public static async Task ValidateOpenApiSchemaAsync<TProgram>(
WebApplicationFactory<TProgram> factory,
string expectedSnapshotPath,
string swaggerEndpoint = "/swagger/v1/swagger.json")
where TProgram : class
{
using var client = factory.CreateClient();
var response = await client.GetAsync(swaggerEndpoint);
response.EnsureSuccessStatusCode();
var actualSchema = await response.Content.ReadAsStringAsync();
if (ShouldUpdateSnapshots())
{
await UpdateSnapshotAsync(expectedSnapshotPath, actualSchema);
return;
}
var expectedSchema = await File.ReadAllTextAsync(expectedSnapshotPath);
// Normalize both for comparison
var actualNormalized = NormalizeOpenApiSchema(actualSchema);
var expectedNormalized = NormalizeOpenApiSchema(expectedSchema);
actualNormalized.Should().Be(expectedNormalized,
"OpenAPI schema should match snapshot. Set STELLAOPS_UPDATE_FIXTURES=true to update.");
}
/// <summary>
/// Validates that the schema contains expected endpoints.
/// </summary>
public static async Task ValidateEndpointsExistAsync<TProgram>(
WebApplicationFactory<TProgram> factory,
IEnumerable<string> expectedEndpoints,
string swaggerEndpoint = "/swagger/v1/swagger.json")
where TProgram : class
{
using var client = factory.CreateClient();
var response = await client.GetAsync(swaggerEndpoint);
response.EnsureSuccessStatusCode();
var schemaJson = await response.Content.ReadAsStringAsync();
var schema = JsonDocument.Parse(schemaJson);
var paths = schema.RootElement.GetProperty("paths");
foreach (var endpoint in expectedEndpoints)
{
paths.TryGetProperty(endpoint, out _).Should().BeTrue(
$"Expected endpoint '{endpoint}' should exist in OpenAPI schema");
}
}
/// <summary>
/// Detects breaking changes between current schema and snapshot.
/// </summary>
public static async Task<SchemaBreakingChanges> DetectBreakingChangesAsync<TProgram>(
WebApplicationFactory<TProgram> factory,
string snapshotPath,
string swaggerEndpoint = "/swagger/v1/swagger.json")
where TProgram : class
{
using var client = factory.CreateClient();
var response = await client.GetAsync(swaggerEndpoint);
response.EnsureSuccessStatusCode();
var actualSchema = await response.Content.ReadAsStringAsync();
if (!File.Exists(snapshotPath))
{
return new SchemaBreakingChanges(new List<string> { "No previous snapshot exists" }, new List<string>());
}
var expectedSchema = await File.ReadAllTextAsync(snapshotPath);
return CompareSchemas(expectedSchema, actualSchema);
}
private static SchemaBreakingChanges CompareSchemas(string expected, string actual)
{
var breakingChanges = new List<string>();
var nonBreakingChanges = new List<string>();
try
{
var expectedDoc = JsonDocument.Parse(expected);
var actualDoc = JsonDocument.Parse(actual);
// Check for removed endpoints (breaking)
if (expectedDoc.RootElement.TryGetProperty("paths", out var expectedPaths) &&
actualDoc.RootElement.TryGetProperty("paths", out var actualPaths))
{
foreach (var path in expectedPaths.EnumerateObject())
{
if (!actualPaths.TryGetProperty(path.Name, out _))
{
breakingChanges.Add($"Endpoint removed: {path.Name}");
}
else
{
// Check for removed methods
foreach (var method in path.Value.EnumerateObject())
{
if (!actualPaths.GetProperty(path.Name).TryGetProperty(method.Name, out _))
{
breakingChanges.Add($"Method removed: {method.Name.ToUpper()} {path.Name}");
}
}
}
}
// Check for new endpoints (non-breaking)
foreach (var path in actualPaths.EnumerateObject())
{
if (!expectedPaths.TryGetProperty(path.Name, out _))
{
nonBreakingChanges.Add($"Endpoint added: {path.Name}");
}
}
}
// Check for removed schemas (breaking)
if (expectedDoc.RootElement.TryGetProperty("components", out var expectedComponents) &&
expectedComponents.TryGetProperty("schemas", out var expectedSchemas) &&
actualDoc.RootElement.TryGetProperty("components", out var actualComponents) &&
actualComponents.TryGetProperty("schemas", out var actualSchemas))
{
foreach (var schema in expectedSchemas.EnumerateObject())
{
if (!actualSchemas.TryGetProperty(schema.Name, out _))
{
breakingChanges.Add($"Schema removed: {schema.Name}");
}
}
}
}
catch (JsonException ex)
{
breakingChanges.Add($"Schema parse error: {ex.Message}");
}
return new SchemaBreakingChanges(breakingChanges, nonBreakingChanges);
}
private static string NormalizeOpenApiSchema(string schema)
{
try
{
var doc = JsonDocument.Parse(schema);
// Remove non-deterministic fields
return JsonSerializer.Serialize(doc, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
catch
{
return schema;
}
}
private static bool ShouldUpdateSnapshots()
{
return Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
}
private static async Task UpdateSnapshotAsync(string path, string content)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
// Pretty-print for readability
var doc = JsonDocument.Parse(content);
var pretty = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(path, pretty);
}
}
/// <summary>
/// Result of schema breaking change detection.
/// </summary>
public sealed record SchemaBreakingChanges(
IReadOnlyList<string> BreakingChanges,
IReadOnlyList<string> NonBreakingChanges)
{
public bool HasBreakingChanges => BreakingChanges.Count > 0;
}

View File

@@ -0,0 +1,152 @@
using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Provides an in-memory HTTP test server using WebApplicationFactory for contract testing.
/// </summary>
/// <typeparam name="TProgram">The entry point type of the web application (usually Program).</typeparam>
/// <remarks>
/// Usage:
/// <code>
/// public class ApiTests : IClassFixture&lt;HttpFixtureServer&lt;Program&gt;&gt;
/// {
/// private readonly HttpClient _client;
///
/// public ApiTests(HttpFixtureServer&lt;Program&gt; fixture)
/// {
/// _client = fixture.CreateClient();
/// }
///
/// [Fact]
/// public async Task GetHealth_ReturnsOk()
/// {
/// var response = await _client.GetAsync("/health");
/// response.EnsureSuccessStatusCode();
/// }
/// }
/// </code>
/// </remarks>
public sealed class HttpFixtureServer<TProgram> : WebApplicationFactory<TProgram>
where TProgram : class
{
private readonly Action<IServiceCollection>? _configureServices;
/// <summary>
/// Creates a new HTTP fixture server with optional service configuration.
/// </summary>
/// <param name="configureServices">Optional action to configure test services (e.g., replace dependencies with mocks).</param>
public HttpFixtureServer(Action<IServiceCollection>? configureServices = null)
{
_configureServices = configureServices;
}
/// <summary>
/// Configures the web host for testing (disables HTTPS redirection, applies custom services).
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Apply user-provided service configuration (e.g., mock dependencies)
_configureServices?.Invoke(services);
});
builder.UseEnvironment("Test");
}
/// <summary>
/// Creates an HttpClient configured to communicate with the test server.
/// </summary>
public new HttpClient CreateClient()
{
return base.CreateClient();
}
/// <summary>
/// Creates an HttpClient with custom configuration.
/// </summary>
public HttpClient CreateClient(Action<HttpClient> configure)
{
var client = CreateClient();
configure(client);
return client;
}
}
/// <summary>
/// Provides a stub HTTP message handler for hermetic HTTP tests without external dependencies.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// var handler = new HttpMessageHandlerStub()
/// .WhenRequest("https://api.example.com/data")
/// .Responds(HttpStatusCode.OK, "{\"status\":\"ok\"}");
///
/// var httpClient = new HttpClient(handler);
/// var response = await httpClient.GetAsync("https://api.example.com/data");
/// // response.StatusCode == HttpStatusCode.OK
/// </code>
/// </remarks>
public sealed class HttpMessageHandlerStub : HttpMessageHandler
{
private readonly Dictionary<string, Func<HttpRequestMessage, Task<HttpResponseMessage>>> _handlers = new();
private Func<HttpRequestMessage, Task<HttpResponseMessage>>? _defaultHandler;
/// <summary>
/// Configures a response for a specific URL.
/// </summary>
public HttpMessageHandlerStub WhenRequest(string url, Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
{
_handlers[url] = handler;
return this;
}
/// <summary>
/// Configures a simple response for a specific URL.
/// </summary>
public HttpMessageHandlerStub WhenRequest(string url, HttpStatusCode statusCode, string? content = null)
{
return WhenRequest(url, _ => Task.FromResult(new HttpResponseMessage(statusCode)
{
Content = content != null ? new StringContent(content) : null
}));
}
/// <summary>
/// Configures a default handler for unmatched requests.
/// </summary>
public HttpMessageHandlerStub WhenAnyRequest(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
{
_defaultHandler = handler;
return this;
}
/// <summary>
/// Sends the HTTP request through the stub handler.
/// </summary>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri?.ToString() ?? string.Empty;
if (_handlers.TryGetValue(url, out var handler))
{
return await handler(request);
}
if (_defaultHandler != null)
{
return await _defaultHandler(request);
}
// Default: 404 Not Found for unmatched requests
return new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No stub configured for {url}")
};
}
}

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>

View File

@@ -1,56 +1,264 @@
using Testcontainers.Redis;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using StackExchange.Redis;
using Xunit;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Test fixture for Valkey (Redis-compatible) using Testcontainers.
/// Provides an isolated Valkey instance for integration tests.
/// Isolation modes for Valkey/Redis test fixtures.
/// </summary>
public sealed class ValkeyFixture : IAsyncLifetime
public enum ValkeyIsolationMode
{
private readonly RedisContainer _container;
/// <summary>Each test gets its own database (0-15). Default, good isolation.</summary>
DatabasePerTest,
/// <summary>Flush the current database between tests. Faster but shared.</summary>
FlushDb,
/// <summary>Flush all databases between tests. Maximum cleanup.</summary>
FlushAll
}
public ValkeyFixture()
{
_container = new RedisBuilder()
.WithImage("valkey/valkey:8-alpine")
.Build();
}
/// <summary>
/// Provides a Testcontainers-based Valkey (Redis-compatible) instance for integration tests.
/// </summary>
/// <remarks>
/// Usage with xUnit:
/// <code>
/// public class MyTests : IClassFixture&lt;ValkeyFixture&gt;
/// {
/// private readonly ValkeyFixture _fixture;
///
/// public MyTests(ValkeyFixture fixture)
/// {
/// _fixture = fixture;
/// }
///
/// [Fact]
/// public async Task TestCache()
/// {
/// await using var session = await _fixture.CreateSessionAsync();
/// await session.Database.StringSetAsync("key", "value");
/// // ...
/// }
/// }
/// </code>
/// </remarks>
public sealed class ValkeyFixture : IAsyncLifetime, IDisposable
{
private IContainer? _container;
private ConnectionMultiplexer? _connection;
private bool _disposed;
private int _databaseCounter;
/// <summary>
/// Gets the connection string for the Valkey container.
/// Gets the Redis/Valkey connection string (format: "host:port").
/// </summary>
public string ConnectionString => _container.GetConnectionString();
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>
/// Gets the hostname of the Valkey container.
/// Gets the Redis/Valkey host.
/// </summary>
public string Host => _container.Hostname;
public string Host { get; private set; } = string.Empty;
/// <summary>
/// Gets the exposed port of the Valkey container.
/// Gets the Redis/Valkey port.
/// </summary>
public ushort Port => _container.GetMappedPublicPort(6379);
public int Port { get; private set; }
/// <summary>
/// Gets or sets the isolation mode for tests.
/// </summary>
public ValkeyIsolationMode IsolationMode { get; set; } = ValkeyIsolationMode.DatabasePerTest;
/// <summary>
/// Gets the underlying connection multiplexer.
/// </summary>
public ConnectionMultiplexer? Connection => _connection;
/// <summary>
/// Initializes the Valkey container asynchronously.
/// </summary>
public async Task InitializeAsync()
{
// Use official Redis image (Valkey is Redis-compatible)
// In production deployments, substitute with valkey/valkey image if needed
_container = new ContainerBuilder()
.WithImage("redis:7-alpine")
.WithPortBinding(6379, true) // Bind to random host port
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
.Build();
await _container.StartAsync();
Host = _container.Hostname;
Port = _container.GetMappedPublicPort(6379);
ConnectionString = $"{Host}:{Port}";
_connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString);
}
/// <summary>
/// Creates a new test session with appropriate isolation.
/// </summary>
public async Task<ValkeyTestSession> CreateSessionAsync(string? testName = null)
{
if (_connection == null)
{
throw new InvalidOperationException("Fixture not initialized. Call InitializeAsync first.");
}
return IsolationMode switch
{
ValkeyIsolationMode.DatabasePerTest => await CreateDatabaseSessionAsync(testName),
ValkeyIsolationMode.FlushDb => await CreateFlushDbSessionAsync(),
ValkeyIsolationMode.FlushAll => await CreateFlushAllSessionAsync(),
_ => throw new InvalidOperationException($"Unknown isolation mode: {IsolationMode}")
};
}
/// <summary>
/// Creates a database-isolated session (database 0-15).
/// </summary>
private async Task<ValkeyTestSession> CreateDatabaseSessionAsync(string? testName = null)
{
var dbIndex = Interlocked.Increment(ref _databaseCounter) % 16;
var db = _connection!.GetDatabase(dbIndex);
// Flush this specific database before use
var server = _connection.GetServer(ConnectionString);
await server.FlushDatabaseAsync(dbIndex);
return new ValkeyTestSession(_connection, db, dbIndex, this, testName);
}
/// <summary>
/// Creates a session that flushes the current database.
/// </summary>
private async Task<ValkeyTestSession> CreateFlushDbSessionAsync()
{
var db = _connection!.GetDatabase(0);
var server = _connection.GetServer(ConnectionString);
await server.FlushDatabaseAsync(0);
return new ValkeyTestSession(_connection, db, 0, this, null);
}
/// <summary>
/// Creates a session that flushes all databases.
/// </summary>
private async Task<ValkeyTestSession> CreateFlushAllSessionAsync()
{
var server = _connection!.GetServer(ConnectionString);
await server.FlushAllDatabasesAsync();
var db = _connection.GetDatabase(0);
return new ValkeyTestSession(_connection, db, 0, this, null);
}
/// <summary>
/// Flushes a specific database.
/// </summary>
public async Task FlushDatabaseAsync(int databaseIndex)
{
if (_connection == null) return;
var server = _connection.GetServer(ConnectionString);
await server.FlushDatabaseAsync(databaseIndex);
}
/// <summary>
/// Flushes all databases.
/// </summary>
public async Task FlushAllAsync()
{
if (_connection == null) return;
var server = _connection.GetServer(ConnectionString);
await server.FlushAllDatabasesAsync();
}
/// <summary>
/// Gets a database by index.
/// </summary>
public IDatabase GetDatabase(int dbIndex = 0)
{
if (_connection == null)
{
throw new InvalidOperationException("Fixture not initialized. Call InitializeAsync first.");
}
return _connection.GetDatabase(dbIndex);
}
/// <summary>
/// Disposes the Valkey container asynchronously.
/// </summary>
public async Task DisposeAsync()
{
await _container.DisposeAsync();
if (_connection != null)
{
await _connection.CloseAsync();
_connection.Dispose();
}
if (_container != null)
{
await _container.StopAsync();
await _container.DisposeAsync();
}
}
/// <summary>
/// Disposes the fixture.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
DisposeAsync().GetAwaiter().GetResult();
_disposed = true;
}
}
/// <summary>
/// Collection fixture for Valkey to share the container across multiple test classes.
/// Represents an isolated test session within Valkey/Redis.
/// </summary>
[CollectionDefinition("Valkey")]
public class ValkeyCollection : ICollectionFixture<ValkeyFixture>
public sealed class ValkeyTestSession : IAsyncDisposable
{
// This class has no code, and is never created. Its purpose is simply
// to be the place to apply [CollectionDefinition] and all the
// ICollectionFixture<> interfaces.
private readonly ValkeyFixture _fixture;
public ValkeyTestSession(
ConnectionMultiplexer connection,
IDatabase database,
int databaseIndex,
ValkeyFixture fixture,
string? testName)
{
Connection = connection;
Database = database;
DatabaseIndex = databaseIndex;
_fixture = fixture;
TestName = testName;
}
/// <summary>The underlying connection multiplexer.</summary>
public ConnectionMultiplexer Connection { get; }
/// <summary>The database for this session.</summary>
public IDatabase Database { get; }
/// <summary>The database index (0-15).</summary>
public int DatabaseIndex { get; }
/// <summary>Optional test name for debugging.</summary>
public string? TestName { get; }
/// <summary>
/// Cleans up the session resources.
/// </summary>
public async ValueTask DisposeAsync()
{
// Flush this database on cleanup
await _fixture.FlushDatabaseAsync(DatabaseIndex);
}
}

View File

@@ -0,0 +1,180 @@
using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Test fixture for ASP.NET web services using WebApplicationFactory.
/// Provides isolated service hosting with deterministic configuration.
/// </summary>
/// <typeparam name="TProgram">The program entry point (typically Program class).</typeparam>
public class WebServiceFixture<TProgram> : WebApplicationFactory<TProgram>, IAsyncLifetime
where TProgram : class
{
private readonly Action<IServiceCollection>? _configureServices;
private readonly Action<IWebHostBuilder>? _configureWebHost;
public WebServiceFixture(
Action<IServiceCollection>? configureServices = null,
Action<IWebHostBuilder>? configureWebHost = null)
{
_configureServices = configureServices;
_configureWebHost = configureWebHost;
}
/// <summary>
/// Gets the environment name for tests. Defaults to "Testing".
/// </summary>
protected virtual string EnvironmentName => "Testing";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment(EnvironmentName);
builder.ConfigureServices(services =>
{
// Add default test services
services.AddSingleton<TestRequestContext>();
// Apply custom configuration
_configureServices?.Invoke(services);
});
_configureWebHost?.Invoke(builder);
}
/// <summary>
/// Creates an HttpClient with optional authentication.
/// </summary>
public HttpClient CreateAuthenticatedClient(string? bearerToken = null)
{
var client = CreateClient();
if (bearerToken != null)
{
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearerToken);
}
return client;
}
/// <summary>
/// Creates an HttpClient with a specific tenant header.
/// </summary>
public HttpClient CreateTenantClient(string tenantId, string? bearerToken = null)
{
var client = CreateAuthenticatedClient(bearerToken);
client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId);
return client;
}
public virtual Task InitializeAsync() => Task.CompletedTask;
Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask;
}
/// <summary>
/// Provides test request context for tracking.
/// </summary>
public sealed class TestRequestContext
{
private readonly List<RequestRecord> _requests = new();
public void RecordRequest(string method, string path, int statusCode)
{
lock (_requests)
{
_requests.Add(new RequestRecord(method, path, statusCode, DateTime.UtcNow));
}
}
public IReadOnlyList<RequestRecord> GetRequests()
{
lock (_requests)
{
return _requests.ToList();
}
}
public sealed record RequestRecord(string Method, string Path, int StatusCode, DateTime Timestamp);
}
/// <summary>
/// Extension methods for web service testing.
/// </summary>
public static class WebServiceTestExtensions
{
/// <summary>
/// Sends a request with malformed content type header.
/// </summary>
public static async Task<HttpResponseMessage> SendWithMalformedContentTypeAsync(
this HttpClient client,
HttpMethod method,
string url,
string? body = null)
{
var request = new HttpRequestMessage(method, url);
if (body != null)
{
request.Content = new StringContent(body);
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/malformed-type");
}
return await client.SendAsync(request);
}
/// <summary>
/// Sends a request with oversized payload.
/// </summary>
public static async Task<HttpResponseMessage> SendOversizedPayloadAsync(
this HttpClient client,
string url,
int sizeInBytes)
{
var payload = new string('x', sizeInBytes);
var content = new StringContent($"{{\"data\":\"{payload}\"}}");
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
return await client.PostAsync(url, content);
}
/// <summary>
/// Sends a request with wrong HTTP method.
/// </summary>
public static async Task<HttpResponseMessage> SendWithWrongMethodAsync(
this HttpClient client,
string url,
HttpMethod expectedMethod)
{
// If expected is POST, send GET; if expected is GET, send DELETE, etc.
var wrongMethod = expectedMethod == HttpMethod.Get ? HttpMethod.Delete : HttpMethod.Get;
return await client.SendAsync(new HttpRequestMessage(wrongMethod, url));
}
/// <summary>
/// Sends a request without authentication.
/// </summary>
public static async Task<HttpResponseMessage> SendWithoutAuthAsync(
this HttpClient client,
HttpMethod method,
string url)
{
// Remove any existing auth header
client.DefaultRequestHeaders.Authorization = null;
return await client.SendAsync(new HttpRequestMessage(method, url));
}
/// <summary>
/// Sends a request with expired token.
/// </summary>
public static async Task<HttpResponseMessage> SendWithExpiredTokenAsync(
this HttpClient client,
string url,
string expiredToken)
{
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", expiredToken);
return await client.GetAsync(url);
}
}