Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Tests/PostgreSqlFixture.cs
2026-02-02 08:57:29 +02:00

198 lines
7.3 KiB
C#

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))
{
// Check if the Docker daemon is actually running by looking for its process.
// NamedPipeClientStream.Connect() hangs indefinitely when Docker Desktop is
// installed but not running (the pipe exists but nobody reads from it).
// Testcontainers' own Docker client also hangs in this scenario.
// Checking for a running process is instant and avoids the hang entirely.
var dockerProcesses = System.Diagnostics.Process.GetProcessesByName("com.docker.backend");
if (dockerProcesses.Length == 0)
dockerProcesses = System.Diagnostics.Process.GetProcessesByName("dockerd");
foreach (var p in dockerProcesses) p.Dispose();
return dockerProcesses.Length > 0;
}
// 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();
}
}