using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; 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.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.TemplateEngine; using ZB.MOM.WW.ScadaBridge.Transport; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// /// #05-T5: a template's collection must /// survive an export→import round-trip (Add + Overwrite sync), diff in Preview, /// and — crucially — carry across environments so an instance-level override of a /// native alarm source no longer dangles in the target. Before this fix the /// importer amputated the template's native sources entirely, so an imported /// instance override referenced a source that did not exist. Same in-memory host /// pattern as / . /// public sealed class NativeAlarmSourceImportTests : IDisposable { private readonly ServiceProvider _provider; public NativeAlarmSourceImportTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"NativeAlarmSourceImportTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts .UseInMemoryDatabase(dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); services.AddSingleton(new EphemeralDataProtectionProvider()); services.AddScoped(sp => new ScadaBridgeDbContext( sp.GetRequiredService>(), sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); services.AddTemplateEngine(); services.AddDeploymentManager(); _provider = services.BuildServiceProvider(); } public void Dispose() => _provider.Dispose(); private async Task ExportAllTemplatesAndLoadAsync() { Stream bundleStream; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: templateIds, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false); bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } using var ms = new MemoryStream(); await bundleStream.CopyToAsync(ms); ms.Position = 0; await using var loadScope = _provider.CreateAsyncScope(); var importer = loadScope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(ms, passphrase: null); return session.SessionId; } private static TemplateNativeAlarmSource MakeSource( string name = "Boiler", string connection = "OpcUaPrimary", string sourceRef = "ns=3;s=Boiler.Alarm", string? filter = "severity>500", bool locked = false, bool inherited = false, bool lockedInDerived = false) => new(name) { Description = "boiler native alarms", ConnectionName = connection, SourceReference = sourceRef, ConditionFilter = filter, IsLocked = locked, IsInherited = inherited, LockedInDerived = lockedInDerived, }; [Fact] public async Task Import_Add_CarriesTemplateNativeAlarmSources() { await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "tpl" }; // Set every flag non-default so BuildTemplate's fidelity is exercised. t.NativeAlarmSources.Add(MakeSource(locked: true, inherited: true, lockedInDerived: true)); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportAllTemplatesAndLoadAsync(); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.RemoveRange(ctx.Templates); await ctx.SaveChangesAsync(); } await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); await importer.ApplyAsync(sessionId, new List { new("Template", "Pump", ResolutionAction.Add, null) }, user: "bob"); } await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var src = await ctx.Templates .Where(t => t.Name == "Pump") .SelectMany(t => t.NativeAlarmSources) .SingleAsync(); Assert.Equal("Boiler", src.Name); Assert.Equal("boiler native alarms", src.Description); Assert.Equal("OpcUaPrimary", src.ConnectionName); Assert.Equal("ns=3;s=Boiler.Alarm", src.SourceReference); Assert.Equal("severity>500", src.ConditionFilter); Assert.True(src.IsLocked); Assert.True(src.IsInherited); Assert.True(src.LockedInDerived); } } [Fact] public async Task Import_Overwrite_NoChange_EmitsNoNativeSourceAudit() { // An idempotent Overwrite (bundle == target) must produce ZERO // TemplateNativeAlarmSource* audit rows — the "no audit noise" invariant. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "same" }; t.NativeAlarmSources.Add(MakeSource("Boiler", locked: true, lockedInDerived: true)); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportAllTemplatesAndLoadAsync(); await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); await importer.ApplyAsync(sessionId, new List { new("Template", "Pump", ResolutionAction.Overwrite, null) }, user: "bob"); } await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.False(await ctx.AuditLogEntries.AnyAsync(a => a.Action.StartsWith("TemplateNativeAlarmSource"))); } } [Fact] public async Task Import_Overwrite_SyncsNativeAlarmSources() { // Source has Keep (changed ConditionFilter later) + Add ("Chiller"); target // will hold Keep (different filter) + Drop ("Legacy"). Import Overwrite must // update Keep, add Chiller, delete Legacy — with one audit row each. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "same" }; t.NativeAlarmSources.Add(MakeSource("Keep", filter: "severity>500")); t.NativeAlarmSources.Add(MakeSource("Chiller", connection: "OpcUaChiller", sourceRef: "ns=4;s=Chiller")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportAllTemplatesAndLoadAsync(); // Mutate target: change Keep's filter, drop Chiller, add Legacy. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = await ctx.Templates.Include(x => x.NativeAlarmSources).SingleAsync(x => x.Name == "Pump"); t.NativeAlarmSources.Single(s => s.Name == "Keep").ConditionFilter = "severity>900"; t.NativeAlarmSources.Remove(t.NativeAlarmSources.Single(s => s.Name == "Chiller")); t.NativeAlarmSources.Add(MakeSource("Legacy", connection: "OpcUaLegacy", sourceRef: "ns=9;s=Legacy")); await ctx.SaveChangesAsync(); } await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); await importer.ApplyAsync(sessionId, new List { new("Template", "Pump", ResolutionAction.Overwrite, null) }, user: "bob"); } await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var sources = await ctx.Templates .Where(t => t.Name == "Pump") .SelectMany(t => t.NativeAlarmSources) .ToListAsync(); Assert.Equal(2, sources.Count); Assert.Equal("severity>500", sources.Single(s => s.Name == "Keep").ConditionFilter); // updated back Assert.Contains(sources, s => s.Name == "Chiller"); // added Assert.DoesNotContain(sources, s => s.Name == "Legacy"); // deleted Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "TemplateNativeAlarmSourceUpdated" && a.EntityName == "Pump.Keep")); Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "TemplateNativeAlarmSourceAdded" && a.EntityName == "Pump.Chiller")); Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "TemplateNativeAlarmSourceDeleted" && a.EntityName == "Pump.Legacy")); } } [Fact] public async Task Preview_DiffsNativeAlarmSources() { await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "tpl" }; t.NativeAlarmSources.Add(MakeSource("Boiler", filter: "severity>500")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportAllTemplatesAndLoadAsync(); // Mutate the target source so the diff has something to report. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = await ctx.Templates.Include(x => x.NativeAlarmSources).SingleAsync(x => x.Name == "Pump"); t.NativeAlarmSources.Single().ConditionFilter = "severity>900"; await ctx.SaveChangesAsync(); } await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var preview = await importer.PreviewAsync(sessionId); var pump = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump"); Assert.Equal(ConflictKind.Modified, pump.Kind); Assert.NotNull(pump.FieldDiffJson); Assert.Contains("NativeAlarmSources", pump.FieldDiffJson!, StringComparison.Ordinal); } } [Fact] public async Task Import_InstanceOverride_NoLongerDangles() { // Target holds a template + site + instance whose override repoints the // "Boiler" native source. We export the template WITH the source, delete // the target's copy of the source (so the override dangles), then import // Overwrite — which re-adds the source — and flatten: the resolved config // must now carry "Boiler" with the instance override applied. int instanceId; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "tpl" }; t.NativeAlarmSources.Add(MakeSource("Boiler", connection: "OpcUaPrimary", locked: false)); ctx.Templates.Add(t); var site = new Site("Plant 1", "plant-1") { NodeAAddress = "akka://s@10.0.0.1:2552", NodeBAddress = "akka://s@10.0.0.2:2552", }; ctx.Sites.Add(site); await ctx.SaveChangesAsync(); ctx.DataConnections.Add(new DataConnection("OpcUaPrimary", "OpcUa", site.Id) { PrimaryConfiguration = "{}", }); ctx.DataConnections.Add(new DataConnection("OpcUaBackup", "OpcUa", site.Id) { PrimaryConfiguration = "{}", }); var instance = new Instance("Pump-01") { TemplateId = t.Id, SiteId = site.Id, State = InstanceState.NotDeployed, }; instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Boiler") { ConnectionNameOverride = "OpcUaBackup", }); ctx.Instances.Add(instance); await ctx.SaveChangesAsync(); instanceId = instance.Id; } var sessionId = await ExportAllTemplatesAndLoadAsync(); // Delete the target template's native source → the override now dangles. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = await ctx.Templates.Include(x => x.NativeAlarmSources).SingleAsync(x => x.Name == "Pump"); t.NativeAlarmSources.Clear(); await ctx.SaveChangesAsync(); } // Pre-check: with the source gone, flatten resolves no "Boiler". await using (var scope = _provider.CreateAsyncScope()) { var pipeline = scope.ServiceProvider.GetRequiredService(); var pre = await pipeline.FlattenAndValidateAsync(instanceId, CancellationToken.None, validateScripts: false); Assert.True(pre.IsSuccess); Assert.DoesNotContain(pre.Value!.Configuration.NativeAlarmSources, s => s.CanonicalName == "Boiler"); } // Import Overwrite re-adds the template source. await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); await importer.ApplyAsync(sessionId, new List { new("Template", "Pump", ResolutionAction.Overwrite, null) }, user: "bob"); } // Now the override resolves against the re-imported source. await using (var scope = _provider.CreateAsyncScope()) { var pipeline = scope.ServiceProvider.GetRequiredService(); var post = await pipeline.FlattenAndValidateAsync(instanceId, CancellationToken.None, validateScripts: false); Assert.True(post.IsSuccess); var boiler = Assert.Single(post.Value!.Configuration.NativeAlarmSources, s => s.CanonicalName == "Boiler"); Assert.Equal("OpcUaBackup", boiler.ConnectionName); // instance override applied } } }