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

125 lines
5.6 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" },
};
}