using System.Net; using Azaion.Missions.E2E.Helpers; using Xunit; namespace Azaion.Missions.E2E.Tests.Security; /// /// 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 docker run. Traces: AC-6.11, E9. /// [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 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" }, }; }