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; /// /// 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. /// 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; } /// /// Ensures the container is started and migrations are applied. /// Safe to call from multiple tests; only the first call does work. /// public async Task EnsureInitializedAsync() { if (_initialized) return; await _initLock.WaitAsync(); try { if (_initialized) return; await InitializeCoreAsync(); _initialized = true; } finally { _initLock.Release(); } } /// /// 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. /// 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.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.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.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(); } }