diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615171137_ResyncLdapGroupMappingSeed.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615171137_ResyncLdapGroupMappingSeed.cs index a11d59e9..2eab897c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615171137_ResyncLdapGroupMappingSeed.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260615171137_ResyncLdapGroupMappingSeed.cs @@ -7,18 +7,56 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations /// public partial class ResyncLdapGroupMappingSeed : Migration { + /// + /// SQLite idempotent insert for the Id=5 (SCADA-Viewers → Viewer) seed row. + /// INSERT OR IGNORE skips the row when the Id primary key already + /// exists, so replaying this migration against a DB that already carries Id=5 + /// (e.g. one seeded by a newer HasData baseline) is a no-op rather than a PK + /// violation. Exposed as a constant so the idempotency test can exercise the + /// exact SQL the migration emits on the SQLite branch. + /// + internal const string SqliteInsertSql = + "INSERT OR IGNORE INTO \"LdapGroupMappings\" (\"Id\", \"LdapGroupName\", \"Role\") " + + "VALUES (5, 'SCADA-Viewers', 'Viewer');"; + /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.InsertData( - table: "LdapGroupMappings", - columns: new[] { "Id", "LdapGroupName", "Role" }, - values: new object[] { 5, "SCADA-Viewers", "Viewer" }); + // #70: this seed insert must be idempotent. The original unconditional + // InsertData threw a PK violation (and crash-looped startup, since dev + // auto-applies migrations) on any pre-existing DB that already had Id=5 + // — e.g. one seeded via the old HasData baseline. Guard the insert so it + // is a no-op when the row already exists. Provider-aware because the + // production central DB is SQL Server while dev/test run on SQLite, and + // the two speak different conditional-insert dialects (mirrors the + // NotificationOutboxRepository.InsertIfNotExists pattern, #286). + if (migrationBuilder.ActiveProvider == "Microsoft.EntityFrameworkCore.Sqlite") + { + // SQLite: INSERT OR IGNORE silently skips the row on a PK clash. + migrationBuilder.Sql(SqliteInsertSql); + } + else + { + // SQL Server: guard with IF NOT EXISTS. LdapGroupMappings.Id is an + // IDENTITY column (InitialSchema declares SqlServer:Identity "1, 1"), + // so an explicit Id value requires SET IDENTITY_INSERT ON/OFF around + // the INSERT — exactly what EF's InsertData emitted for this row. + migrationBuilder.Sql(@" +IF NOT EXISTS (SELECT 1 FROM [LdapGroupMappings] WHERE [Id] = 5) +BEGIN + SET IDENTITY_INSERT [LdapGroupMappings] ON; + INSERT INTO [LdapGroupMappings] ([Id], [LdapGroupName], [Role]) + VALUES (5, N'SCADA-Viewers', N'Viewer'); + SET IDENTITY_INSERT [LdapGroupMappings] OFF; +END"); + } } /// protected override void Down(MigrationBuilder migrationBuilder) { + // Idempotent delete: removes the Id=5 row if present, no-op otherwise. + // Unchanged in behaviour from the original DeleteData. migrationBuilder.DeleteData( table: "LdapGroupMappings", keyColumn: "Id", diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/ResyncLdapGroupMappingSeedMigrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/ResyncLdapGroupMappingSeedMigrationTests.cs new file mode 100644 index 00000000..adc681f6 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/ResyncLdapGroupMappingSeedMigrationTests.cs @@ -0,0 +1,146 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; + +/// +/// Idempotency tests for the ResyncLdapGroupMappingSeed migration (#70). +/// +/// The original migration did an UNCONDITIONAL InsertData of the +/// Id=5 (SCADA-Viewers → Viewer) seed row. On any pre-existing DB that already +/// carried Id=5 that threw a PK violation and crash-looped startup (dev +/// auto-applies migrations). The fix makes the insert provider-aware and +/// idempotent (SQLite INSERT OR IGNORE / SQL Server guarded +/// IF NOT EXISTS … SET IDENTITY_INSERT … INSERT). +/// +/// These tests drive the migration's Up() through the EF migrations SQL +/// pipeline configured for SQLite, so they exercise the exact +/// migrationBuilder.Sql(...) text the migration emits on the SQLite +/// branch — the branch a CI box without an MSSQL container can actually run. +/// The SQL Server branch is the same migrationBuilder.Sql text shape and +/// is exercised by the full-history MSSQL migration fixture +/// () used by the other migration tests. +/// +public class ResyncLdapGroupMappingSeedMigrationTests +{ + private const string SqliteProvider = "Microsoft.EntityFrameworkCore.Sqlite"; + + [Fact] + public void Up_OnSqlite_InsertsRow_WhenIdFiveMissing() + { + using var connection = OpenSeededConnection(seedIdFive: false); + + ApplyMigrationUp(connection); + + Assert.Equal(1, CountId5(connection)); + Assert.Equal(("SCADA-Viewers", "Viewer"), ReadId5(connection)); + } + + [Fact] + public void Up_OnSqlite_IsIdempotent_WhenIdFiveAlreadyExists() + { + // Pre-existing DB already carries Id=5 — the #70 crash scenario. + using var connection = OpenSeededConnection(seedIdFive: true); + + // Applying the migration MUST NOT throw on the PK clash... + var ex = Record.Exception(() => ApplyMigrationUp(connection)); + Assert.Null(ex); + + // ...and MUST NOT duplicate the row. + Assert.Equal(1, CountId5(connection)); + Assert.Equal(("SCADA-Viewers", "Viewer"), ReadId5(connection)); + } + + [Fact] + public void Up_OnSqlite_ReplayingTwice_IsStillIdempotent() + { + using var connection = OpenSeededConnection(seedIdFive: false); + + ApplyMigrationUp(connection); + var ex = Record.Exception(() => ApplyMigrationUp(connection)); + + Assert.Null(ex); + Assert.Equal(1, CountId5(connection)); + } + + // --- harness ----------------------------------------------------------- + + /// + /// Opens a shared in-memory SQLite connection holding only the columns this + /// migration touches and (optionally) a pre-existing Id=5 row. + /// + private static SqliteConnection OpenSeededConnection(bool seedIdFive) + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + using (var create = connection.CreateCommand()) + { + // Mirror the production PK shape: Id is the primary key. Re-inserting + // an existing Id raises a UNIQUE/PK violation unless guarded. + create.CommandText = + "CREATE TABLE \"LdapGroupMappings\" (" + + " \"Id\" INTEGER NOT NULL CONSTRAINT \"PK_LdapGroupMappings\" PRIMARY KEY," + + " \"LdapGroupName\" TEXT NOT NULL," + + " \"Role\" TEXT NOT NULL);"; + create.ExecuteNonQuery(); + } + + if (seedIdFive) + { + using var seed = connection.CreateCommand(); + seed.CommandText = + "INSERT INTO \"LdapGroupMappings\" (\"Id\", \"LdapGroupName\", \"Role\") " + + "VALUES (5, 'SCADA-Viewers', 'Viewer');"; + seed.ExecuteNonQuery(); + } + + return connection; + } + + /// + /// Generates the real migration's Up() SQL via the EF migrations + /// pipeline configured for SQLite (so migrationBuilder.ActiveProvider + /// is the SQLite provider and the SQLite branch is taken), then runs every + /// emitted command against the supplied connection. + /// + private static void ApplyMigrationUp(SqliteConnection connection) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using var context = new ScadaBridgeDbContext(options); + + var migration = new ResyncLdapGroupMappingSeed { ActiveProvider = SqliteProvider }; + var sqlGenerator = context.GetService(); + var commands = sqlGenerator.Generate(migration.UpOperations); + + foreach (var command in commands) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = command.CommandText; + cmd.ExecuteNonQuery(); + } + } + + private static int CountId5(SqliteConnection connection) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM \"LdapGroupMappings\" WHERE \"Id\" = 5;"; + return Convert.ToInt32(cmd.ExecuteScalar()); + } + + private static (string LdapGroupName, string Role) ReadId5(SqliteConnection connection) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT \"LdapGroupName\", \"Role\" FROM \"LdapGroupMappings\" WHERE \"Id\" = 5;"; + using var reader = cmd.ExecuteReader(); + Assert.True(reader.Read()); + return (reader.GetString(0), reader.GetString(1)); + } +}