stabilize tests
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared PostgreSQL container fixture for tests that need a real database.
|
||||
/// Uses lazy initialization (thread-safe) so the container is started once
|
||||
/// regardless of whether xUnit calls IAsyncLifetime on class fixtures.
|
||||
/// </summary>
|
||||
public sealed class PostgreSqlFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
private bool _initialized;
|
||||
|
||||
public PostgreSqlContainer? Postgres { get; private set; }
|
||||
public EvidenceLockerDataSource? DataSource { get; private set; }
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the container is started and migrations are applied.
|
||||
/// Safe to call from multiple tests; only the first call does work.
|
||||
/// </summary>
|
||||
public async Task EnsureInitializedAsync()
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
await _initLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_initialized) return;
|
||||
await InitializeCoreAsync();
|
||||
_initialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast check for Docker availability before attempting to start a container.
|
||||
/// Testcontainers can hang for minutes when Docker Desktop is not running,
|
||||
/// consuming gigabytes of memory in the process.
|
||||
/// </summary>
|
||||
private static bool IsDockerLikelyAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// On Windows, try to open the Docker named pipe with a short timeout.
|
||||
// File.Exists does not work for named pipes.
|
||||
using var pipe = new System.IO.Pipes.NamedPipeClientStream(".", "docker_engine", System.IO.Pipes.PipeDirection.InOut, System.IO.Pipes.PipeOptions.None);
|
||||
pipe.Connect(2000); // 2 second timeout
|
||||
return true;
|
||||
}
|
||||
|
||||
// On Linux/macOS, check for the Docker socket
|
||||
return File.Exists("/var/run/docker.sock");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeCoreAsync()
|
||||
{
|
||||
if (!IsDockerLikelyAvailable())
|
||||
{
|
||||
SkipReason = "Docker is not available (cannot connect to Docker pipe/socket)";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Postgres = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17-alpine")
|
||||
.WithDatabase("evidence_locker_tests")
|
||||
.WithUsername("postgres")
|
||||
.WithPassword("postgres")
|
||||
.Build();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await Postgres.StartAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
SkipReason = "Docker container start timed out after 30s";
|
||||
return;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
SkipReason = $"Docker endpoint unavailable: {ex.Message}";
|
||||
return;
|
||||
}
|
||||
catch (ArgumentException ex) when (ex.Message.Contains("Docker", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SkipReason = $"Docker unavailable: {ex.Message}";
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex.Message.Contains("Docker") || ex.Message.Contains("CreateClient"))
|
||||
{
|
||||
SkipReason = $"Docker unavailable: {ex.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
var databaseOptions = new DatabaseOptions
|
||||
{
|
||||
ConnectionString = Postgres.GetConnectionString(),
|
||||
ApplyMigrationsAtStartup = false
|
||||
};
|
||||
|
||||
DataSource = new EvidenceLockerDataSource(databaseOptions, NullLogger<EvidenceLockerDataSource>.Instance);
|
||||
|
||||
try
|
||||
{
|
||||
// Verify embedded SQL resources are discoverable before running migration
|
||||
var scripts = MigrationLoader.LoadAll();
|
||||
if (scripts.Count == 0)
|
||||
{
|
||||
SkipReason = "Migration aborted: MigrationLoader.LoadAll() returned 0 scripts (embedded resources not found)";
|
||||
return;
|
||||
}
|
||||
|
||||
var migrationRunner = new EvidenceLockerMigrationRunner(DataSource, NullLogger<EvidenceLockerMigrationRunner>.Instance);
|
||||
await migrationRunner.ApplyAsync(CancellationToken.None);
|
||||
|
||||
// Create a non-superuser role for testing RLS.
|
||||
// Superusers bypass RLS even with FORCE ROW LEVEL SECURITY,
|
||||
// so tests must use a regular role.
|
||||
await using var setupConn = await DataSource.OpenConnectionAsync(CancellationToken.None);
|
||||
await using var roleCmd = new Npgsql.NpgsqlCommand(@"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'evidence_app') THEN
|
||||
CREATE ROLE evidence_app LOGIN PASSWORD 'evidence_app' NOBYPASSRLS;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
GRANT USAGE ON SCHEMA evidence_locker TO evidence_app;
|
||||
GRANT USAGE ON SCHEMA evidence_locker_app TO evidence_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA evidence_locker TO evidence_app;
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA evidence_locker_app TO evidence_app;
|
||||
", setupConn);
|
||||
await roleCmd.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
await setupConn.CloseAsync();
|
||||
|
||||
// Reconnect using the non-superuser role so RLS policies are enforced
|
||||
var appConnectionString = Postgres.GetConnectionString()
|
||||
.Replace("Username=postgres", "Username=evidence_app")
|
||||
.Replace("Password=postgres", "Password=evidence_app");
|
||||
await DataSource.DisposeAsync();
|
||||
DataSource = new EvidenceLockerDataSource(
|
||||
new DatabaseOptions { ConnectionString = appConnectionString, ApplyMigrationsAtStartup = false },
|
||||
NullLogger<EvidenceLockerDataSource>.Instance);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason = $"Migration failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// IAsyncLifetime - called by xUnit if it supports it on class fixtures
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (DataSource is not null)
|
||||
{
|
||||
await DataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
if (Postgres is not null)
|
||||
{
|
||||
await Postgres.DisposeAsync();
|
||||
}
|
||||
|
||||
_initLock.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user