[AZ-576] Add e2e test infrastructure (xUnit + jwks-mock + reporting)
ci/woodpecker/push/build-arm Pipeline failed

Scaffold the blackbox test project the rest of epic AZ-575 (AZ-577..AZ-586)
will build on. Two new csprojs under tests/, plus the TLS materials and
TRX->CSV reporting hand-off the existing docker-compose.test.yml already
calls for.

JWKS mock (tests/Azaion.Missions.JwksMock/):
- ASP.NET Core minimal API on .NET 10, no NuGet deps; JWS is hand-rolled
  to keep the surface tight and avoid version drift with the SUT
- KeyStore with one in-memory ECDSA P-256 keypair + retired-key grace
  window for NFT-RES-07 / NFT-SEC-11 rotation observability
- Endpoints: GET /.well-known/jwks.json, POST /sign, POST /rotate-key
- Mock-only alg_override / kid_override switches drive NFT-SEC-09/10/11
- TLS keypair committed under tls/; tests/jwks-mock-ca.crt is a copy
  mounted into both missions and e2e-consumer per docker-compose.test.yml

E2E consumer (tests/Azaion.Missions.E2E.Tests/):
- xUnit 2.9.2 + Bogus 35.6.1 + Npgsql 10.0.2 + Xunit.SkippableFact 1.4.13
- TestBase / TokenMinter scaffolding for downstream tasks
- Fixtures/ for DbReset, DbSeed, ComposeRestart, JwksRotate, JwksMockReverse
- Helpers/ for DbAssertions (side-channel), HttpAssertions, FixtureSql
- 8 Tests/<category>/Sanity.cs discovery smoke tests (AC-3)
- Tests/InfrastructureSanity.cs SkippableFacts for AC-1/2/5/6
- Tests/AaaPatternEnforcement.cs greps source files for AC-7
- Tests/Reporting/TrxToCsvPostProcessorTests.cs covers AC-4
- Reporting/TrxToCsvPostProcessor.cs handles VSTest TRX -> environment.md
  CSV; xUnit traits are not propagated by the TRX logger so the converter
  reflects them out of the test DLL via GetCustomAttributesData
- Reporting.Cli/ is a separate console csproj that links the converter
  source files (test project excludes Reporting.Cli/** from compile)
- Dockerfile + entrypoint.sh wire dotnet test -> trx -> csv inside the
  e2e-consumer container the compose file already references

Local verification: 13 pass, 3 skip (with explicit reasons), 0 fail.
End-to-end TRX->CSV manually verified against environment.md header spec.
Docker stack build is handed off to autodev Step 7 (test-run skill).

Reports under _docs/03_implementation/.
AZ-576 task spec moved to _docs/tasks/done/.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-15 06:57:40 +03:00
parent b0c7132889
commit ccd85a09df
48 changed files with 2030 additions and 4 deletions
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Azaion.Missions.JwksMock</RootNamespace>
<AssemblyName>Azaion.Missions.JwksMock</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<None Include="tls\jwks-mock.crt" CopyToOutputDirectory="PreserveNewest" />
<None Include="tls\jwks-mock.key" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+12
View File
@@ -0,0 +1,12 @@
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
WORKDIR /src
COPY . .
RUN arch=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "$TARGETARCH") && \
dotnet publish Azaion.Missions.JwksMock.csproj -c Release -o /app --os linux --arch $arch
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
EXPOSE 8443
ENTRYPOINT ["dotnet", "Azaion.Missions.JwksMock.dll"]
@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using Azaion.Missions.JwksMock.Services;
namespace Azaion.Missions.JwksMock.Endpoints;
public static class JwksEndpoint
{
/// <summary>
/// <c>GET /.well-known/jwks.json</c>. Mirrors the shape the production
/// admin issuer publishes — JsonWebKey 'kty=EC, crv=P-256, alg=ES256,
/// use=sig' with base64url x/y coordinates.
/// </summary>
public static IResult Handle(KeyStore keys)
{
var keysArray = new JsonArray();
foreach (var key in keys.PublishedKeys())
{
var p = key.Ec.ExportParameters(includePrivateParameters: false);
keysArray.Add(new JsonObject
{
["kty"] = "EC",
["use"] = "sig",
["alg"] = "ES256",
["crv"] = "P-256",
["kid"] = key.Kid,
["x"] = Base64Url.Encode(p.Q.X!),
["y"] = Base64Url.Encode(p.Q.Y!)
});
}
var doc = new JsonObject { ["keys"] = keysArray };
return Results.Json(doc, statusCode: 200, contentType: "application/json")
.WithCacheControl("public, max-age=60");
}
private static IResult WithCacheControl(this IResult result, string value) =>
new CacheControlResult(result, value);
private sealed class CacheControlResult(IResult inner, string cacheControl) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.Headers.CacheControl = cacheControl;
return inner.ExecuteAsync(httpContext);
}
}
}
@@ -0,0 +1,17 @@
using Azaion.Missions.JwksMock.Services;
namespace Azaion.Missions.JwksMock.Endpoints;
public static class RotateKeyEndpoint
{
/// <summary>
/// <c>POST /rotate-key</c>. Generates a new active ECDSA P-256 keypair,
/// retires the previous active key for <c>OldKeyGraceSeconds</c>, returns
/// the new <c>kid</c>.
/// </summary>
public static IResult Handle(KeyStore keys)
{
var newKey = keys.Rotate();
return Results.Json(new { kid = newKey.Kid });
}
}
@@ -0,0 +1,65 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Azaion.Missions.JwksMock.Services;
namespace Azaion.Missions.JwksMock.Endpoints;
public static class SignEndpoint
{
/// <summary>
/// <c>POST /sign</c>. Body is a small JSON object documented in
/// <c>_docs/02_document/tests/test-data.md § JWKS mock token-minting contract</c>.
/// All fields optional; omitted fields fall back to mock defaults.
/// </summary>
public static async Task<IResult> Handle(HttpContext ctx, TokenSigner signer)
{
SignBody? body;
try
{
body = await JsonSerializer.DeserializeAsync(
ctx.Request.Body,
SignBodyContext.Default.SignBody,
ctx.RequestAborted);
}
catch (JsonException ex)
{
return Results.BadRequest(new { error = "invalid_json", detail = ex.Message });
}
body ??= new SignBody();
try
{
var result = signer.Sign(new SignRequest(
Issuer: body.Iss,
Audience: body.Aud,
ExpOffsetSeconds: body.ExpOffsetSeconds,
Permissions: body.Permissions,
Subject: body.Sub,
AlgOverride: body.AlgOverride,
KidOverride: body.KidOverride));
return Results.Json(new SignResponse(result.Token, result.Kid), SignBodyContext.Default.SignResponse);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = "invalid_arg", detail = ex.Message });
}
}
}
public sealed record SignBody(
[property: JsonPropertyName("iss")] string? Iss = null,
[property: JsonPropertyName("aud")] string? Aud = null,
[property: JsonPropertyName("sub")] string? Sub = null,
[property: JsonPropertyName("exp_offset_seconds")] int? ExpOffsetSeconds = null,
[property: JsonPropertyName("permissions")] string? Permissions = null,
[property: JsonPropertyName("alg_override")] string? AlgOverride = null,
[property: JsonPropertyName("kid_override")] string? KidOverride = null);
public sealed record SignResponse(
[property: JsonPropertyName("token")] string Token,
[property: JsonPropertyName("kid")] string Kid);
[JsonSerializable(typeof(SignBody))]
[JsonSerializable(typeof(SignResponse))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)]
internal sealed partial class SignBodyContext : JsonSerializerContext;
+60
View File
@@ -0,0 +1,60 @@
using System.Security.Cryptography.X509Certificates;
using Azaion.Missions.JwksMock.Endpoints;
using Azaion.Missions.JwksMock.Services;
var builder = WebApplication.CreateBuilder(args);
// Tests source these from the compose env block (JWT_ISSUER, JWT_AUDIENCE,
// OLD_KEY_GRACE_SECONDS); appsettings.json supplies dev defaults.
var issuer = builder.Configuration["JWT_ISSUER"]
?? builder.Configuration["Jwks:Issuer"]
?? throw new InvalidOperationException("JWT_ISSUER not configured");
var audience = builder.Configuration["JWT_AUDIENCE"]
?? builder.Configuration["Jwks:Audience"]
?? throw new InvalidOperationException("JWT_AUDIENCE not configured");
var oldKeyGraceSecRaw = builder.Configuration["OLD_KEY_GRACE_SECONDS"]
?? builder.Configuration["Jwks:OldKeyGraceSeconds"]
?? "5";
var oldKeyGrace = TimeSpan.FromSeconds(int.Parse(oldKeyGraceSecRaw, System.Globalization.CultureInfo.InvariantCulture));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<KeyStore>(sp => new KeyStore(oldKeyGrace, sp.GetRequiredService<TimeProvider>()));
builder.Services.AddSingleton<TokenSigner>(sp => new TokenSigner(
sp.GetRequiredService<KeyStore>(),
sp.GetRequiredService<TimeProvider>(),
issuer,
audience));
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(8443, listen =>
{
listen.UseHttps(LoadTlsCert());
});
});
var app = builder.Build();
app.MapGet("/.well-known/jwks.json", JwksEndpoint.Handle);
app.MapPost("/sign", SignEndpoint.Handle);
app.MapPost("/rotate-key", RotateKeyEndpoint.Handle);
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.Run();
// Loads the server TLS cert + key from the build context. The same cert is
// also published as `tests/jwks-mock-ca.crt` and mounted into the missions +
// e2e-consumer containers as a trust anchor.
static X509Certificate2 LoadTlsCert()
{
var basePath = AppContext.BaseDirectory;
var crtPath = Path.Combine(basePath, "tls", "jwks-mock.crt");
var keyPath = Path.Combine(basePath, "tls", "jwks-mock.key");
if (!File.Exists(crtPath) || !File.Exists(keyPath))
throw new FileNotFoundException(
$"jwks-mock TLS materials not found. Expected:\n {crtPath}\n {keyPath}\n" +
"Run tests/Azaion.Missions.JwksMock/regen-cert.sh to regenerate.");
return X509Certificate2.CreateFromPemFile(crtPath, keyPath);
}
public partial class Program; // For WebApplicationFactory if a host-process test ever needs it.
@@ -0,0 +1,19 @@
namespace Azaion.Missions.JwksMock.Services;
/// <summary>RFC 7515 §2 base64url (no padding) helpers.</summary>
public static class Base64Url
{
public static string Encode(ReadOnlySpan<byte> input)
{
var b64 = Convert.ToBase64String(input);
return b64.Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
public static byte[] Decode(string input)
{
var s = input.Replace('-', '+').Replace('_', '/');
var pad = s.Length % 4;
if (pad > 0) s += new string('=', 4 - pad);
return Convert.FromBase64String(s);
}
}
@@ -0,0 +1,118 @@
using System.Security.Cryptography;
namespace Azaion.Missions.JwksMock.Services;
/// <summary>
/// Holds the active ECDSA P-256 keypair used to sign test JWTs, plus an
/// optional retired keypair retained for <c>OldKeyGraceSeconds</c> after a
/// rotation so consumers can still validate in-flight tokens minted under the
/// previous kid (NFT-RES-07 / NFT-SEC-11).
/// </summary>
/// <remarks>
/// Singleton, thread-safe. The private key never leaves the container — only
/// public-half exports go out via the JWKS endpoint.
/// </remarks>
public sealed class KeyStore : IDisposable
{
private readonly TimeSpan _graceWindow;
private readonly TimeProvider _clock;
private readonly Lock _gate = new();
private KeypairEntry _active;
private KeypairEntry? _retired;
public KeyStore(TimeSpan graceWindow, TimeProvider clock)
{
_graceWindow = graceWindow;
_clock = clock;
_active = KeypairEntry.New();
}
public KeypairView Active
{
get
{
lock (_gate) return _active.View();
}
}
public IReadOnlyList<KeypairView> PublishedKeys()
{
lock (_gate)
{
EvictExpiredRetired();
if (_retired is null)
return [_active.View()];
return [_active.View(), _retired.View()];
}
}
/// <summary>
/// Rotate the active keypair. The previous active key is retained as the
/// retired key (overwriting any older retired entry) until
/// <c>OldKeyGraceSeconds</c> elapses.
/// </summary>
public KeypairView Rotate()
{
lock (_gate)
{
_retired?.Dispose();
_retired = _active.WithRetiredAt(_clock.GetUtcNow().Add(_graceWindow));
_active = KeypairEntry.New();
return _active.View();
}
}
public void Dispose()
{
lock (_gate)
{
_active.Dispose();
_retired?.Dispose();
_retired = null;
}
}
private void EvictExpiredRetired()
{
if (_retired is null) return;
if (_retired.RetiredAtUtc is { } retiredAt && _clock.GetUtcNow() > retiredAt)
{
_retired.Dispose();
_retired = null;
}
}
private sealed class KeypairEntry : IDisposable
{
public ECDsa Ec { get; }
public string Kid { get; }
public DateTimeOffset? RetiredAtUtc { get; }
private KeypairEntry(ECDsa ec, string kid, DateTimeOffset? retiredAt)
{
Ec = ec;
Kid = kid;
RetiredAtUtc = retiredAt;
}
public static KeypairEntry New()
{
var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
// kid: SHA-256 of the public key parameters, base64url-truncated to 16 bytes.
var pub = ec.ExportSubjectPublicKeyInfo();
var hash = SHA256.HashData(pub);
var kid = Base64Url.Encode(hash.AsSpan(0, 16));
return new KeypairEntry(ec, kid, retiredAt: null);
}
public KeypairEntry WithRetiredAt(DateTimeOffset retiredAtUtc)
=> new(Ec, Kid, retiredAtUtc);
public KeypairView View() => new(Kid, Ec, RetiredAtUtc);
public void Dispose() => Ec.Dispose();
}
}
public readonly record struct KeypairView(string Kid, ECDsa Ec, DateTimeOffset? RetiredAtUtc);
@@ -0,0 +1,102 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Azaion.Missions.JwksMock.Services;
/// <summary>
/// Hand-rolls JWS-compact ES256 tokens for tests. Honors per-call overrides
/// the test harness uses to exercise NFT-SEC-* (alg confusion, unknown kid,
/// claim mismatch, etc.).
/// </summary>
public sealed class TokenSigner
{
private readonly KeyStore _keys;
private readonly TimeProvider _clock;
private readonly string _defaultIssuer;
private readonly string _defaultAudience;
public TokenSigner(KeyStore keys, TimeProvider clock, string defaultIssuer, string defaultAudience)
{
_keys = keys;
_clock = clock;
_defaultIssuer = defaultIssuer;
_defaultAudience = defaultAudience;
}
public SignResult Sign(SignRequest request)
{
var active = _keys.Active;
var kid = request.KidOverride ?? active.Kid;
var alg = request.AlgOverride ?? "ES256";
var nowUnix = _clock.GetUtcNow().ToUnixTimeSeconds();
var expUnix = nowUnix + (request.ExpOffsetSeconds ?? 3600);
var header = new JsonObject
{
["alg"] = alg,
["kid"] = kid,
["typ"] = "JWT"
};
var payload = new JsonObject
{
["iss"] = request.Issuer ?? _defaultIssuer,
["aud"] = request.Audience ?? _defaultAudience,
["iat"] = nowUnix,
["exp"] = expUnix
};
if (request.Permissions is not null)
payload["permissions"] = request.Permissions;
if (request.Subject is not null)
payload["sub"] = request.Subject;
var headerBytes = JsonSerializer.SerializeToUtf8Bytes(header);
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload);
var headerSeg = Base64Url.Encode(headerBytes);
var payloadSeg = Base64Url.Encode(payloadBytes);
// Signing input is the literal ASCII string "<header>.<payload>" per RFC 7515 §5.1.
var signingInput = Encoding.ASCII.GetBytes($"{headerSeg}.{payloadSeg}");
byte[] signature;
if (alg == "ES256")
{
signature = active.Ec.SignData(signingInput, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
else if (alg == "HS256")
{
// alg-confusion attack vector for NFT-SEC-10. We sign with a key derived
// from the active public key so a naive validator that fails to enforce
// alg pinning would accept the token.
var pubKey = active.Ec.ExportSubjectPublicKeyInfo();
using var hmac = new HMACSHA256(pubKey);
signature = hmac.ComputeHash(signingInput);
}
else if (alg == "none")
{
signature = [];
}
else
{
throw new ArgumentException($"Unsupported alg_override '{alg}'", nameof(request));
}
var sigSeg = Base64Url.Encode(signature);
var token = $"{headerSeg}.{payloadSeg}.{sigSeg}";
return new SignResult(token, kid);
}
}
public sealed record SignRequest(
string? Issuer,
string? Audience,
int? ExpOffsetSeconds,
string? Permissions,
string? Subject,
string? AlgOverride,
string? KidOverride);
public sealed record SignResult(string Token, string Kid);
@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Jwks": {
"Issuer": "https://admin-test.azaion.local",
"Audience": "azaion-edge",
"OldKeyGraceSeconds": 5
}
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
## Regenerate the jwks-mock TLS keypair + the trust-anchor copy mounted into
## consumers. Both files are committed test artifacts (the test runs are
## deterministic, so the cert is reused across CI runs unless the keypair is
## intentionally rotated).
##
## Outputs:
## tests/Azaion.Missions.JwksMock/tls/jwks-mock.key (private, 0600)
## tests/Azaion.Missions.JwksMock/tls/jwks-mock.crt (public, ECDSA P-256, 100y)
## tests/jwks-mock-ca.crt (copy of jwks-mock.crt)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TLS_DIR="$SCRIPT_DIR/tls"
TESTS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
mkdir -p "$TLS_DIR"
cd "$TLS_DIR"
openssl ecparam -name prime256v1 -genkey -noout -out jwks-mock.key
openssl req -new -x509 \
-key jwks-mock.key \
-out jwks-mock.crt \
-days 36500 \
-sha256 \
-subj "/CN=jwks-mock" \
-addext "subjectAltName=DNS:jwks-mock,DNS:localhost,IP:127.0.0.1" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,digitalSignature,keyEncipherment,keyCertSign" \
-addext "extendedKeyUsage=serverAuth"
chmod 600 jwks-mock.key
cp jwks-mock.crt "$TESTS_DIR/jwks-mock-ca.crt"
echo "[regen-cert] regenerated:"
echo " $TLS_DIR/jwks-mock.key"
echo " $TLS_DIR/jwks-mock.crt"
echo " $TESTS_DIR/jwks-mock-ca.crt"
@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBzDCCAXOgAwIBAgIUZDltID1GVJuqwUDA+867RVJHYOwwCgYIKoZIzj0EAwIw
FDESMBAGA1UEAwwJandrcy1tb2NrMCAXDTI2MDUxNTAzNDAxM1oYDzIxMjYwNDIx
MDM0MDEzWjAUMRIwEAYDVQQDDAlqd2tzLW1vY2swWTATBgcqhkjOPQIBBggqhkjO
PQMBBwNCAATS59eN3v/CvrfN5OHTqWe/wp/ZsayKsf6g3sfjWaqreCgQWiVdfHas
tbny+dwuGdcv8F0uMINEXcmWDKY73dono4GgMIGdMB0GA1UdDgQWBBT8KD5Dt+Da
s19QUvSB0kpY6JxiLzAfBgNVHSMEGDAWgBT8KD5Dt+Das19QUvSB0kpY6JxiLzAl
BgNVHREEHjAcgglqd2tzLW1vY2uCCWxvY2FsaG9zdIcEfwAAATAPBgNVHRMBAf8E
BTADAQH/MA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggrBgEFBQcDATAKBggq
hkjOPQQDAgNHADBEAiBZL20arEn9WnXpbqilOrvOSk1b9tFb2Ad7NIMq8mQoZAIg
BD49p5vjFs7lvIlhX/mjs+LbITx1HX7EpztVszNsAfk=
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBIZ9LfWiAeAxoOIYbFoD+tCDoO+5uIyhsPNSrmMCjknoAoGCCqGSM49
AwEHoUQDQgAE0ufXjd7/wr63zeTh06lnv8Kf2bGsirH+oN7H41mqq3goEFolXXx2
rLW58vncLhnXL/BdLjCDRF3JlgymO93aJw==
-----END EC PRIVATE KEY-----