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()
{