mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 16:51:07 +00:00
[AZ-581] [AZ-582] [AZ-583] [AZ-584] Sec+Res NFT tests
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>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-01..06 + 04b — JWT authn/authz scenarios from
|
||||
/// <c>_docs/02_document/tests/security-tests.md</c>.
|
||||
/// Traces: AC-5.2..AC-5.6, AC-5.8, AC-5.11, AC-5.12, AC-9.1, AC-9.2.
|
||||
/// </summary>
|
||||
[Collection("SecurityAuthClaims")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.4")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_01_missing_authorization_header_rejects_protected_endpoints_with_401_and_no_db_write()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
var anyMissionId = Guid.NewGuid();
|
||||
var preCount = DbAssertions.TableRowCount("vehicles");
|
||||
|
||||
// Act + Assert — GET /vehicles
|
||||
using (var resp = await Missions.GetAsync("/vehicles"))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
using (var resp = await Missions.GetAsync("/missions"))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
using (var resp = await Missions.GetAsync($"/missions/{anyMissionId}/waypoints"))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
var postBody = new
|
||||
{
|
||||
Type = 0,
|
||||
Model = "Bayraktar",
|
||||
Name = "BR-noauth",
|
||||
FuelType = 1,
|
||||
BatteryCapacity = 0,
|
||||
EngineConsumption = 5,
|
||||
EngineConsumptionIdle = 1,
|
||||
IsDefault = false
|
||||
};
|
||||
using (var post = new HttpRequestMessage(HttpMethod.Post, "/vehicles")
|
||||
{
|
||||
Content = JsonContent.Create(postBody)
|
||||
})
|
||||
using (var resp = await Missions.SendAsync(post))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
// Assert — POST 401 did not write a row.
|
||||
var postCount = DbAssertions.TableRowCount("vehicles");
|
||||
Assert.Equal(preCount, postCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.5")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_02_invalid_signature_rejects_byte_flip_and_foreign_keypair_with_401()
|
||||
{
|
||||
// Arrange — single-byte-flip uses a mock-signed token; foreign-keypair
|
||||
// uses a local ECDSA P-256 (the one in-test signing path the task
|
||||
// spec permits).
|
||||
var good = await Tokens.MintDefaultAsync();
|
||||
var flipped = FlipFirstSignatureChar(good.Jwt);
|
||||
|
||||
using var foreign = new ForeignKeypair();
|
||||
var foreignJwt = foreign.Mint(
|
||||
TestEnvironment.JwtIssuer, TestEnvironment.JwtAudience, "FL");
|
||||
|
||||
// Act + Assert — flipped signature
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", flipped);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
// Act + Assert — foreign keypair token (kid not in JWKS).
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", foreignJwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.2,AC-5.6")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_03_clock_skew_30s_rejects_minus_60_and_accepts_minus_15()
|
||||
{
|
||||
// Arrange — both tokens are otherwise identical; only exp differs.
|
||||
var expiredBeyondSkew = await Tokens.MintAsync(
|
||||
new SignRequest(Permissions: "FL", ExpOffsetSeconds: -60));
|
||||
var expiredWithinSkew = await Tokens.MintAsync(
|
||||
new SignRequest(Permissions: "FL", ExpOffsetSeconds: -15));
|
||||
|
||||
// Act + Assert — outside the 30s skew window.
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredBeyondSkew.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
// Inside the 30s skew window.
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredWithinSkew.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.3,AC-5.11")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_04_wrong_iss_rejected_default_iss_accepted()
|
||||
{
|
||||
// Arrange
|
||||
var wrongIss = await Tokens.MintAsync(
|
||||
new SignRequest(Iss: "https://attacker.example.com", Permissions: "FL"));
|
||||
var defaultIss = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act + Assert
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongIss.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", defaultIss.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.3,AC-5.12")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_04b_wrong_aud_rejected()
|
||||
{
|
||||
// Arrange
|
||||
var wrongAud = await Tokens.MintAsync(
|
||||
new SignRequest(Aud: "wrong-audience", Permissions: "FL"));
|
||||
|
||||
// Act + Assert
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongAud.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.8,AC-9.1")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_05_missing_permissions_claim_returns_403()
|
||||
{
|
||||
// Arrange — Permissions=null + PermissionsArray=null omits the claim.
|
||||
var noPermissions = await Tokens.MintAsync(new SignRequest());
|
||||
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", noPermissions.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
|
||||
// Assert — authentication succeeds, authorization fails → 403 (NOT 401).
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ADMIN")]
|
||||
[InlineData("fl")]
|
||||
[InlineData("FLight")]
|
||||
[Trait("Traces", "AC-9.1,AC-9.2")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_06_wrong_single_permission_value_returns_403(string permissions)
|
||||
{
|
||||
// Arrange
|
||||
var token = await Tokens.MintAsync(new SignRequest(Permissions: permissions));
|
||||
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
|
||||
// Assert — RequireClaim("permissions","FL") is case-sensitive exact match.
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-9.1,AC-9.2")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_06_multi_value_permissions_array_accepts_when_FL_is_present()
|
||||
{
|
||||
// Arrange — array permissions claim; ASP.NET's JWT handler flattens
|
||||
// an array claim into multiple per-value claims, so RequireClaim
|
||||
// matches if ANY value equals "FL".
|
||||
var token = await Tokens.MintAsync(
|
||||
new SignRequest(PermissionsArray: new[] { "FL", "ADMIN" }));
|
||||
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private static string FlipFirstSignatureChar(string jwt)
|
||||
{
|
||||
var parts = jwt.Split('.');
|
||||
if (parts.Length != 3)
|
||||
throw new InvalidOperationException(
|
||||
"expected a JWS-compact JWT with exactly 3 segments");
|
||||
var sig = parts[2].ToCharArray();
|
||||
// Toggle the first char between two base64url-valid letters so the
|
||||
// result is still parseable but signature verification fails.
|
||||
sig[0] = sig[0] == 'A' ? 'B' : 'A';
|
||||
return $"{parts[0]}.{parts[1]}.{new string(sig)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Collection("SecurityCrossCutting")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CrossCuttingTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-08 — security-category variant of FT-N-08. Same destructive
|
||||
/// fixture (DROP TABLE vehicles CASCADE) but emphasises the redaction
|
||||
/// assertions and the matching log-line presence. Lives in the
|
||||
/// <c>ErrorEnvelope500</c> collection so xUnit serialises against FT-N-08
|
||||
/// and the consumer image still uses one round of compose restart for both.
|
||||
/// Traces: AC-8.6, AC-10.3.
|
||||
/// </summary>
|
||||
[Collection("ErrorEnvelope500")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class ErrorRedactionTests : TestBase, IClassFixture<ComposeRestartFixture>
|
||||
{
|
||||
private readonly ComposeRestartFixture _restart;
|
||||
|
||||
public ErrorRedactionTests(ComposeRestartFixture restart) => _restart = restart;
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-8.6,AC-10.3")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_08_500_body_redacts_internals_and_log_records_exception_type()
|
||||
{
|
||||
Skip.IfNot(_restart.Enabled,
|
||||
"ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " +
|
||||
"NFT-SEC-08 drops the vehicles table and needs the full stack restart " +
|
||||
"in teardown.");
|
||||
|
||||
// Arrange — DROP TABLE vehicles forces the SUT into the generic
|
||||
// catch path on /vehicles/{any}.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
DropVehiclesTable();
|
||||
var requestStart = DateTime.UtcNow;
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"/vehicles/{Guid.NewGuid()}");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(req);
|
||||
|
||||
// Assert — wire-shape is EXACTLY { statusCode, message }; no extra
|
||||
// keys, no stack-leak keywords anywhere in the JSON DOM.
|
||||
var problem = await HttpAssertions.AssertProblemEnvelopeAsync(
|
||||
response, HttpStatusCode.InternalServerError);
|
||||
Assert.Equal(500, problem.StatusCode);
|
||||
Assert.Equal("Internal server error", problem.Message);
|
||||
|
||||
// The unhandled exception MUST still be logged. The log line
|
||||
// includes the exception type (Npgsql.PostgresException) so an
|
||||
// operator can diagnose without the response leaking it.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
var sawLog = false;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (DockerLogs.Contains("missions-sut", "Unhandled exception", requestStart))
|
||||
{
|
||||
sawLog = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(100);
|
||||
}
|
||||
Assert.True(sawLog,
|
||||
"expected 'Unhandled exception' in missions-sut docker logs within 2s of request");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_restart.RestartStack();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DropVehiclesTable()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DROP TABLE IF EXISTS vehicles CASCADE;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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 Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-11 — security-shaped view of JWKS rotation. Verifies the kid-cache
|
||||
/// mechanics + grace-window timing; the resilience-shaped variant
|
||||
/// (no-restart) lives in <c>Tests/Resilience/JwksRotationTests.cs</c>.
|
||||
/// Traces: AC-5.7.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Owns the <c>JwksRotation</c> xUnit collection because rotating the mock
|
||||
/// changes the active kid for every subsequent test that holds a stale
|
||||
/// token. After running, the next test class in any collection mints a
|
||||
/// fresh token, so it picks up the new kid on its next JWKS refresh.
|
||||
/// </remarks>
|
||||
[Collection("JwksRotation")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class JwksRotationTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact(Timeout = 130_000)]
|
||||
[Trait("Traces", "AC-5.7")]
|
||||
[Trait("max_ms", "120000")]
|
||||
public async Task NFT_SEC_11_unknown_kid_rotation_completes_within_120s_honouring_grace()
|
||||
{
|
||||
// Arrange — warm up: confirm the active key works before rotation.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
|
||||
var t1 = await Tokens.MintDefaultAsync();
|
||||
var kidV1 = t1.Kid;
|
||||
using (var resp = await CallVehiclesAsync(t1.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
|
||||
var rotationStart = DateTime.UtcNow;
|
||||
|
||||
// Act 1: Rotate the mock. After this call, kid_v2 is active and
|
||||
// kid_v1 is retained for OLD_KEY_GRACE_SECONDS=5.
|
||||
var kidV2 = await RotateMockAsync();
|
||||
Assert.NotEqual(kidV1, kidV2);
|
||||
|
||||
// Mint T2 with the brand-new active key.
|
||||
var t2 = await Tokens.MintDefaultAsync();
|
||||
Assert.Equal(kidV2, t2.Kid);
|
||||
|
||||
// Assert AC-5.7.1 — T2 is rejected BEFORE missions refreshes its JWKS
|
||||
// cache (the new kid is not yet in the cache). We probe immediately
|
||||
// and require at least one 401 — once missions refreshes, subsequent
|
||||
// calls should succeed.
|
||||
using (var resp = await CallVehiclesAsync(t2.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
// Assert AC-5.7.3 — during the 5s grace window, the OLD-kid token T1
|
||||
// is still accepted (missions' cache still contains kid_v1 from the
|
||||
// initial bootstrap fetch; the cache hasn't refreshed yet).
|
||||
using (var resp = await CallVehiclesAsync(t1.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
|
||||
// Act 2: Wait for JWKS refresh — poll T2 every 3s, up to 90s.
|
||||
var refreshDeadline = DateTime.UtcNow.AddSeconds(90);
|
||||
var refreshed = false;
|
||||
while (DateTime.UtcNow < refreshDeadline)
|
||||
{
|
||||
using var resp = await CallVehiclesAsync(t2.Jwt);
|
||||
if (resp.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
refreshed = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
Assert.True(refreshed,
|
||||
"JWKS refresh did not propagate to missions within 90s (max-age=60s + auto-refresh=30s)");
|
||||
|
||||
// Assert AC-5.7.4 — after the 5s grace window, the mock refuses to
|
||||
// sign with the old kid. Wait until grace certainly expired.
|
||||
var graceExpiry = rotationStart.AddSeconds(7);
|
||||
var until = graceExpiry - DateTime.UtcNow;
|
||||
if (until > TimeSpan.Zero)
|
||||
await Task.Delay(until);
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var signUrl = new Uri(TestEnvironment.JwksMockSignUrl);
|
||||
using var signResponse = await http.PostAsJsonAsync(
|
||||
signUrl,
|
||||
new { kid_override = kidV1, permissions = "FL" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, signResponse.StatusCode);
|
||||
var body = await signResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(body.TryGetProperty("error", out _),
|
||||
"mock refusal must include 'error' field");
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> CallVehiclesAsync(string jwt)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
return await Missions.SendAsync(req);
|
||||
}
|
||||
|
||||
private static async Task<string> RotateMockAsync()
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var rotateUrl = new Uri(new Uri(TestEnvironment.JwksMockBaseUrl), "/rotate-key");
|
||||
using var resp = await http.PostAsync(rotateUrl, content: null);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return body.GetProperty("kid").GetString()
|
||||
?? throw new InvalidOperationException("mock /rotate-key returned no kid");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-12 — security-shaped startup config posture. The 4 missing-env
|
||||
/// rows are also exercised by NFT-RES-05 row 1–4 in
|
||||
/// <c>Tests/Resilience/ConfigDbStartupTests.cs</c>; here they fall under the
|
||||
/// <c>Sec</c> category so the CSV report carries both rows. Traces: AC-6.1,
|
||||
/// AC-6.2, E1, E3.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each scenario spawns its own missions container via <c>docker run</c>
|
||||
/// (independent of the long-running compose stack) so the test can probe
|
||||
/// startup behaviour without taking the shared SUT down. The helper bails
|
||||
/// with <see cref="Skip.IfNot(bool, string)"/> when docker access is not
|
||||
/// available (developer inner-loop with <c>COMPOSE_RESTART_ENABLED=0</c>).
|
||||
/// </remarks>
|
||||
[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<object[]> 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<string, string> BaseEnv() => new(StringComparer.Ordinal)
|
||||
{
|
||||
{ "DATABASE_URL", PostgresUrl },
|
||||
{ "JWT_ISSUER", Issuer },
|
||||
{ "JWT_AUDIENCE", Audience },
|
||||
{ "JWT_JWKS_URL", JwksUrlHttps },
|
||||
{ "ASPNETCORE_URLS", "http://+:8080" },
|
||||
{ "ASPNETCORE_ENVIRONMENT","Test" },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user