using System.Net; using System.Net.Http.Headers; using Azaion.Missions.E2E.Helpers; using Xunit; namespace Azaion.Missions.E2E.Tests.Security; /// /// NFT-SEC-12 — security-shaped startup config posture. The 4 missing-env /// rows are also exercised by NFT-RES-05 row 1–4 in /// Tests/Resilience/ConfigDbStartupTests.cs; here they fall under the /// Sec category so the CSV report carries both rows. Traces: AC-6.1, /// AC-6.2, E1, E3. /// /// /// Each scenario spawns its own missions container via docker run /// (independent of the long-running compose stack) so the test can probe /// startup behaviour without taking the shared SUT down. The helper bails /// with when docker access is not /// available (developer inner-loop with COMPOSE_RESTART_ENABLED=0). /// [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 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 BaseEnv() => new(StringComparer.Ordinal) { { "DATABASE_URL", PostgresUrl }, { "JWT_ISSUER", Issuer }, { "JWT_AUDIENCE", Audience }, { "JWT_JWKS_URL", JwksUrlHttps }, { "ASPNETCORE_URLS", "http://+:8080" }, { "ASPNETCORE_ENVIRONMENT","Test" }, }; }