Files
missions/tests/Azaion.Missions.E2E.Tests/Tests/Resilience/DefaultVehicleRaceTests.cs
T
Oleksandr Bezdieniezhnykh 24c4561bef [AZ-581] [AZ-582] [AZ-583] [AZ-584] Sec+Res NFT tests
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>
2026-05-15 08:58:59 +03:00

143 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];
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, name, is_default, created_at, updated_at)
VALUES (@id, @name, TRUE, NOW(), NOW());
""";
cmd.Parameters.AddWithValue("id", vehicleId);
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, name, is_default, created_at, updated_at)
VALUES (gen_random_uuid(), 'seed-default', TRUE, NOW(), NOW());
""";
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);
}