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
}
}
}