Files
Oleksandr Bezdieniezhnykh 24c4561bef [AZ-581] [AZ-582] [AZ-583] [AZ-584] Sec+Res NFT tests
Batch 3 of test implementation cycle 1 (existing-code Step 6).

- AZ-581 AuthClaimsTests: NFT-SEC-01..06+04b (foreign-keypair, byte-flip,
  30s skew, iss/aud/perms, multi-value permissions array).
- AZ-582 CrossCutting/ErrorRedaction/JwksRotation/StartupConfig/CorsConfig:
  NFT-SEC-07..13 (alg pin, kid rotation grace window, env fail-fast, CORS
  Production gate).
- AZ-583 CascadeF3/CascadeF4/MigratorRestart: NFT-RES-01..04. CascadeF4
  pins current walk-order divergence with carry_forward AC-4.6.
- AZ-584 ConfigDbStartup/JwksRotationNoRestart/DefaultVehicleRace:
  NFT-RES-05..08. NFT-RES-08 pins current behaviour (unique-index closes
  the race) with carry_forward AC-1.4.

Mock contract: SignBody accepts permissions OR permissions_array (mutually
exclusive). TokenSigner validates kid_override against published keys so
NFT-SEC-11 can assert "mock refuses old kid post-grace".

Helpers added: ForeignKeypair (test-only ECDSA P-256),
MissionsContainerHelper (docker-run wrapper for startup-time scenarios),
DockerLogs.

7 of 22 new tests are Skippable, gated on COMPOSE_RESTART_ENABLED + docker
CLI in the e2e-consumer image (explicit skip reason; no silent pass).

Build green: test csproj + jwks-mock csproj.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 08:58:59 +03:00

202 lines
8.0 KiB
C#

using System.Diagnostics;
using Azaion.Missions.E2E.Helpers;
using Npgsql;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Resilience;
/// <summary>
/// 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.
/// </summary>
[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<object[]> FailFastCases() => new[]
{
new object[] { "all_missing", Array.Empty<string>() },
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<string, string> BaseEnv() => new(StringComparer.Ordinal)
{
{ "DATABASE_URL", PostgresUrl },
{ "JWT_ISSUER", Issuer },
{ "JWT_AUDIENCE", Audience },
{ "JWT_JWKS_URL", JwksUrlHttps },
{ "ASPNETCORE_URLS", "http://+:8080" },
{ "ASPNETCORE_ENVIRONMENT","Test" },
};
}