using System.Diagnostics; using Azaion.Missions.E2E.Helpers; using Npgsql; using Xunit; namespace Azaion.Missions.E2E.Tests.Resilience; /// /// NFT-RES-05 (config fail-fast + DB-down differentiator) and /// NFT-RES-06 (Npgsql 3D000 on missing database). The 4 missing-env rows /// overlap with NFT-SEC-12 in the security category — same docker-run /// primitive, separate Sec/Res CSV rows. /// Traces: AC-6.1, AC-6.2, AC-6.7, AC-6.8, E3, E4. /// [Collection("MigratorRestart")] [Trait("Category", "Res")] [Trait("db_access", "seed-or-assert-only")] public sealed class ConfigDbStartupTests { private const string PostgresUrl = "postgresql://postgres:postgres-test@missions-postgres-test:5432/azaion"; private const string JwksUrlHttps = "https://jwks-mock:8443/.well-known/jwks.json"; private const string Issuer = "https://admin-test.azaion.local"; private const string Audience = "azaion-edge"; public static IEnumerable FailFastCases() => new[] { new object[] { "all_missing", Array.Empty() }, new object[] { "db_url_missing", new[] { "DATABASE_URL" } }, new object[] { "jwt_issuer_missing", new[] { "JWT_ISSUER" } }, new object[] { "jwt_audience_missing", new[] { "JWT_AUDIENCE" } }, new object[] { "jwks_url_missing", new[] { "JWT_JWKS_URL" } }, }; [SkippableTheory] [MemberData(nameof(FailFastCases))] [Trait("Traces", "AC-6.1,AC-6.2,E3")] [Trait("max_ms", "30000")] public void NFT_RES_05_missing_required_env_var_throws_invalid_operation_exception( string caseName, string[] omittedVars) { Skip.IfNot(MissionsContainerHelper.Enabled, "MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access."); // Arrange var env = BaseEnv(); foreach (var v in omittedVars) env.Remove(v); if (omittedVars.Length == 0) { env.Remove("DATABASE_URL"); env.Remove("JWT_ISSUER"); env.Remove("JWT_AUDIENCE"); env.Remove("JWT_JWKS_URL"); } // Act var result = MissionsContainerHelper.RunUntilExit( $"missions-res05-{caseName}", env, TimeSpan.FromSeconds(20)); // Assert Assert.NotEqual(0, result.ExitCode); Assert.Contains("InvalidOperationException", result.Logs, StringComparison.Ordinal); } [SkippableFact] [Trait("Traces", "AC-6.1,E3")] [Trait("max_ms", "30000")] public void NFT_RES_05_whitespace_required_env_var_treated_as_missing() { Skip.IfNot(MissionsContainerHelper.Enabled, "MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access."); // Arrange — whitespace-only value triggers the same fail-fast path // as an absent value (ResolveRequiredOrThrow uses IsNullOrWhiteSpace). var env = BaseEnv(); env["JWT_ISSUER"] = " "; // Act var result = MissionsContainerHelper.RunUntilExit( "missions-res05-whitespace-iss", env, TimeSpan.FromSeconds(20)); // Assert Assert.NotEqual(0, result.ExitCode); Assert.Contains("InvalidOperationException", result.Logs, StringComparison.Ordinal); var mentionsIssuer = result.Logs.Contains("JWT_ISSUER", StringComparison.Ordinal) || result.Logs.Contains("Jwt:Issuer", StringComparison.Ordinal); Assert.True(mentionsIssuer, $"logs must mention JWT_ISSUER. Logs:\n{result.Logs}"); } [SkippableFact] [Trait("Traces", "AC-6.7,E4")] [Trait("max_ms", "60000")] public void NFT_RES_05_db_down_after_config_resolution_logs_npgsql_connection_refused() { Skip.IfNot(MissionsContainerHelper.Enabled, "MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access."); // Arrange — all 4 required vars set, but point DATABASE_URL at a // host that is not running. Config resolution succeeds; Npgsql // fails on the migrator's first connection attempt. var env = BaseEnv(); env["DATABASE_URL"] = "postgresql://postgres:postgres-test@nonexistent-host-for-res05:5432/azaion"; // Act var result = MissionsContainerHelper.RunUntilExit( "missions-res05-db-down", env, TimeSpan.FromSeconds(45)); // Assert Assert.NotEqual(0, result.ExitCode); // Connection-refused / name-not-resolved / unreachable are the // acceptable Npgsql failure shapes; the differentiator is that // InvalidOperationException must NOT appear — proving config // resolution completed before the connection broke. Assert.DoesNotContain("InvalidOperationException", result.Logs, StringComparison.Ordinal); var connectionShape = result.Logs.Contains("Connection refused", StringComparison.OrdinalIgnoreCase) || result.Logs.Contains("could not resolve", StringComparison.OrdinalIgnoreCase) || result.Logs.Contains("could not connect", StringComparison.OrdinalIgnoreCase) || result.Logs.Contains("Name or service not known", StringComparison.OrdinalIgnoreCase) || result.Logs.Contains("Temporary failure in name resolution", StringComparison.OrdinalIgnoreCase); Assert.True(connectionShape, $"logs must show Npgsql connection failure (not InvalidOperationException). Logs:\n{result.Logs}"); } [SkippableFact] [Trait("Traces", "AC-6.8")] [Trait("max_ms", "60000")] public void NFT_RES_06_dropping_target_database_causes_3D000_exit() { Skip.IfNot(MissionsContainerHelper.Enabled, "Requires docker CLI + COMPOSE_RESTART_ENABLED=1 + Postgres admin access."); // Arrange — drop the azaion database via a side-channel that // connects to the `postgres` admin DB. Caller is responsible for // recreating the DB in teardown (handled by ComposeRestartFixture // in the surrounding collection). try { DropAzaionDatabase(); } catch (PostgresException ex) { Skip.If(true, $"could not drop azaion database for NFT-RES-06 setup ({ex.SqlState}: {ex.MessageText}); " + "the test requires superuser admin access on the postgres-test container."); return; } try { // Act var result = MissionsContainerHelper.RunUntilExit( "missions-res06-dropdb", BaseEnv(), TimeSpan.FromSeconds(45)); // Assert Assert.NotEqual(0, result.ExitCode); Assert.Contains("3D000", result.Logs, StringComparison.Ordinal); } finally { RestoreAzaionDatabase(); } } private static void DropAzaionDatabase() { var adminConn = TestEnvironment.DbSideChannel .Replace("Database=azaion", "Database=postgres", StringComparison.Ordinal); using var conn = new NpgsqlConnection(adminConn); conn.Open(); using var cmd = conn.CreateCommand(); // WITH (FORCE) terminates any other backends still on azaion. cmd.CommandText = "DROP DATABASE IF EXISTS azaion WITH (FORCE);"; cmd.ExecuteNonQuery(); } private static void RestoreAzaionDatabase() { var adminConn = TestEnvironment.DbSideChannel .Replace("Database=azaion", "Database=postgres", StringComparison.Ordinal); using var conn = new NpgsqlConnection(adminConn); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = "CREATE DATABASE azaion;"; cmd.ExecuteNonQuery(); } private static Dictionary BaseEnv() => new(StringComparer.Ordinal) { { "DATABASE_URL", PostgresUrl }, { "JWT_ISSUER", Issuer }, { "JWT_AUDIENCE", Audience }, { "JWT_JWKS_URL", JwksUrlHttps }, { "ASPNETCORE_URLS", "http://+:8080" }, { "ASPNETCORE_ENVIRONMENT","Test" }, }; }