using Microsoft.AspNetCore.DataProtection; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations.Operations; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; public class MediumPersistenceSqlTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; public MediumPersistenceSqlTests(MsSqlMigrationFixture fixture) => _fixture = fixture; [SkippableFact] public async Task RealSql_GlobalNameRaceAndLegacyCredentialUpgrade() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var options = new DbContextOptionsBuilder().UseSqlServer(_fixture.ConnectionString).Options; var protection = new EphemeralDataProtectionProvider(); await using var db = new ScadaBridgeDbContext(options, protection); var a = new Site("Medium A", "medium-a"); var b = new Site("Medium B", "medium-b"); var template = new Template("Medium template"); db.AddRange(a, b, template); await db.SaveChangesAsync(); async Task Insert(int site) { await using var attempt = new ScadaBridgeDbContext(options, protection); attempt.Instances.Add(new Instance("global-medium-pump") { SiteId = site, TemplateId = template.Id }); try { await attempt.SaveChangesAsync(); return true; } catch (InstanceNameConflictException) { return false; } } var race = await Task.WhenAll(Insert(a.Id), Insert(b.Id)); Assert.Single(race, success => success); Assert.Single(race, success => !success); await db.Database.ExecuteSqlRawAsync( "INSERT INTO DataConnections (SiteId, Name, Protocol, PrimaryConfiguration, BackupConfiguration, FailoverRetryCount) VALUES ({0}, 'legacy-medium', 'OpcUa', {1}, {2}, 3)", a.Id, """{"password":"sql-primary-secret"}""", """{"password":"sql-backup-secret"}"""); await DataConnectionSecretUpgrade.UpgradeAsync(db); await DataConnectionSecretUpgrade.UpgradeAsync(db); var protectedRow = await db.DataConnections.SingleAsync(c => c.Name == "legacy-medium"); Assert.Contains("sql-primary-secret", protectedRow.PrimaryConfiguration); Assert.Contains("sql-backup-secret", protectedRow.BackupConfiguration); using var connection = _fixture.OpenConnection(); using var raw = connection.CreateCommand(); raw.CommandText = "SELECT PrimaryConfiguration FROM DataConnections WHERE Name = 'legacy-medium'"; Assert.DoesNotContain("sql-primary-secret", (string)(await raw.ExecuteScalarAsync())!); raw.CommandText = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='DataConnections' AND COLUMN_NAME IN ('PrimaryConfiguration','BackupConfiguration') AND CHARACTER_MAXIMUM_LENGTH=-1"; Assert.Equal(2, (int)(await raw.ExecuteScalarAsync())!); // A pre-upgrade conflict is rejected explicitly; the transaction restores the index. using var transaction = connection.BeginTransaction(); using var legacy = connection.CreateCommand(); legacy.Transaction = transaction; legacy.CommandText = "DROP INDEX IX_Instances_UniqueName ON Instances"; await legacy.ExecuteNonQueryAsync(); legacy.CommandText = "INSERT INTO Instances (UniqueName, TemplateId, SiteId, State) VALUES ('global-medium-pump', @template, @site, 'NotDeployed')"; legacy.Parameters.AddWithValue("@template", template.Id); legacy.Parameters.AddWithValue("@site", a.Id); await legacy.ExecuteNonQueryAsync(); var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer"); new ProbeMigration().Apply(builder); legacy.CommandText = builder.Operations.OfType().First().Sql; var denied = await Assert.ThrowsAsync(() => legacy.ExecuteNonQueryAsync()); Assert.Equal(51002, denied.Number); transaction.Rollback(); } private sealed class ProbeMigration : EnforceGlobalInstanceNamesAndProtectProtocolConfiguration { public void Apply(MigrationBuilder builder) => base.Up(builder); } }