7b6d6bb805
Follow-up from the T5 code review: assert an idempotent Overwrite emits zero TemplateNativeAlarmSource* audit rows, and round-trip IsInherited/LockedInDerived as true through both the serializer and the importer BuildTemplate path. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
375 lines
17 KiB
C#
375 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// #05-T5: a template's <see cref="TemplateNativeAlarmSource"/> 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 <see cref="TemplateScriptFidelityTests"/> / <see cref="BundleImporterApplyTests"/>.
|
|
/// </summary>
|
|
public sealed class NativeAlarmSourceImportTests : IDisposable
|
|
{
|
|
private readonly ServiceProvider _provider;
|
|
|
|
public NativeAlarmSourceImportTests()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(
|
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
|
|
|
var dbName = $"NativeAlarmSourceImportTests_{Guid.NewGuid()}";
|
|
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
|
.UseInMemoryDatabase(dbName)
|
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
|
|
|
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
|
services.AddScoped(sp => new ScadaBridgeDbContext(
|
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
|
|
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
|
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
|
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
|
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
|
services.AddScoped<IAuditService, AuditService>();
|
|
services.AddTransport();
|
|
services.AddTemplateEngine();
|
|
services.AddDeploymentManager();
|
|
|
|
_provider = services.BuildServiceProvider();
|
|
}
|
|
|
|
public void Dispose() => _provider.Dispose();
|
|
|
|
private async Task<Guid> ExportAllTemplatesAndLoadAsync()
|
|
{
|
|
Stream bundleStream;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
|
var selection = new ExportSelection(
|
|
TemplateIds: templateIds,
|
|
SharedScriptIds: Array.Empty<int>(),
|
|
ExternalSystemIds: Array.Empty<int>(),
|
|
DatabaseConnectionIds: Array.Empty<int>(),
|
|
NotificationListIds: Array.Empty<int>(),
|
|
SmtpConfigurationIds: Array.Empty<int>(),
|
|
ApiMethodIds: Array.Empty<int>(),
|
|
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<IBundleImporter>();
|
|
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<ScadaBridgeDbContext>();
|
|
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<ScadaBridgeDbContext>();
|
|
ctx.Templates.RemoveRange(ctx.Templates);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
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<ScadaBridgeDbContext>();
|
|
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<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
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<ScadaBridgeDbContext>();
|
|
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<ScadaBridgeDbContext>();
|
|
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<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
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<ScadaBridgeDbContext>();
|
|
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<ScadaBridgeDbContext>();
|
|
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<IBundleImporter>();
|
|
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<ScadaBridgeDbContext>();
|
|
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<ScadaBridgeDbContext>();
|
|
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<IFlatteningPipeline>();
|
|
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<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { 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<IFlatteningPipeline>();
|
|
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
|
|
}
|
|
}
|
|
}
|