mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 13:21:07 +00:00
24c4561bef
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>
160 lines
6.8 KiB
C#
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" },
|
|
};
|
|
}
|