diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md index 77e34a84..50ac3e52 100644 --- a/docs/requirements/Component-Transport.md +++ b/docs/requirements/Component-Transport.md @@ -21,7 +21,7 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it - Move **site-scoped configuration** (T18): `Site` definitions, site-scoped `DataConnection`s (protocol connections — distinct from External-System `DatabaseConnection`s), and `Instance`s along with their `InstanceAttributeOverride` / `InstanceAlarmOverride` / `InstanceNativeAlarmSourceOverride` / `InstanceConnectionBinding` children and `Area` membership (carried by name). - Reconcile cross-environment site identifiers and connection names through the **name-mapping subsystem** (`BundleNameMap`): auto-match by identifier/name, operator override via the import-wizard Map step or CLI flags, and per-conflict create-or-bind resolution (see "Name Mapping"). - Compute a **per-line (Myers) diff** for code fields on Modified artifacts (T20) via the pure `LineDiffer`, embedding a size-capped structured line diff in each `ArtifactDiff`. -- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`. +- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`. A template travels with **all** of its child collections — `TemplateAttribute`, `TemplateAlarm`, `TemplateScript`, `TemplateComposition`, and `TemplateNativeAlarmSource` (including `IsInherited` placeholder rows, which the collision detector depends on) — each field-faithful on Add and reconciled add/update/delete on Overwrite. Carrying the native alarm sources is what keeps an imported `InstanceNativeAlarmSourceOverride` from dangling against a source the target template would otherwise lack. - Encrypt content with AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) when a passphrase is supplied; leave content plaintext with a UI warning and an `UnencryptedBundleExport` audit event when none is given. - Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification. - Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session. diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs index 9392b615..190561ec 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs @@ -101,6 +101,15 @@ public sealed class ArtifactDiff "Compositions", changes); + DiffChildren( + existing.NativeAlarmSources, + incoming.NativeAlarmSources, + e => e.Name, + i => i.Name, + NativeAlarmSourcesEqual, + "NativeAlarmSources", + changes); + return BuildItem("Template", incoming.Name, changes); } @@ -665,6 +674,15 @@ public sealed class ArtifactDiff && e.ReturnDefinition == i.ReturnDefinition && e.IsLocked == i.IsLocked; + private static bool NativeAlarmSourcesEqual(TemplateNativeAlarmSource e, TemplateNativeAlarmSourceDto i) => + e.Description == i.Description + && e.ConnectionName == i.ConnectionName + && e.SourceReference == i.SourceReference + && e.ConditionFilter == i.ConditionFilter + && e.IsLocked == i.IsLocked + && e.IsInherited == i.IsInherited + && e.LockedInDerived == i.LockedInDerived; + private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) => e.HttpMethod == i.HttpMethod && e.Path == i.Path diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index a225d713..b2b6fb1f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -1495,6 +1495,7 @@ public sealed class BundleImporter : IBundleImporter await SyncTemplateAttributesAsync(ex, dto, user, ct).ConfigureAwait(false); await SyncTemplateAlarmsAsync(ex, dto, user, ct).ConfigureAwait(false); await SyncTemplateScriptsAsync(ex, dto, user, ct).ConfigureAwait(false); + await SyncTemplateNativeAlarmSourcesAsync(ex, dto, user, ct).ConfigureAwait(false); summary.Overwritten++; break; case ResolutionAction.Add: @@ -1578,6 +1579,19 @@ public sealed class BundleImporter : IBundleImporter LockedInDerived = s.LockedInDerived, }); } + foreach (var n in dto.NativeAlarmSources) + { + t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name) + { + Description = n.Description, + ConnectionName = n.ConnectionName, + SourceReference = n.SourceReference, + ConditionFilter = n.ConditionFilter, + IsLocked = n.IsLocked, + IsInherited = n.IsInherited, + LockedInDerived = n.LockedInDerived, + }); + } return t; } @@ -1936,6 +1950,123 @@ public sealed class BundleImporter : IBundleImporter } } + /// + /// #05-T5 — Overwrite child sync (native alarm sources). Mirrors + /// for the + /// NativeAlarmSources collection: diffs the DTO's sources against the + /// existing template's sources by name and stages add / update / delete on + /// the tracked entity. Audit rows: + /// TemplateNativeAlarmSourceAdded / …Updated / …Deleted. + /// + /// Update detection compares every writable field (Description, + /// ConnectionName, SourceReference, ConditionFilter, IsLocked, IsInherited, + /// LockedInDerived) — an idempotent overwrite produces no audit noise. + /// + /// + private async Task SyncTemplateNativeAlarmSourcesAsync( + Template ex, + TemplateDto dto, + string user, + CancellationToken ct) + { + var existingByName = ex.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal); + var dtoByName = dto.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal); + + // Deletes — sources present on the target but not in the bundle. + foreach (var existing in existingByName.Values.ToList()) + { + if (dtoByName.ContainsKey(existing.Name)) continue; + await _templateRepo.DeleteTemplateNativeAlarmSourceAsync(existing.Id, ct).ConfigureAwait(false); + ex.NativeAlarmSources.Remove(existing); + await _auditService.LogAsync( + user, + "TemplateNativeAlarmSourceDeleted", + "TemplateNativeAlarmSource", + existing.Id.ToString(), + $"{ex.Name}.{existing.Name}", + new { TemplateName = ex.Name, SourceName = existing.Name }, + ct).ConfigureAwait(false); + } + + // Adds + Updates. + foreach (var srcDto in dto.NativeAlarmSources) + { + if (existingByName.TryGetValue(srcDto.Name, out var current)) + { + bool changed = + !string.Equals(current.Description, srcDto.Description, StringComparison.Ordinal) || + !string.Equals(current.ConnectionName, srcDto.ConnectionName, StringComparison.Ordinal) || + !string.Equals(current.SourceReference, srcDto.SourceReference, StringComparison.Ordinal) || + !string.Equals(current.ConditionFilter, srcDto.ConditionFilter, StringComparison.Ordinal) || + current.IsLocked != srcDto.IsLocked || + current.IsInherited != srcDto.IsInherited || + current.LockedInDerived != srcDto.LockedInDerived; + if (!changed) continue; + + current.Description = srcDto.Description; + current.ConnectionName = srcDto.ConnectionName; + current.SourceReference = srcDto.SourceReference; + current.ConditionFilter = srcDto.ConditionFilter; + current.IsLocked = srcDto.IsLocked; + current.IsInherited = srcDto.IsInherited; + current.LockedInDerived = srcDto.LockedInDerived; + await _templateRepo.UpdateTemplateNativeAlarmSourceAsync(current, ct).ConfigureAwait(false); + await _auditService.LogAsync( + user, + "TemplateNativeAlarmSourceUpdated", + "TemplateNativeAlarmSource", + current.Id.ToString(), + $"{ex.Name}.{current.Name}", + new + { + TemplateName = ex.Name, + SourceName = current.Name, + current.Description, + current.ConnectionName, + current.SourceReference, + current.ConditionFilter, + current.IsLocked, + current.IsInherited, + current.LockedInDerived, + }, + ct).ConfigureAwait(false); + } + else + { + var newSource = new TemplateNativeAlarmSource(srcDto.Name) + { + Description = srcDto.Description, + ConnectionName = srcDto.ConnectionName, + SourceReference = srcDto.SourceReference, + ConditionFilter = srcDto.ConditionFilter, + IsLocked = srcDto.IsLocked, + IsInherited = srcDto.IsInherited, + LockedInDerived = srcDto.LockedInDerived, + }; + ex.NativeAlarmSources.Add(newSource); + await _auditService.LogAsync( + user, + "TemplateNativeAlarmSourceAdded", + "TemplateNativeAlarmSource", + "0", + $"{ex.Name}.{newSource.Name}", + new + { + TemplateName = ex.Name, + SourceName = newSource.Name, + newSource.Description, + newSource.ConnectionName, + newSource.SourceReference, + newSource.ConditionFilter, + newSource.IsLocked, + newSource.IsInherited, + newSource.LockedInDerived, + }, + ct).ConfigureAwait(false); + } + } + } + /// /// Pass A of the post-template-flush rewire. /// For every imported template (Add / Overwrite / Rename) whose bundle DTO diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs index 80e35c21..319b16cd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs @@ -126,7 +126,39 @@ public sealed record TemplateDto( IReadOnlyList Attributes, IReadOnlyList Alarms, IReadOnlyList Scripts, - IReadOnlyList Compositions); + IReadOnlyList Compositions) +{ + // Template-defined native alarm source bindings. Modeled as an init-only + // property with an empty default (NOT a positional ctor param) because an + // IReadOnlyList has no valid compile-time default for a positional + // parameter, and for the same forward/source-compat reasons as the + // site/instance arrays on BundleContentDto: + // 1. Forward-compat: a bundle written before native alarm sources were + // transported has no NativeAlarmSources field and deserializes this to + // Array.Empty<> — consumers always see an empty list, never null. + // 2. Source-compat: every existing positional `new TemplateDto(...)` caller + // keeps compiling; producers opt in via the object-initializer. + // IsInherited placeholder rows ARE carried (the collision detector depends + // on them), mirroring how the flattener treats inherited native sources. + public IReadOnlyList NativeAlarmSources { get; init; } = + Array.Empty(); +} + +/// +/// A template-defined native alarm source binding. Carries every writable field +/// of the TemplateNativeAlarmSource entity, including the inheritance/lock +/// flags, so an export→import round-trip is field-faithful and instance-level +/// overrides of the source no longer dangle in the target environment. +/// +public sealed record TemplateNativeAlarmSourceDto( + string Name, + string? Description, + string ConnectionName, + string SourceReference, + string? ConditionFilter, + bool IsLocked, + bool IsInherited, + bool LockedInDerived); public sealed record TemplateAttributeDto( string Name, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs index 49554f63..a8503879 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs @@ -91,7 +91,20 @@ public sealed class EntitySerializer LockedInDerived: s.LockedInDerived)).ToList(), Compositions: t.Compositions.Select(c => new TemplateCompositionDto( InstanceName: c.InstanceName, - ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList()); + ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList()) + { + // Native alarm sources carry ALL rows including IsInherited + // placeholders — the collision detector depends on them. + NativeAlarmSources = t.NativeAlarmSources.Select(n => new TemplateNativeAlarmSourceDto( + Name: n.Name, + Description: n.Description, + ConnectionName: n.ConnectionName, + SourceReference: n.SourceReference, + ConditionFilter: n.ConditionFilter, + IsLocked: n.IsLocked, + IsInherited: n.IsInherited, + LockedInDerived: n.LockedInDerived)).ToList(), + }; }).ToList(), SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto( Name: s.Name, @@ -352,6 +365,20 @@ public sealed class EntitySerializer LockedInDerived = s.LockedInDerived, }); } + foreach (var n in dto.NativeAlarmSources) + { + t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name) + { + TemplateId = t.Id, + Description = n.Description, + ConnectionName = n.ConnectionName, + SourceReference = n.SourceReference, + ConditionFilter = n.ConditionFilter, + IsLocked = n.IsLocked, + IsInherited = n.IsInherited, + LockedInDerived = n.LockedInDerived, + }); + } return t; }) .ToList(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs new file mode 100644 index 00000000..2d6b9600 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs @@ -0,0 +1,338 @@ +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) => new(name) + { + Description = "boiler native alarms", + ConnectionName = connection, + SourceReference = sourceRef, + ConditionFilter = filter, + IsLocked = locked, + IsInherited = false, + LockedInDerived = false, + }; + + [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" }; + t.NativeAlarmSources.Add(MakeSource(locked: 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); + } + } + + [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 + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs index 5bc8a7ac..57c5eda8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs @@ -202,6 +202,46 @@ public sealed class EntitySerializerTests Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived); } + [Fact] + public void Roundtrip_template_preserves_NativeAlarmSources() + { + var basic = new Template("Basic") { Id = 1 }; + basic.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler") + { + Id = 1, + TemplateId = 1, + Description = "boiler alarms", + ConnectionName = "OpcUaPrimary", + SourceReference = "ns=3;s=Boiler.Alarm", + ConditionFilter = "severity>500", + IsLocked = true, + IsInherited = false, + LockedInDerived = true, + }); + + var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } }; + + var sut = new EntitySerializer(); + var dto = sut.ToBundleContent(aggregate); + + var dtoSrc = Assert.Single(Assert.Single(dto.Templates).NativeAlarmSources); + Assert.Equal("Boiler", dtoSrc.Name); + Assert.Equal("OpcUaPrimary", dtoSrc.ConnectionName); + Assert.Equal("ns=3;s=Boiler.Alarm", dtoSrc.SourceReference); + Assert.Equal("severity>500", dtoSrc.ConditionFilter); + Assert.True(dtoSrc.IsLocked); + Assert.True(dtoSrc.LockedInDerived); + + var rtSrc = Assert.Single(Assert.Single(sut.FromBundleContent(dto).Templates).NativeAlarmSources); + Assert.Equal("Boiler", rtSrc.Name); + Assert.Equal("boiler alarms", rtSrc.Description); + Assert.Equal("OpcUaPrimary", rtSrc.ConnectionName); + Assert.Equal("ns=3;s=Boiler.Alarm", rtSrc.SourceReference); + Assert.Equal("severity>500", rtSrc.ConditionFilter); + Assert.True(rtSrc.IsLocked); + Assert.True(rtSrc.LockedInDerived); + } + [Fact] public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false() {