mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 13:21:06 +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,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