mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 08:21:08 +00:00
3398ec49a0
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.
145 lines
5.3 KiB
C#
145 lines
5.3 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using Azaion.Missions.E2E.Fixtures;
|
|
using Azaion.Missions.E2E.Helpers;
|
|
using Npgsql;
|
|
using Xunit;
|
|
|
|
namespace Azaion.Missions.E2E.Tests.Resilience;
|
|
|
|
/// <summary>
|
|
/// NFT-RES-08 — TOCTOU race on <c>vehicles.is_default</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Spec AC-1.4 expects the race to be OBSERVABLE — i.e. at least one of 100
|
|
/// concurrent iterations leaves two rows with <c>is_default=true</c>. The
|
|
/// current migrator ships
|
|
/// <c>ux_vehicles_one_default ON vehicles (is_default) WHERE is_default = TRUE</c>,
|
|
/// which closes the race at the storage layer: the second writer always
|
|
/// fails with <c>23505</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Following <c>CascadeF4Tests</c> precedent we pin the CURRENT behaviour
|
|
/// (max-one default after the race) and mark the divergence with the
|
|
/// <c>carry_forward</c> trait. If the index is ever removed without an
|
|
/// application-level guard replacing it, this test fails loudly — that
|
|
/// failure is the signal to revisit the AC-1.4 carry-forward in the
|
|
/// traceability matrix.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Collection("MigratorRestart")]
|
|
[Trait("Category", "Res")]
|
|
[Trait("carry_forward", "AC-1.4/index-closes-race")]
|
|
[Trait("db_access", "seed-or-assert-only")]
|
|
public sealed class DefaultVehicleRaceTests : TestBase, IClassFixture<DbResetFixture>
|
|
{
|
|
private const int Iterations = 100;
|
|
|
|
[Fact]
|
|
[Trait("Traces", "AC-1.4")]
|
|
[Trait("max_ms", "30000")]
|
|
public async Task NFT_RES_08_concurrent_default_writes_converge_on_one_default_today()
|
|
{
|
|
// Arrange — fresh DB and a valid token reused across iterations.
|
|
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
|
var token = await Tokens.MintDefaultAsync();
|
|
Missions.DefaultRequestHeaders.Authorization =
|
|
new AuthenticationHeaderValue("Bearer", token.Jwt);
|
|
|
|
var observations = new int[Iterations];
|
|
|
|
// Act
|
|
for (int i = 0; i < Iterations; i++)
|
|
{
|
|
ResetVehiclesAndSeedOneDefault();
|
|
|
|
// Each writer carries a unique id so PK collisions never mask
|
|
// the race that AC-1.4 is interested in.
|
|
var postTask = TryPostVehicleAsync(Guid.NewGuid());
|
|
var insertTask = TrySideChannelInsertAsync(Guid.NewGuid());
|
|
|
|
await Task.WhenAll(postTask, insertTask);
|
|
observations[i] = CountDefaultVehicles();
|
|
}
|
|
|
|
var maxObserved = observations.Max();
|
|
|
|
// Assert — CURRENT behaviour: the partial unique index forces
|
|
// every iteration to converge on a single default vehicle.
|
|
// If this assertion ever fails (max >= 2), the index has been
|
|
// removed/relaxed and AC-1.4 carry-forward should be revisited.
|
|
Assert.True(maxObserved <= 1,
|
|
$"observed >= 2 defaults in some iteration (max={maxObserved}). " +
|
|
"Index ux_vehicles_one_default appears removed/relaxed — revisit " +
|
|
"AC-1.4 carry-forward in traceability_matrix.csv.");
|
|
}
|
|
|
|
private async Task<HttpRequestState> TryPostVehicleAsync(Guid vehicleId)
|
|
{
|
|
try
|
|
{
|
|
var body = new
|
|
{
|
|
Id = vehicleId,
|
|
Name = $"race-api-{vehicleId:N}",
|
|
IsDefault = true,
|
|
};
|
|
using var resp = await Missions.PostAsJsonAsync("/vehicles", body);
|
|
return new HttpRequestState((int)resp.StatusCode, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new HttpRequestState(-1, ex);
|
|
}
|
|
}
|
|
|
|
private static async Task<SideChannelState> TrySideChannelInsertAsync(Guid vehicleId)
|
|
{
|
|
try
|
|
{
|
|
await using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
|
await conn.OpenAsync();
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
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);
|
|
}
|
|
catch (PostgresException ex)
|
|
{
|
|
return new SideChannelState(false, ex);
|
|
}
|
|
}
|
|
|
|
private static void ResetVehiclesAndSeedOneDefault()
|
|
{
|
|
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
|
conn.Open();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
TRUNCATE vehicles RESTART IDENTITY CASCADE;
|
|
INSERT INTO vehicles (id, model, name, is_default)
|
|
VALUES (gen_random_uuid(), 'seed-model', 'seed-default', TRUE);
|
|
""";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
private static int CountDefaultVehicles()
|
|
{
|
|
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
|
conn.Open();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT COUNT(*) FROM vehicles WHERE is_default = TRUE;";
|
|
return Convert.ToInt32(cmd.ExecuteScalar());
|
|
}
|
|
|
|
private sealed record HttpRequestState(int StatusCode, Exception? Error);
|
|
private sealed record SideChannelState(bool Inserted, Exception? Error);
|
|
}
|