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));
+ }
+}