[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-15 08:58:59 +03:00
parent 6b2c2d998e
commit 24c4561bef
24 changed files with 2240 additions and 3 deletions
@@ -0,0 +1,235 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Security;
/// <summary>
/// NFT-SEC-01..06 + 04b — JWT authn/authz scenarios from
/// <c>_docs/02_document/tests/security-tests.md</c>.
/// Traces: AC-5.2..AC-5.6, AC-5.8, AC-5.11, AC-5.12, AC-9.1, AC-9.2.
/// </summary>
[Collection("SecurityAuthClaims")]
[Trait("Category", "Sec")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
{
[Fact]
[Trait("Traces", "AC-5.4")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_01_missing_authorization_header_rejects_protected_endpoints_with_401_and_no_db_write()
{
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
var anyMissionId = Guid.NewGuid();
var preCount = DbAssertions.TableRowCount("vehicles");
// Act + Assert — GET /vehicles
using (var resp = await Missions.GetAsync("/vehicles"))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
using (var resp = await Missions.GetAsync("/missions"))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
using (var resp = await Missions.GetAsync($"/missions/{anyMissionId}/waypoints"))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
var postBody = new
{
Type = 0,
Model = "Bayraktar",
Name = "BR-noauth",
FuelType = 1,
BatteryCapacity = 0,
EngineConsumption = 5,
EngineConsumptionIdle = 1,
IsDefault = false
};
using (var post = new HttpRequestMessage(HttpMethod.Post, "/vehicles")
{
Content = JsonContent.Create(postBody)
})
using (var resp = await Missions.SendAsync(post))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
// Assert — POST 401 did not write a row.
var postCount = DbAssertions.TableRowCount("vehicles");
Assert.Equal(preCount, postCount);
}
[Fact]
[Trait("Traces", "AC-5.5")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_02_invalid_signature_rejects_byte_flip_and_foreign_keypair_with_401()
{
// Arrange — single-byte-flip uses a mock-signed token; foreign-keypair
// uses a local ECDSA P-256 (the one in-test signing path the task
// spec permits).
var good = await Tokens.MintDefaultAsync();
var flipped = FlipFirstSignatureChar(good.Jwt);
using var foreign = new ForeignKeypair();
var foreignJwt = foreign.Mint(
TestEnvironment.JwtIssuer, TestEnvironment.JwtAudience, "FL");
// Act + Assert — flipped signature
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", flipped);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
}
// Act + Assert — foreign keypair token (kid not in JWKS).
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", foreignJwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
}
}
[Fact]
[Trait("Traces", "AC-5.2,AC-5.6")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_03_clock_skew_30s_rejects_minus_60_and_accepts_minus_15()
{
// Arrange — both tokens are otherwise identical; only exp differs.
var expiredBeyondSkew = await Tokens.MintAsync(
new SignRequest(Permissions: "FL", ExpOffsetSeconds: -60));
var expiredWithinSkew = await Tokens.MintAsync(
new SignRequest(Permissions: "FL", ExpOffsetSeconds: -15));
// Act + Assert — outside the 30s skew window.
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredBeyondSkew.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
}
// Inside the 30s skew window.
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredWithinSkew.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
}
}
[Fact]
[Trait("Traces", "AC-5.3,AC-5.11")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_04_wrong_iss_rejected_default_iss_accepted()
{
// Arrange
var wrongIss = await Tokens.MintAsync(
new SignRequest(Iss: "https://attacker.example.com", Permissions: "FL"));
var defaultIss = await Tokens.MintDefaultAsync();
// Act + Assert
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongIss.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
}
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", defaultIss.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
}
}
[Fact]
[Trait("Traces", "AC-5.3,AC-5.12")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_04b_wrong_aud_rejected()
{
// Arrange
var wrongAud = await Tokens.MintAsync(
new SignRequest(Aud: "wrong-audience", Permissions: "FL"));
// Act + Assert
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongAud.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
}
[Fact]
[Trait("Traces", "AC-5.8,AC-9.1")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_05_missing_permissions_claim_returns_403()
{
// Arrange — Permissions=null + PermissionsArray=null omits the claim.
var noPermissions = await Tokens.MintAsync(new SignRequest());
// Act
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", noPermissions.Jwt);
using var resp = await Missions.SendAsync(req);
// Assert — authentication succeeds, authorization fails → 403 (NOT 401).
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Forbidden);
}
[Theory]
[InlineData("ADMIN")]
[InlineData("fl")]
[InlineData("FLight")]
[Trait("Traces", "AC-9.1,AC-9.2")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_06_wrong_single_permission_value_returns_403(string permissions)
{
// Arrange
var token = await Tokens.MintAsync(new SignRequest(Permissions: permissions));
// Act
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var resp = await Missions.SendAsync(req);
// Assert — RequireClaim("permissions","FL") is case-sensitive exact match.
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Forbidden);
}
[Fact]
[Trait("Traces", "AC-9.1,AC-9.2")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_06_multi_value_permissions_array_accepts_when_FL_is_present()
{
// Arrange — array permissions claim; ASP.NET's JWT handler flattens
// an array claim into multiple per-value claims, so RequireClaim
// matches if ANY value equals "FL".
var token = await Tokens.MintAsync(
new SignRequest(PermissionsArray: new[] { "FL", "ADMIN" }));
// Act
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var resp = await Missions.SendAsync(req);
// Assert
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
}
private static string FlipFirstSignatureChar(string jwt)
{
var parts = jwt.Split('.');
if (parts.Length != 3)
throw new InvalidOperationException(
"expected a JWS-compact JWT with exactly 3 segments");
var sig = parts[2].ToCharArray();
// Toggle the first char between two base64url-valid letters so the
// result is still parseable but signature verification fails.
sig[0] = sig[0] == 'A' ? 'B' : 'A';
return $"{parts[0]}.{parts[1]}.{new string(sig)}";
}
}
@@ -0,0 +1,159 @@
using System.Net;
using Azaion.Missions.E2E.Helpers;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Security;
/// <summary>
/// NFT-SEC-13 — CORS posture across environments. The Production-gate
/// rejects an empty allow-list (CorsConfigurationValidator); the
/// Test/Development environment logs a PermissiveDefaultWarning when the
/// same shape is observed. Each scenario spawns its own missions container
/// via <c>docker run</c>. Traces: AC-6.11, E9.
/// </summary>
[Collection("SecurityCors")]
[Trait("Category", "Sec")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class CorsConfigTests
{
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";
[SkippableFact]
[Trait("Traces", "AC-6.11,E9")]
[Trait("max_ms", "15000")]
public void NFT_SEC_13_production_empty_origins_exits_non_zero_with_invalid_operation_exception()
{
Skip.IfNot(MissionsContainerHelper.Enabled,
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
// Arrange
var env = BaseEnv();
env["ASPNETCORE_ENVIRONMENT"] = "Production";
// Act
var result = MissionsContainerHelper.RunUntilExit(
"missions-sec13-prod-empty", env, TimeSpan.FromSeconds(15));
// Assert
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("InvalidOperationException", result.Logs, StringComparison.Ordinal);
var mentionsCors =
result.Logs.Contains("CorsConfig", StringComparison.Ordinal)
|| result.Logs.Contains("AllowedOrigins", StringComparison.Ordinal)
|| result.Logs.Contains("Production", StringComparison.Ordinal);
Assert.True(mentionsCors,
$"logs must mention CorsConfig/AllowedOrigins/Production. Logs:\n{result.Logs}");
}
[SkippableFact]
[Trait("Traces", "AC-6.11")]
[Trait("max_ms", "15000")]
public async Task NFT_SEC_13_production_allow_any_origin_starts_with_warning_log()
{
Skip.IfNot(MissionsContainerHelper.Enabled,
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
// Arrange
var env = BaseEnv();
env["ASPNETCORE_ENVIRONMENT"] = "Production";
env["CorsConfig__AllowAnyOrigin"] = "true";
// Act
using var c = await MissionsContainerHelper.StartAndWaitForHealthAsync(
"missions-sec13-prod-anyorigin", env, TimeSpan.FromSeconds(20));
// Assert — container is up AND a warning sits in the log slice.
var logs = c.ReadLogs();
var mentionsWarning =
logs.Contains("permissive", StringComparison.OrdinalIgnoreCase)
|| logs.Contains("AllowAnyOrigin", StringComparison.Ordinal)
|| logs.Contains("warn", StringComparison.OrdinalIgnoreCase);
Assert.True(mentionsWarning,
$"logs must include a permissive-CORS warning. Logs:\n{logs}");
}
[SkippableFact]
[Trait("Traces", "AC-6.11")]
[Trait("max_ms", "20000")]
public async Task NFT_SEC_13_production_explicit_origin_preflight_allowed_and_other_origins_rejected()
{
Skip.IfNot(MissionsContainerHelper.Enabled,
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
// Arrange
const string allowedOrigin = "https://operator.example.com";
const string disallowedOrigin = "https://attacker.example.com";
var env = BaseEnv();
env["ASPNETCORE_ENVIRONMENT"] = "Production";
env["CorsConfig__AllowedOrigins__0"] = allowedOrigin;
var containerName = "missions-sec13-prod-origins";
using var c = await MissionsContainerHelper.StartAndWaitForHealthAsync(
containerName, env, TimeSpan.FromSeconds(20));
// Act — allowed origin preflight.
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var url = new Uri($"http://{containerName}:8080/vehicles");
using (var preflight = new HttpRequestMessage(HttpMethod.Options, url))
{
preflight.Headers.Add("Origin", allowedOrigin);
preflight.Headers.Add("Access-Control-Request-Method", "GET");
using var resp = await http.SendAsync(preflight);
Assert.True(resp.IsSuccessStatusCode,
$"preflight from allowed origin should succeed; got {(int)resp.StatusCode}");
Assert.True(resp.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowVals),
"preflight from allowed origin must echo Access-Control-Allow-Origin");
Assert.Contains(allowedOrigin, allowVals);
}
// Disallowed origin preflight — middleware responds without echoing the header.
using (var preflight = new HttpRequestMessage(HttpMethod.Options, url))
{
preflight.Headers.Add("Origin", disallowedOrigin);
preflight.Headers.Add("Access-Control-Request-Method", "GET");
using var resp = await http.SendAsync(preflight);
// ASP.NET Core CORS middleware returns 204 even when origin is
// disallowed, but does NOT emit Access-Control-Allow-Origin —
// the missing header is the signal browsers act on.
Assert.False(resp.Headers.TryGetValues("Access-Control-Allow-Origin", out _),
"preflight from disallowed origin must NOT echo Access-Control-Allow-Origin");
}
}
[SkippableFact]
[Trait("Traces", "AC-6.11")]
[Trait("max_ms", "15000")]
public async Task NFT_SEC_13_test_environment_permissive_default_emits_warning_log()
{
Skip.IfNot(MissionsContainerHelper.Enabled,
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
// Arrange — Test env with no CorsConfig. EnsureSafeForEnvironment
// is a no-op; permissive policy is applied with a warning log.
var env = BaseEnv();
env["ASPNETCORE_ENVIRONMENT"] = "Test";
// Act
using var c = await MissionsContainerHelper.StartAndWaitForHealthAsync(
"missions-sec13-test-permissive", env, TimeSpan.FromSeconds(20));
// Assert
var logs = c.ReadLogs();
Assert.Contains("Permissive", logs, StringComparison.Ordinal);
}
private static Dictionary<string, string> BaseEnv() => new(StringComparer.Ordinal)
{
{ "DATABASE_URL", PostgresUrl },
{ "JWT_ISSUER", "https://admin-test.azaion.local" },
{ "JWT_AUDIENCE", "azaion-edge" },
{ "JWT_JWKS_URL", JwksUrlHttps },
{ "ASPNETCORE_URLS", "http://+:8080" },
{ "ASPNETCORE_ENVIRONMENT","Test" },
};
}
@@ -0,0 +1,133 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Npgsql;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Security;
/// <summary>
/// NFT-SEC-07 (health anonymous), NFT-SEC-09 (SQL-injection guard),
/// NFT-SEC-10 (alg-pin) — fast cross-cutting security checks that share a
/// happy-path stack and need no destructive teardown.
/// Traces: AC-7.1, AC-9.4, AC-1.6, AC-2.3 (defensive), AC-5.1, AC-5.10.
/// </summary>
[Collection("SecurityCrossCutting")]
[Trait("Category", "Sec")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class CrossCuttingTests : TestBase, IClassFixture<DbResetFixture>
{
[Fact]
[Trait("Traces", "AC-7.1,AC-9.4")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_07_health_is_anonymous_and_accepts_expired_token()
{
// Arrange — anonymous case + expired-token case prove the auth
// pipeline does NOT run for /health (an expired token would otherwise
// 401 long before reaching the endpoint).
var expired = await Tokens.MintAsync(new SignRequest(Permissions: "FL", ExpOffsetSeconds: -3600));
// Act + Assert — anonymous
using (var resp = await Missions.GetAsync("/health"))
{
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("healthy", body.GetProperty("status").GetString());
}
// Expired token
using (var req = new HttpRequestMessage(HttpMethod.Get, "/health"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expired.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("healthy", body.GetProperty("status").GetString());
}
}
[Fact]
[Trait("Traces", "AC-1.6,AC-2.3")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_09_sql_injection_payloads_are_treated_as_literal_strings()
{
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.Three_BR01_BR02_MQ9.Sql);
var token = await Tokens.MintDefaultAsync();
// Act + Assert — OR '1'='1 should NOT short-circuit to "all rows".
using (var req = new HttpRequestMessage(
HttpMethod.Get,
"/vehicles?" + Uri.EscapeDataString("name=' OR '1'='1")))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
var raw = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(raw);
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
// The literal "'OR'1'='1" never matches any vehicle name.
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
// Drop-table payload should NOT execute as SQL.
using (var req = new HttpRequestMessage(
HttpMethod.Get,
"/missions?" + Uri.EscapeDataString("name=; DROP TABLE vehicles; --")))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
var raw = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(raw);
Assert.True(doc.RootElement.TryGetProperty("TotalCount", out var totalEl));
Assert.Equal(0, totalEl.GetInt32());
}
// Side-channel: vehicles table still exists.
var oid = ScalarToRegclass("vehicles");
Assert.NotNull(oid);
}
[Fact]
[Trait("Traces", "AC-5.1,AC-5.10")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_10_alg_pin_rejects_HS256_confusion_and_unsigned_tokens()
{
// Arrange — both attack shapes carry valid claims; only `alg` differs.
var hs256 = await Tokens.MintAsync(
new SignRequest(Permissions: "FL", AlgOverride: "HS256"));
var unsigned = await Tokens.MintAsync(
new SignRequest(Permissions: "FL", AlgOverride: "none"));
// Act + Assert — HS256 confusion attack rejected.
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", hs256.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
}
// alg:none unsigned token rejected.
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", unsigned.Jwt);
using var resp = await Missions.SendAsync(req);
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
}
}
private static string? ScalarToRegclass(string table)
{
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT to_regclass(@t)::TEXT";
cmd.Parameters.AddWithValue("t", table);
return cmd.ExecuteScalar() as string;
}
}
@@ -0,0 +1,90 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Npgsql;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Security;
/// <summary>
/// NFT-SEC-08 — security-category variant of FT-N-08. Same destructive
/// fixture (DROP TABLE vehicles CASCADE) but emphasises the redaction
/// assertions and the matching log-line presence. Lives in the
/// <c>ErrorEnvelope500</c> collection so xUnit serialises against FT-N-08
/// and the consumer image still uses one round of compose restart for both.
/// Traces: AC-8.6, AC-10.3.
/// </summary>
[Collection("ErrorEnvelope500")]
[Trait("Category", "Sec")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class ErrorRedactionTests : TestBase, IClassFixture<ComposeRestartFixture>
{
private readonly ComposeRestartFixture _restart;
public ErrorRedactionTests(ComposeRestartFixture restart) => _restart = restart;
[SkippableFact]
[Trait("Traces", "AC-8.6,AC-10.3")]
[Trait("max_ms", "5000")]
public async Task NFT_SEC_08_500_body_redacts_internals_and_log_records_exception_type()
{
Skip.IfNot(_restart.Enabled,
"ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " +
"NFT-SEC-08 drops the vehicles table and needs the full stack restart " +
"in teardown.");
// Arrange — DROP TABLE vehicles forces the SUT into the generic
// catch path on /vehicles/{any}.
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
DropVehiclesTable();
var requestStart = DateTime.UtcNow;
var token = await Tokens.MintDefaultAsync();
try
{
// Act
using var req = new HttpRequestMessage(HttpMethod.Get, $"/vehicles/{Guid.NewGuid()}");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var response = await Missions.SendAsync(req);
// Assert — wire-shape is EXACTLY { statusCode, message }; no extra
// keys, no stack-leak keywords anywhere in the JSON DOM.
var problem = await HttpAssertions.AssertProblemEnvelopeAsync(
response, HttpStatusCode.InternalServerError);
Assert.Equal(500, problem.StatusCode);
Assert.Equal("Internal server error", problem.Message);
// The unhandled exception MUST still be logged. The log line
// includes the exception type (Npgsql.PostgresException) so an
// operator can diagnose without the response leaking it.
var deadline = DateTime.UtcNow.AddSeconds(2);
var sawLog = false;
while (DateTime.UtcNow < deadline)
{
if (DockerLogs.Contains("missions-sut", "Unhandled exception", requestStart))
{
sawLog = true;
break;
}
await Task.Delay(100);
}
Assert.True(sawLog,
"expected 'Unhandled exception' in missions-sut docker logs within 2s of request");
}
finally
{
_restart.RestartStack();
}
}
private static void DropVehiclesTable()
{
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "DROP TABLE IF EXISTS vehicles CASCADE;";
cmd.ExecuteNonQuery();
}
}
@@ -0,0 +1,117 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Security;
/// <summary>
/// NFT-SEC-11 — security-shaped view of JWKS rotation. Verifies the kid-cache
/// mechanics + grace-window timing; the resilience-shaped variant
/// (no-restart) lives in <c>Tests/Resilience/JwksRotationTests.cs</c>.
/// Traces: AC-5.7.
/// </summary>
/// <remarks>
/// Owns the <c>JwksRotation</c> xUnit collection because rotating the mock
/// changes the active kid for every subsequent test that holds a stale
/// token. After running, the next test class in any collection mints a
/// fresh token, so it picks up the new kid on its next JWKS refresh.
/// </remarks>
[Collection("JwksRotation")]
[Trait("Category", "Sec")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class JwksRotationTests : TestBase, IClassFixture<DbResetFixture>
{
[Fact(Timeout = 130_000)]
[Trait("Traces", "AC-5.7")]
[Trait("max_ms", "120000")]
public async Task NFT_SEC_11_unknown_kid_rotation_completes_within_120s_honouring_grace()
{
// Arrange — warm up: confirm the active key works before rotation.
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
var t1 = await Tokens.MintDefaultAsync();
var kidV1 = t1.Kid;
using (var resp = await CallVehiclesAsync(t1.Jwt))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
var rotationStart = DateTime.UtcNow;
// Act 1: Rotate the mock. After this call, kid_v2 is active and
// kid_v1 is retained for OLD_KEY_GRACE_SECONDS=5.
var kidV2 = await RotateMockAsync();
Assert.NotEqual(kidV1, kidV2);
// Mint T2 with the brand-new active key.
var t2 = await Tokens.MintDefaultAsync();
Assert.Equal(kidV2, t2.Kid);
// Assert AC-5.7.1 — T2 is rejected BEFORE missions refreshes its JWKS
// cache (the new kid is not yet in the cache). We probe immediately
// and require at least one 401 — once missions refreshes, subsequent
// calls should succeed.
using (var resp = await CallVehiclesAsync(t2.Jwt))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
// Assert AC-5.7.3 — during the 5s grace window, the OLD-kid token T1
// is still accepted (missions' cache still contains kid_v1 from the
// initial bootstrap fetch; the cache hasn't refreshed yet).
using (var resp = await CallVehiclesAsync(t1.Jwt))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
// Act 2: Wait for JWKS refresh — poll T2 every 3s, up to 90s.
var refreshDeadline = DateTime.UtcNow.AddSeconds(90);
var refreshed = false;
while (DateTime.UtcNow < refreshDeadline)
{
using var resp = await CallVehiclesAsync(t2.Jwt);
if (resp.StatusCode == HttpStatusCode.OK)
{
refreshed = true;
break;
}
await Task.Delay(TimeSpan.FromSeconds(3));
}
Assert.True(refreshed,
"JWKS refresh did not propagate to missions within 90s (max-age=60s + auto-refresh=30s)");
// Assert AC-5.7.4 — after the 5s grace window, the mock refuses to
// sign with the old kid. Wait until grace certainly expired.
var graceExpiry = rotationStart.AddSeconds(7);
var until = graceExpiry - DateTime.UtcNow;
if (until > TimeSpan.Zero)
await Task.Delay(until);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var signUrl = new Uri(TestEnvironment.JwksMockSignUrl);
using var signResponse = await http.PostAsJsonAsync(
signUrl,
new { kid_override = kidV1, permissions = "FL" });
Assert.Equal(HttpStatusCode.BadRequest, signResponse.StatusCode);
var body = await signResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(body.TryGetProperty("error", out _),
"mock refusal must include 'error' field");
}
private async Task<HttpResponseMessage> CallVehiclesAsync(string jwt)
{
var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
return await Missions.SendAsync(req);
}
private static async Task<string> RotateMockAsync()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var rotateUrl = new Uri(new Uri(TestEnvironment.JwksMockBaseUrl), "/rotate-key");
using var resp = await http.PostAsync(rotateUrl, content: null);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
return body.GetProperty("kid").GetString()
?? throw new InvalidOperationException("mock /rotate-key returned no kid");
}
}
@@ -0,0 +1,124 @@
using System.Net;
using System.Net.Http.Headers;
using Azaion.Missions.E2E.Helpers;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Security;
/// <summary>
/// NFT-SEC-12 — security-shaped startup config posture. The 4 missing-env
/// rows are also exercised by NFT-RES-05 row 14 in
/// <c>Tests/Resilience/ConfigDbStartupTests.cs</c>; here they fall under the
/// <c>Sec</c> category so the CSV report carries both rows. Traces: AC-6.1,
/// AC-6.2, E1, E3.
/// </summary>
/// <remarks>
/// Each scenario spawns its own missions container via <c>docker run</c>
/// (independent of the long-running compose stack) so the test can probe
/// startup behaviour without taking the shared SUT down. The helper bails
/// with <see cref="Skip.IfNot(bool, string)"/> when docker access is not
/// available (developer inner-loop with <c>COMPOSE_RESTART_ENABLED=0</c>).
/// </remarks>
[Collection("SecurityStartupConfig")]
[Trait("Category", "Sec")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class StartupConfigTests
{
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[]> MissingEnvCases() => new[]
{
new object[] { "missing_db_url", "DATABASE_URL", "Database:Url" },
new object[] { "missing_jwt_issuer", "JWT_ISSUER", "Jwt:Issuer" },
new object[] { "missing_jwt_aud", "JWT_AUDIENCE", "Jwt:Audience" },
new object[] { "missing_jwks_url", "JWT_JWKS_URL", "Jwt:JwksUrl" },
};
[SkippableTheory]
[MemberData(nameof(MissingEnvCases))]
[Trait("Traces", "AC-6.1,AC-6.2")]
[Trait("max_ms", "5000")]
public void NFT_SEC_12_missing_required_env_var_exits_non_zero_with_invalid_operation_exception(
string caseName, string omittedVar, string configAlias)
{
Skip.IfNot(MissionsContainerHelper.Enabled,
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
// Arrange
var env = BaseEnv();
env.Remove(omittedVar);
var container = $"missions-sec12-{caseName}";
// Act
var result = MissionsContainerHelper.RunUntilExit(
container, env, TimeSpan.FromSeconds(15));
// Assert
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("InvalidOperationException", result.Logs, StringComparison.Ordinal);
var mentionsVar = result.Logs.Contains(omittedVar, StringComparison.Ordinal)
|| result.Logs.Contains(configAlias, StringComparison.Ordinal);
Assert.True(mentionsVar,
$"logs must mention '{omittedVar}' or '{configAlias}'. Logs:\n{result.Logs}");
}
[SkippableFact]
[Trait("Traces", "E1,E3")]
[Trait("max_ms", "30000")]
public async Task NFT_SEC_12_http_jwks_url_starts_then_fails_protected_request_with_RequireHttps_log()
{
Skip.IfNot(MissionsContainerHelper.Enabled,
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
// Arrange — config resolution succeeds (HTTP URL is a well-formed
// string), so the container starts. The first protected request
// triggers a JWKS fetch which the HttpDocumentRetriever rejects
// because RequireHttps=true.
var env = BaseEnv();
env["JWT_JWKS_URL"] = "http://jwks-mock:8443/.well-known/jwks.json";
var container = "missions-sec12-http-jwks";
using var c = await MissionsContainerHelper.StartAndWaitForHealthAsync(
container, env, TimeSpan.FromSeconds(20));
// Mint a normal token from the mock — the SUT will reject it not
// because the token is bad, but because it cannot fetch JWKS at all.
var minter = new TokenMinter(TestEnvironment.JwksMockSignUrl);
var token = await minter.MintDefaultAsync();
// Act — send /vehicles to the new SUT container directly.
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var url = new Uri($"http://{container}:8080/vehicles");
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var resp = await http.SendAsync(req);
// Assert — either 500 (RequireHttps exception bubbles to error
// middleware) or 401 (auth handler swallows the inner exception).
Assert.True(
resp.StatusCode is HttpStatusCode.InternalServerError or HttpStatusCode.Unauthorized,
$"expected 500 or 401, got {(int)resp.StatusCode}");
var logs = c.ReadLogs();
var mentionsHttps = logs.Contains("RequireHttps", StringComparison.OrdinalIgnoreCase)
|| logs.Contains("HTTPS", StringComparison.OrdinalIgnoreCase)
|| logs.Contains("requires https", StringComparison.OrdinalIgnoreCase);
Assert.True(mentionsHttps,
$"logs must mention HTTPS / RequireHttps. Logs:\n{logs}");
}
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" },
};
}