diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs index 6845bb73..f55d3837 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs @@ -61,6 +61,39 @@ public class NotificationOutboxRepository : INotificationOutboxRepository var type = n.Type.ToString(); var status = n.Status.ToString(); + // Provider-aware idempotent insert. The production path is SQL Server; the + // NotificationOutbox integration tests run over an in-memory SQLite database, + // and SQLite does not understand the T-SQL `IF NOT EXISTS … INSERT` form + // (it raises 'near "IF": syntax error'). Detect SQLite by provider name so + // ConfigurationDatabase needs no Sqlite package reference; the SQL Server + // path below stays byte-identical. + if (_context.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") + { + // SQLite's idempotent insert: INSERT OR IGNORE skips the row when the + // NotificationId primary key already exists (no schema prefix in SQLite). + // Same parameterised columns/values as the SQL Server branch — the + // FormattableString interpolation still parameterises every value + // (no concatenation), so this is injection-safe. + var sqliteRowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync( + $@"INSERT OR IGNORE INTO Notifications + (NotificationId, Type, ListName, Subject, Body, TypeData, Status, RetryCount, LastError, + ResolvedTargets, SourceSiteId, SourceNode, SourceInstanceId, SourceScript, + OriginExecutionId, OriginParentExecutionId, + SiteEnqueuedAt, CreatedAt, LastAttemptAt, NextAttemptAt, DeliveredAt) +VALUES + ({n.NotificationId}, {type}, {n.ListName}, {n.Subject}, {n.Body}, {n.TypeData}, {status}, {n.RetryCount}, {n.LastError}, + {n.ResolvedTargets}, {n.SourceSiteId}, {n.SourceNode}, {n.SourceInstanceId}, {n.SourceScript}, + {n.OriginExecutionId}, {n.OriginParentExecutionId}, + {n.SiteEnqueuedAt}, {n.CreatedAt}, {n.LastAttemptAt}, {n.NextAttemptAt}, {n.DeliveredAt});", + cancellationToken); + + // rowsAffected == 1 -> we inserted; 0 -> the row was IGNOREd because the + // NotificationId already existed. Preserves the true=inserted / false=already-existed + // contract. No SqlException catch needed — INSERT OR IGNORE never raises a + // unique-constraint violation. + return sqliteRowsAffected == 1; + } + // FormattableString interpolation parameterises every value (no concatenation), // so this is safe against injection even for the string columns. try diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ScadaBridgeWebApplicationFactory.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ScadaBridgeWebApplicationFactory.cs index e95c210a..9aaada52 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ScadaBridgeWebApplicationFactory.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ScadaBridgeWebApplicationFactory.cs @@ -38,6 +38,10 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory ["ScadaBridge__Database__MachineDataDb"] = "Server=localhost;Database=ScadaBridge_MachineData_Test;TrustServerCertificate=True", ["ScadaBridge__Database__SkipMigrations"] = "true", ["ScadaBridge__Security__JwtSigningKey"] = "integration-test-signing-key-must-be-at-least-32-chars-long", + // The inbound API-key pepper is a REQUIRED Central config value (StartupValidator + // enforces a >=16-char floor; it backs the peppered-HMAC verifier). Supply a fixed + // test pepper so host boot passes validation in the test environment. + ["ScadaBridge__InboundApi__ApiKeyPepper"] = "integration-test-api-key-pepper-0123456789", // Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions) and use // the renamed keys (Transport replaces LdapUseTls; None == plaintext for the // GLAuth dev directory, paired with AllowInsecure=true).