mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 22:31:07 +00:00
24c4561bef
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>
125 lines
5.6 KiB
C#
125 lines
5.6 KiB
C#
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" },
|
||
};
|
||
}
|