198 lines
7.3 KiB
C#
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();
|
|
}
|
|
}
|