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; /// /// NFT-RES-08 — TOCTOU race on vehicles.is_default. /// /// /// /// Spec AC-1.4 expects the race to be OBSERVABLE — i.e. at least one of 100 /// concurrent iterations leaves two rows with is_default=true. The /// current migrator ships /// ux_vehicles_one_default ON vehicles (is_default) WHERE is_default = TRUE, /// which closes the race at the storage layer: the second writer always /// fails with 23505. /// /// /// Following CascadeF4Tests precedent we pin the CURRENT behaviour /// (max-one default after the race) and mark the divergence with the /// carry_forward 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. /// /// [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 { 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 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 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); }