Files
missions/tests/Azaion.Missions.E2E.Tests/Tests/Security/CorsConfigTests.cs
T
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

160 lines
6.8 KiB
C#

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