mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 06:41:07 +00:00
24c4561bef
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>
143 lines
5.3 KiB
C#
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);
|
|
}
|