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
@@ -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;
}
}