[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,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" },
};
}