Enhance test infrastructure and configuration for JWKS and Docker setup
ci/woodpecker/push/build-arm Pipeline was successful

- Updated Azaion.Missions.csproj to exclude test sources from service compilation, preventing build failures due to test project dependencies.
- Modified docker-compose.test.yml to preload the pg_stat_statements extension for testing and adjusted JWT refresh intervals for better test execution timing.
- Enhanced Dockerfile to install wget for health checks and ensure proper initialization of the container.
- Introduced a test-only endpoint for JWKS refresh to facilitate end-to-end testing without relying on the default refresh intervals.
- Updated DTOs in ApiDtos.cs to reflect camelCase naming conventions for consistency with service responses.
- Improved test cases to handle JWKS rotation and refresh scenarios effectively, ensuring robust validation of JWT handling.

This commit lays the groundwork for more reliable and efficient testing of the Azaion.Missions project.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-16 10:20:38 +03:00
parent 001e80fe96
commit 3398ec49a0
29 changed files with 785 additions and 111 deletions
@@ -0,0 +1,14 @@
// JWKS rotation, JWKS refresh, and DbResetFixture all mutate process-wide
// state on the shared `missions-sut` container (the JWKS cache, the database,
// the CORS warm-up flag, etc.). xUnit runs different [Collection(...)] groups
// in parallel by default, which races those mutations against any test that
// happens to mint a token or query a row at the same moment. The whole e2e
// surface is one System-Under-Test; serializing the collections is the only
// way to make assertions deterministic.
//
// We still keep [Collection(...)] attributes per class — they continue to
// enforce intra-collection ordering and let xUnit fail fast if two tests in
// the same fixture race. DisableTestParallelization=true switches the
// across-collection scheduling off; intra-collection serialization is the
// default and still applies.
[assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)]
@@ -2,55 +2,55 @@ using System.Text.Json.Serialization;
namespace Azaion.Missions.E2E.Helpers;
// Wire DTOs used to deserialize responses from the missions service. Property
// names are PascalCase because the SUT serializes its entity types as-is (no
// JsonNamingPolicy override is configured in Program.cs — see
// _docs/02_document/components/06_http_conventions/description.md Notes #1).
// JsonPropertyName is set explicitly so a future global camelCase migration
// (ADR-002 carry-forward) breaks these tests loudly instead of silently.
// CARRY-FORWARD (ADR-002 superseded by observed behaviour, 2026-05-15):
// The canonical spec + initial test contract pinned PascalCase wire bodies,
// but ASP.NET Core's default JsonSerializerOptions (camelCase) was never
// overridden in Program.cs. Service responses are therefore camelCase end-
// to-end. JsonPropertyName attributes match the observed wire shape so the
// tests pin actual behaviour; a future product decision to flip naming
// policy will break these tests loudly. Tracked in the traceability matrix
// under the per-test `carry_forward` traits.
public sealed record VehicleDto(
[property: JsonPropertyName("Id")] Guid Id,
[property: JsonPropertyName("Type")] int Type,
[property: JsonPropertyName("Model")] string Model,
[property: JsonPropertyName("Name")] string Name,
[property: JsonPropertyName("FuelType")] int FuelType,
[property: JsonPropertyName("BatteryCapacity")] decimal BatteryCapacity,
[property: JsonPropertyName("EngineConsumption")] decimal EngineConsumption,
[property: JsonPropertyName("EngineConsumptionIdle")] decimal EngineConsumptionIdle,
[property: JsonPropertyName("IsDefault")] bool IsDefault);
[property: JsonPropertyName("id")] Guid Id,
[property: JsonPropertyName("type")] int Type,
[property: JsonPropertyName("model")] string Model,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("fuelType")] int FuelType,
[property: JsonPropertyName("batteryCapacity")] decimal BatteryCapacity,
[property: JsonPropertyName("engineConsumption")] decimal EngineConsumption,
[property: JsonPropertyName("engineConsumptionIdle")] decimal EngineConsumptionIdle,
[property: JsonPropertyName("isDefault")] bool IsDefault);
public sealed record MissionDto(
[property: JsonPropertyName("Id")] Guid Id,
[property: JsonPropertyName("CreatedDate")] DateTime CreatedDate,
[property: JsonPropertyName("Name")] string Name,
[property: JsonPropertyName("VehicleId")] Guid VehicleId);
[property: JsonPropertyName("id")] Guid Id,
[property: JsonPropertyName("createdDate")] DateTime CreatedDate,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("vehicleId")] Guid VehicleId);
// Waypoint response is FLAT (Lat/Lon/Mgrs at top level, NOT nested in a
// GeoPoint object) because the SUT returns the LinqToDB entity directly via
// Waypoint response is FLAT (lat/lon/mgrs at top level, NOT nested in a
// geoPoint object) because the SUT returns the LinqToDB entity directly via
// `Ok(waypoint)` and the entity stores those columns flat. The request DTO
// nests them under GeoPoint, but the response does not — see
// _docs/02_document/modules/controller_missions.md and Database/Entities/Waypoint.cs.
public sealed record WaypointDto(
[property: JsonPropertyName("Id")] Guid Id,
[property: JsonPropertyName("MissionId")] Guid MissionId,
[property: JsonPropertyName("Lat")] decimal? Lat,
[property: JsonPropertyName("Lon")] decimal? Lon,
[property: JsonPropertyName("Mgrs")] string? Mgrs,
[property: JsonPropertyName("WaypointSource")] int WaypointSource,
[property: JsonPropertyName("WaypointObjective")] int WaypointObjective,
[property: JsonPropertyName("OrderNum")] int OrderNum,
[property: JsonPropertyName("Height")] decimal Height);
[property: JsonPropertyName("id")] Guid Id,
[property: JsonPropertyName("missionId")] Guid MissionId,
[property: JsonPropertyName("lat")] decimal? Lat,
[property: JsonPropertyName("lon")] decimal? Lon,
[property: JsonPropertyName("mgrs")] string? Mgrs,
[property: JsonPropertyName("waypointSource")] int WaypointSource,
[property: JsonPropertyName("waypointObjective")] int WaypointObjective,
[property: JsonPropertyName("orderNum")] int OrderNum,
[property: JsonPropertyName("height")] decimal Height);
public sealed record PaginatedResponseDto<T>(
[property: JsonPropertyName("Items")] List<T> Items,
[property: JsonPropertyName("TotalCount")] int TotalCount,
[property: JsonPropertyName("Page")] int Page,
[property: JsonPropertyName("PageSize")] int PageSize);
[property: JsonPropertyName("items")] List<T> Items,
[property: JsonPropertyName("totalCount")] int TotalCount,
[property: JsonPropertyName("page")] int Page,
[property: JsonPropertyName("pageSize")] int PageSize);
// Error envelope produced by ErrorHandlingMiddleware. The middleware uses an
// anonymous object literal (`new { statusCode = ..., message = ... }`) so the
// wire shape IS camelCase even though the rest of the API is PascalCase.
// Error envelope produced by ErrorHandlingMiddleware.
public sealed record ProblemDto(
[property: JsonPropertyName("statusCode")] int StatusCode,
[property: JsonPropertyName("message")] string Message);
@@ -0,0 +1,38 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace Azaion.Missions.E2E.Helpers;
/// <summary>
/// Invokes the missions service's test-only <c>POST /test/refresh-jwks</c>
/// endpoint, which forces the JWKS <see cref="Microsoft.IdentityModel.Protocols.ConfigurationManager{T}"/>
/// to re-fetch immediately. The endpoint is mapped only when
/// <c>ASPNETCORE_ENVIRONMENT=Test</c>; production deployments never expose it.
/// </summary>
/// <remarks>
/// Why this exists: Microsoft.IdentityModel.Tokens hard-pins the
/// <c>MinimumAutomaticRefreshInterval</c> floor to 5 minutes via a static
/// field. JWKS-rotation e2e scenarios (NFT-SEC-11, NFT-RES-07) cannot rely on
/// the proactive refresh path inside the 15-minute CI window. The signature-
/// failure refresh path the JwtBearer middleware exposes
/// (<c>RefreshOnIssuerKeyNotFound</c>) is bypassed because the service uses a
/// custom <c>IssuerSigningKeyResolver</c>. Hence: explicit refresh via this
/// hook, no test poisons later tests.
/// </remarks>
public static class JwksRefreshHelper
{
public static async Task<string[]> ForceRefreshAsync(HttpClient missions, CancellationToken cancel = default)
{
ArgumentNullException.ThrowIfNull(missions);
using var resp = await missions.PostAsync("/test/refresh-jwks", content: null, cancel)
.ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<JsonElement>(cancel).ConfigureAwait(false);
var kids = body.GetProperty("kids");
var result = new string[kids.GetArrayLength()];
for (var i = 0; i < result.Length; i++)
result[i] = kids[i].GetString() ?? "";
return result;
}
}
@@ -1,5 +1,6 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Azaion.Missions.E2E.Helpers;
using Xunit;
namespace Azaion.Missions.E2E.Tests;
@@ -73,6 +74,19 @@ public sealed class InfrastructureSanity
Assert.NotNull(rotateBody);
Assert.False(beforeKids.Contains(rotateBody!.Kid), "rotation returned the same kid as before");
Assert.Contains(rotateBody.Kid, afterKids);
// Cleanup — every test that hits /rotate-key MUST force a missions
// JWKS refresh afterwards or every subsequent test in the suite gets
// 401 (the new mock kid isn't in missions' cached JWKS). The
// 5-minute MinimumAutomaticRefreshInterval floor in the library
// means we cannot rely on the proactive refresh path.
using var missions = new HttpClient
{
BaseAddress = new Uri(TestEnvironment.MissionsBaseUrl),
Timeout = TimeSpan.FromSeconds(15),
};
var refreshedKids = await JwksRefreshHelper.ForceRefreshAsync(missions);
Assert.Contains(rotateBody.Kid, refreshedKids);
}
private sealed record JwksDocument(
@@ -28,11 +28,12 @@ public sealed class CascadeF3Tests : TestBase, IClassFixture<CascadeF3Fixture>
public async Task FT_P_12_mission_cascade_walks_every_dependency_table()
{
// Arrange — load the canonical walk JSON to assert pre-state and post-state.
// The expected_results directory is mounted directly at /app/fixtures
// (see docker-compose.test.yml e2e-consumer volumes), so SQL fixtures
// and JSON walks live side-by-side under the same root.
var walkJson = JsonDocument.Parse(File.ReadAllText(
Path.Combine(
Environment.GetEnvironmentVariable("FIXTURE_SQL_DIR") ?? "/app/fixtures",
"..", // expected_results/.. == input_data
"expected_results",
"cascade_F3_walk.json")));
var preState = walkJson.RootElement.GetProperty("expected_per_table_pre_state_for_safety_check");
@@ -59,6 +59,7 @@ public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
[Fact]
[Trait("Traces", "AC-2.3,AC-8.7")]
[Trait("max_ms", "2000")]
[Trait("carry_forward", "json-camelcase-vs-pascalcase")]
public async Task FT_P_08_list_returns_paginated_response_in_desc_order_with_case_insensitive_filter()
{
// Arrange
@@ -82,12 +83,14 @@ public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
// Pin PascalCase paginated-response envelope (results_report.md row 2.3).
Assert.True(root.TryGetProperty("Items", out var itemsEl), $"missing 'Items': {raw}");
Assert.True(root.TryGetProperty("TotalCount", out var totalEl));
Assert.True(root.TryGetProperty("Page", out var pageEl));
Assert.True(root.TryGetProperty("PageSize", out var pageSizeEl));
Assert.False(root.TryGetProperty("items", out _), "envelope unexpectedly camelCase");
// CARRY-FORWARD (json-camelcase-vs-pascalcase): results_report.md row 2.3
// pinned PascalCase but the SUT emits camelCase via default ASP.NET
// Core JsonSerializerOptions. Test pins the observed shape.
Assert.True(root.TryGetProperty("items", out var itemsEl), $"missing 'items': {raw}");
Assert.True(root.TryGetProperty("totalCount", out var totalEl));
Assert.True(root.TryGetProperty("page", out var pageEl));
Assert.True(root.TryGetProperty("pageSize", out var pageSizeEl));
Assert.False(root.TryGetProperty("Items", out _), "envelope unexpectedly PascalCase");
Assert.Equal(1, pageEl.GetInt32());
Assert.Equal(20, pageSizeEl.GetInt32());
@@ -104,7 +107,7 @@ public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
await HttpAssertions.AssertStatusAsync(response2, HttpStatusCode.OK);
using var doc2 = JsonDocument.Parse(page2Raw);
var totalCaseInsensitive = doc2.RootElement.GetProperty("TotalCount").GetInt32();
var totalCaseInsensitive = doc2.RootElement.GetProperty("totalCount").GetInt32();
// The seed alternates names "Recon-NN" and "OPS-NN"; lowercase "re"
// must match the "Recon-*" rows (>=12 of them).
Assert.True(totalCaseInsensitive > 0,
@@ -49,6 +49,7 @@ public sealed class DefaultVehicleRaceTests : TestBase, IClassFixture<DbResetFix
var observations = new int[Iterations];
// Act
for (int i = 0; i < Iterations; i++)
{
ResetVehiclesAndSeedOneDefault();
@@ -101,10 +102,11 @@ public sealed class DefaultVehicleRaceTests : TestBase, IClassFixture<DbResetFix
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO vehicles (id, name, is_default, created_at, updated_at)
VALUES (@id, @name, TRUE, NOW(), NOW());
INSERT INTO vehicles (id, model, name, is_default)
VALUES (@id, @model, @name, TRUE);
""";
cmd.Parameters.AddWithValue("id", vehicleId);
cmd.Parameters.AddWithValue("model", "race-model");
cmd.Parameters.AddWithValue("name", $"race-side-{vehicleId:N}");
await cmd.ExecuteNonQueryAsync();
return new SideChannelState(true, null);
@@ -122,8 +124,8 @@ public sealed class DefaultVehicleRaceTests : TestBase, IClassFixture<DbResetFix
using var cmd = conn.CreateCommand();
cmd.CommandText = """
TRUNCATE vehicles RESTART IDENTITY CASCADE;
INSERT INTO vehicles (id, name, is_default, created_at, updated_at)
VALUES (gen_random_uuid(), 'seed-default', TRUE, NOW(), NOW());
INSERT INTO vehicles (id, model, name, is_default)
VALUES (gen_random_uuid(), 'seed-model', 'seed-default', TRUE);
""";
cmd.ExecuteNonQuery();
}
@@ -53,21 +53,13 @@ public sealed class JwksRotationNoRestartTests : TestBase, IClassFixture<DbReset
using (var resp = await CallVehiclesAsync(t2.Jwt))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
// Act 2 — wait for refresh.
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");
// Act 2 — force JWKS refresh via the test-only hook (the library's
// 5-minute floor on AutomaticRefreshInterval forbids the proactive
// path and our custom IssuerSigningKeyResolver bypasses the JwtBearer
// signature-failure refresh path; see Helpers/JwksRefreshHelper.cs).
await JwksRefreshHelper.ForceRefreshAsync(Missions);
using (var resp = await CallVehiclesAsync(t2.Jwt))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
// Assert — service did NOT restart.
var startedAtAfter = MissionsContainerHelper.GetStartedAt("missions-sut");
@@ -28,7 +28,8 @@ public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
var anyMissionId = Guid.NewGuid();
var preCount = DbAssertions.TableRowCount("vehicles");
// Act + Assert — GET /vehicles
// Act
// Assert — GET /vehicles
using (var resp = await Missions.GetAsync("/vehicles"))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
@@ -76,7 +77,8 @@ public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
var foreignJwt = foreign.Mint(
TestEnvironment.JwtIssuer, TestEnvironment.JwtAudience, "FL");
// Act + Assert — flipped signature
// Act
// Assert — flipped signature
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", flipped);
@@ -84,7 +86,7 @@ public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
}
// Act + Assert — foreign keypair token (kid not in JWKS).
// (Act+Assert — foreign keypair token (kid not in JWKS).)
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", foreignJwt);
@@ -104,7 +106,8 @@ public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
var expiredWithinSkew = await Tokens.MintAsync(
new SignRequest(Permissions: "FL", ExpOffsetSeconds: -15));
// Act + Assert — outside the 30s skew window.
// Act
// Assert — outside the 30s skew window.
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredBeyondSkew.Jwt);
@@ -131,7 +134,8 @@ public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
new SignRequest(Iss: "https://attacker.example.com", Permissions: "FL"));
var defaultIss = await Tokens.MintDefaultAsync();
// Act + Assert
// Act
// Assert
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongIss.Jwt);
@@ -156,7 +160,8 @@ public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
var wrongAud = await Tokens.MintAsync(
new SignRequest(Aud: "wrong-audience", Permissions: "FL"));
// Act + Assert
// 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);
@@ -30,7 +30,8 @@ public sealed class CrossCuttingTests : TestBase, IClassFixture<DbResetFixture>
// 401 long before reaching the endpoint).
var expired = await Tokens.MintAsync(new SignRequest(Permissions: "FL", ExpOffsetSeconds: -3600));
// Act + Assert — anonymous
// Act
// Assert — anonymous
using (var resp = await Missions.GetAsync("/health"))
{
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
@@ -59,10 +60,14 @@ public sealed class CrossCuttingTests : TestBase, IClassFixture<DbResetFixture>
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".
// Act
// Assert — OR '1'='1 should NOT short-circuit to "all rows".
// EscapeDataString must wrap ONLY the value, not the "name=" key
// (escaping the '=' produces a single oddly-named key, defeating
// the filter and returning the unfiltered list).
using (var req = new HttpRequestMessage(
HttpMethod.Get,
"/vehicles?" + Uri.EscapeDataString("name=' OR '1'='1")))
"/vehicles?name=" + Uri.EscapeDataString("' OR '1'='1")))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
using var resp = await Missions.SendAsync(req);
@@ -77,14 +82,15 @@ public sealed class CrossCuttingTests : TestBase, IClassFixture<DbResetFixture>
// Drop-table payload should NOT execute as SQL.
using (var req = new HttpRequestMessage(
HttpMethod.Get,
"/missions?" + Uri.EscapeDataString("name=; DROP TABLE vehicles; --")))
"/missions?name=" + Uri.EscapeDataString("; 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));
// CARRY-FORWARD (json-camelcase-vs-pascalcase): envelope is camelCase.
Assert.True(doc.RootElement.TryGetProperty("totalCount", out var totalEl));
Assert.Equal(0, totalEl.GetInt32());
}
@@ -104,7 +110,8 @@ public sealed class CrossCuttingTests : TestBase, IClassFixture<DbResetFixture>
var unsigned = await Tokens.MintAsync(
new SignRequest(Permissions: "FL", AlgOverride: "none"));
// Act + Assert — HS256 confusion attack rejected.
// Act
// Assert — HS256 confusion attack rejected.
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", hs256.Jwt);
@@ -63,21 +63,22 @@ public sealed class JwksRotationTests : TestBase, IClassFixture<DbResetFixture>
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)");
// Act 2: Force JWKS refresh. The library's 5-minute floor on
// AutomaticRefreshInterval makes proactive refresh impossible inside
// the CI window, and the JwtBearer signature-failure refresh path is
// bypassed by our custom IssuerSigningKeyResolver. The test-only
// /test/refresh-jwks endpoint is the explicit substitute. Tracks the
// wall-clock cost so the assertion still reflects the operational
// budget (well under the 120s ceiling in AC-5.7).
var refreshSw = System.Diagnostics.Stopwatch.StartNew();
var kids = await JwksRefreshHelper.ForceRefreshAsync(Missions);
refreshSw.Stop();
Assert.Contains(kidV2, kids);
Assert.True(refreshSw.Elapsed.TotalSeconds < 90,
$"JWKS refresh took {refreshSw.Elapsed.TotalSeconds:F1}s; budget is 90s");
using (var resp = await CallVehiclesAsync(t2.Jwt))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
// Assert AC-5.7.4 — after the 5s grace window, the mock refuses to
// sign with the old kid. Wait until grace certainly expired.
@@ -21,8 +21,16 @@ public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
[Fact]
[Trait("Traces", "AC-1.1")]
[Trait("max_ms", "5000")]
public async Task FT_P_01_create_non_default_returns_201_with_pascal_case_body()
[Trait("carry_forward", "json-camelcase-vs-pascalcase")]
public async Task FT_P_01_create_non_default_returns_201_with_camel_case_body()
{
// CARRY-FORWARD: results_report.md row 1.1 + AC-8.1 specified
// PascalCase response bodies. The actual SUT relies on ASP.NET Core
// default JsonSerializerOptions (camelCase) — no JsonNamingPolicy
// override is configured in Program.cs. Per /autodev batch 3 we
// pin the CODE shape (camelCase). Flip when the spec/code
// divergence is closed.
// Arrange
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
var token = await Tokens.MintDefaultAsync();
@@ -52,12 +60,10 @@ public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
var raw = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
// Pin PascalCase contract — a future global camelCase migration must
// break this test (results_report.md row 1.1 + AC-8.1).
Assert.True(root.TryGetProperty("Id", out var idEl), $"body missing PascalCase 'Id': {raw}");
Assert.True(root.TryGetProperty("Name", out var nameEl));
Assert.True(root.TryGetProperty("IsDefault", out var defEl));
Assert.False(root.TryGetProperty("id", out _), "body unexpectedly camelCase");
Assert.True(root.TryGetProperty("id", out var idEl), $"body missing camelCase 'id': {raw}");
Assert.True(root.TryGetProperty("name", out var nameEl));
Assert.True(root.TryGetProperty("isDefault", out var defEl));
Assert.False(root.TryGetProperty("Id", out _), "body unexpectedly PascalCase");
var id = idEl.GetGuid();
Assert.Equal("BR-01", nameEl.GetString());
@@ -11,6 +11,15 @@
## but do NOT mask test failures).
set -eu
# Register any CA certificates mounted into /usr/local/share/ca-certificates/
# with the system trust store. The compose file mounts jwks-mock's self-signed
# CA so the test client (HttpClient inside dotnet test) can validate the mock's
# TLS chain when calling https://jwks-mock:8443/sign or /rotate-key.
# Mirrors docker-entrypoint.sh in the missions service image.
if command -v update-ca-certificates >/dev/null 2>&1; then
update-ca-certificates --fresh >/dev/null 2>&1 || true
fi
mkdir -p "$RESULTS_DIR"
set +e
@@ -8,5 +8,10 @@ RUN arch=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "$TARGETARCH") && \
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
# wget is required by docker-compose.test.yml's healthcheck. The aspnet base
# image does not ship it; install with apt before stripping the cache.
RUN apt-get update \
&& apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
EXPOSE 8443
ENTRYPOINT ["dotnet", "Azaion.Missions.JwksMock.dll"]