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