using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Azaion.Missions.E2E.Fixtures; using Azaion.Missions.E2E.Helpers; using Npgsql; using Xunit; namespace Azaion.Missions.E2E.Tests.Security; /// /// NFT-SEC-07 (health anonymous), NFT-SEC-09 (SQL-injection guard), /// NFT-SEC-10 (alg-pin) — fast cross-cutting security checks that share a /// happy-path stack and need no destructive teardown. /// Traces: AC-7.1, AC-9.4, AC-1.6, AC-2.3 (defensive), AC-5.1, AC-5.10. /// [Collection("SecurityCrossCutting")] [Trait("Category", "Sec")] [Trait("db_access", "seed-or-assert-only")] public sealed class CrossCuttingTests : TestBase, IClassFixture { [Fact] [Trait("Traces", "AC-7.1,AC-9.4")] [Trait("max_ms", "5000")] public async Task NFT_SEC_07_health_is_anonymous_and_accepts_expired_token() { // Arrange — anonymous case + expired-token case prove the auth // pipeline does NOT run for /health (an expired token would otherwise // 401 long before reaching the endpoint). var expired = await Tokens.MintAsync(new SignRequest(Permissions: "FL", ExpOffsetSeconds: -3600)); // Act + Assert — anonymous using (var resp = await Missions.GetAsync("/health")) { await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); Assert.Equal("healthy", body.GetProperty("status").GetString()); } // Expired token using (var req = new HttpRequestMessage(HttpMethod.Get, "/health")) { req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expired.Jwt); using var resp = await Missions.SendAsync(req); await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); Assert.Equal("healthy", body.GetProperty("status").GetString()); } } [Fact] [Trait("Traces", "AC-1.6,AC-2.3")] [Trait("max_ms", "5000")] public async Task NFT_SEC_09_sql_injection_payloads_are_treated_as_literal_strings() { // Arrange DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel); Seeds.Apply(Seeds.Three_BR01_BR02_MQ9.Sql); var token = await Tokens.MintDefaultAsync(); // Act + Assert — OR '1'='1 should NOT short-circuit to "all rows". using (var req = new HttpRequestMessage( HttpMethod.Get, "/vehicles?" + Uri.EscapeDataString("name=' OR '1'='1"))) { req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var resp = await Missions.SendAsync(req); await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK); var raw = await resp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(raw); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); // The literal "'OR'1'='1" never matches any vehicle name. Assert.Equal(0, doc.RootElement.GetArrayLength()); } // Drop-table payload should NOT execute as SQL. using (var req = new HttpRequestMessage( HttpMethod.Get, "/missions?" + Uri.EscapeDataString("name=; DROP TABLE vehicles; --"))) { req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var resp = await Missions.SendAsync(req); await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK); var raw = await resp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(raw); Assert.True(doc.RootElement.TryGetProperty("TotalCount", out var totalEl)); Assert.Equal(0, totalEl.GetInt32()); } // Side-channel: vehicles table still exists. var oid = ScalarToRegclass("vehicles"); Assert.NotNull(oid); } [Fact] [Trait("Traces", "AC-5.1,AC-5.10")] [Trait("max_ms", "5000")] public async Task NFT_SEC_10_alg_pin_rejects_HS256_confusion_and_unsigned_tokens() { // Arrange — both attack shapes carry valid claims; only `alg` differs. var hs256 = await Tokens.MintAsync( new SignRequest(Permissions: "FL", AlgOverride: "HS256")); var unsigned = await Tokens.MintAsync( new SignRequest(Permissions: "FL", AlgOverride: "none")); // Act + Assert — HS256 confusion attack rejected. using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles")) { req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", hs256.Jwt); using var resp = await Missions.SendAsync(req); await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized); } // alg:none unsigned token rejected. using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles")) { req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", unsigned.Jwt); using var resp = await Missions.SendAsync(req); await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized); } } private static string? ScalarToRegclass(string table) { using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT to_regclass(@t)::TEXT"; cmd.Parameters.AddWithValue("t", table); return cmd.ExecuteScalar() as string; } }