diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs
index ee779405..b2a3e4b1 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs
@@ -90,6 +90,18 @@ public interface ISiteRepository
/// A task that resolves to the distinct referencing entities (empty when none).
Task> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, CancellationToken cancellationToken = default);
+ ///
+ /// Retrieves a site's instances with their
+ /// eagerly loaded. Purpose-built
+ /// for the move-data-connection guard, which must scan source-site instance
+ /// overrides for name-based connection references without paying the eager-load
+ /// cost on the hot-path .
+ ///
+ /// The site primary key to filter by.
+ /// Cancellation token.
+ /// A task that resolves to the site's entities with overrides loaded (empty when none).
+ Task> GetInstancesWithNativeAlarmOverridesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
+
/// Saves all pending changes to the database.
/// Cancellation token.
/// A task that resolves to the number of state entries written to the database.
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs
index ad5d9680..b9bcfde9 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs
@@ -132,9 +132,15 @@ public class SiteRepository : ISiteRepository
{
return await _dbContext.Instances
.Where(i => i.SiteId == siteId)
- .Include(i => i.ConnectionBindings)
+ .ToListAsync(cancellationToken);
+ }
+
+ ///
+ public async Task> GetInstancesWithNativeAlarmOverridesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
+ {
+ return await _dbContext.Instances
+ .Where(i => i.SiteId == siteId)
.Include(i => i.NativeAlarmSourceOverrides)
- .AsSplitQuery()
.ToListAsync(cancellationToken);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index 646080ae..eb2bad7d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -1426,17 +1426,40 @@ public class ManagementActor : ReceiveActor
// instances may override that name
// (InstanceNativeAlarmSourceOverride.ConnectionNameOverride). Moving a
// connection changes which physical connection that name resolves to on
- // the source site, so any such reference to the moving name is a blocker.
- // Detect and report only — never auto-rewrite.
+ // the SOURCE site, so any such reference *on the source site* to the
+ // moving name is a blocker. Detect and report only — never auto-rewrite.
+ //
+ // Scoping: templates are site-agnostic and ConnectionName resolves
+ // against the DEPLOYING site's connection pool at flatten time, so the
+ // template scan is restricted to templates actually instantiated on the
+ // SOURCE site. A template referencing the same connection NAME but only
+ // instantiated on another site is NOT affected by this move (that site
+ // keeps its own connection of the same name), so a global template scan
+ // would be a false positive that over-blocks legitimate moves whenever
+ // connection names are reused across sites (the common case).
+ //
+ // Comparison is StringComparison.Ordinal to match the
+ // flattening/deployment pipeline (FlatteningService / FlatteningPipeline
+ // resolve connection names case-sensitively) — block exactly the
+ // references the runtime would actually fail to resolve.
var referenceBlockers = new List();
+ // Source-site instances with their native-alarm-source overrides eagerly
+ // loaded (purpose-built load so the hot-path GetInstancesBySiteIdAsync
+ // stays lean). Reused below for both the override scan and to derive the
+ // set of templates to scan.
+ var sourceInstances = await repo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(sourceSiteId);
+
+ // Only templates instantiated on the SOURCE site can be broken by the move.
var templateRepo = sp.GetRequiredService();
- var templates = await templateRepo.GetAllTemplatesAsync();
- foreach (var template in templates)
+ var sourceTemplateIds = sourceInstances.Select(i => i.TemplateId).Distinct();
+ foreach (var templateId in sourceTemplateIds)
{
+ var template = await templateRepo.GetTemplateByIdAsync(templateId);
+ if (template is null) continue;
foreach (var source in template.NativeAlarmSources)
{
- if (string.Equals(source.ConnectionName, conn.Name, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(source.ConnectionName, conn.Name, StringComparison.Ordinal))
{
referenceBlockers.Add(
$"template '{template.Name}' native-alarm-source '{source.Name}'");
@@ -1446,12 +1469,11 @@ public class ManagementActor : ReceiveActor
// Instance-level overrides on the SOURCE site that name this connection
// would orphan once the connection leaves the site.
- var sourceInstances = await repo.GetInstancesBySiteIdAsync(sourceSiteId);
foreach (var instance in sourceInstances)
{
foreach (var ovr in instance.NativeAlarmSourceOverrides)
{
- if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.Ordinal))
{
referenceBlockers.Add(
$"instance '{instance.UniqueName}' (ID {instance.Id}) native-alarm-source override '{ovr.SourceCanonicalName}'");
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs
index 8c08d030..c659afaa 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs
@@ -776,6 +776,76 @@ public class SiteRepositoryTests : IDisposable
Assert.Single(instances);
}
+ [Fact]
+ public async Task GetInstancesReferencingDataConnection_NoBindings_ReturnsEmpty()
+ {
+ var site = new Site("Site1", "S-001");
+ var template = new Template("T1");
+ _context.Sites.Add(site);
+ _context.Templates.Add(template);
+ await _context.SaveChangesAsync();
+
+ var conn = new DataConnection("Conn1", "OpcUa", site.Id);
+ _context.DataConnections.Add(conn);
+ _context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id });
+ await _context.SaveChangesAsync();
+
+ var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task GetInstancesReferencingDataConnection_OneBinding_ReturnsThatInstance()
+ {
+ var site = new Site("Site1", "S-001");
+ var template = new Template("T1");
+ _context.Sites.Add(site);
+ _context.Templates.Add(template);
+ await _context.SaveChangesAsync();
+
+ var conn = new DataConnection("Conn1", "OpcUa", site.Id);
+ _context.DataConnections.Add(conn);
+ var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
+ _context.Instances.Add(instance);
+ await _context.SaveChangesAsync();
+
+ _context.InstanceConnectionBindings.Add(
+ new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
+ await _context.SaveChangesAsync();
+
+ var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
+ Assert.Single(result);
+ Assert.Equal(instance.Id, result[0].Id);
+ }
+
+ [Fact]
+ public async Task GetInstancesReferencingDataConnection_TwoBindingsSameInstance_ReturnsOne()
+ {
+ var site = new Site("Site1", "S-001");
+ var template = new Template("T1");
+ _context.Sites.Add(site);
+ _context.Templates.Add(template);
+ await _context.SaveChangesAsync();
+
+ var conn = new DataConnection("Conn1", "OpcUa", site.Id);
+ _context.DataConnections.Add(conn);
+ var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
+ _context.Instances.Add(instance);
+ await _context.SaveChangesAsync();
+
+ // Two distinct bindings (different attributes) on the SAME instance both
+ // reference the connection -> the result must be deduped to one instance.
+ _context.InstanceConnectionBindings.Add(
+ new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
+ _context.InstanceConnectionBindings.Add(
+ new InstanceConnectionBinding("Attr2") { InstanceId = instance.Id, DataConnectionId = conn.Id });
+ await _context.SaveChangesAsync();
+
+ var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
+ Assert.Single(result);
+ Assert.Equal(instance.Id, result[0].Id);
+ }
+
[Fact]
public void Constructor_NullContext_Throws()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
index 42bc003e..9ab96d9d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
@@ -2104,18 +2104,14 @@ public class ManagementActorTests : TestKit, IDisposable
// No bindings reference the connection -> move allowed.
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any())
.Returns(new List());
- // No native-alarm-source overrides on the source site.
- siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any())
+ // No native-alarm-source overrides on the source site (purpose-built load).
+ siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any())
.Returns(new List());
siteRepo.UpdateDataConnectionAsync(Arg.Any(), Arg.Any())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any()).Returns(1);
_services.AddScoped(_ => siteRepo);
- // No templates reference the connection by name.
- _templateRepo.GetAllTemplatesAsync(Arg.Any())
- .Returns(new List());
-
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
@@ -2213,4 +2209,173 @@ public class ManagementActorTests : TestKit, IDisposable
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any(), Arg.Any());
}
+
+ [Fact]
+ public void MoveDataConnection_SameSite_ReturnsErrorNoWrite()
+ {
+ // Moving a connection to the site it already belongs to is a no-op and
+ // must be rejected before any write — and before the existence/collision
+ // guards even run.
+ var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
+
+ var siteRepo = Substitute.For();
+ siteRepo.GetDataConnectionByIdAsync(5, Arg.Any()).Returns(conn);
+ _services.AddScoped(_ => siteRepo);
+
+ var actor = CreateActor();
+ var envelope = Envelope(new MoveDataConnectionCommand(5, 1), "Designer");
+
+ actor.Tell(envelope);
+
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Contains("already belongs", response.Error);
+ Assert.Equal(1, conn.SiteId);
+ siteRepo.DidNotReceive().UpdateDataConnectionAsync(
+ Arg.Any(), Arg.Any());
+ siteRepo.DidNotReceive().SaveChangesAsync(Arg.Any());
+ }
+
+ [Fact]
+ public void MoveDataConnection_BlockedByTemplateNativeAlarmSourceOnSourceSite_ReturnsError()
+ {
+ // A template instantiated on the SOURCE site has a native-alarm-source
+ // whose ConnectionName matches the moving connection -> blocked, because
+ // the deploying-site flatten would no longer resolve that name.
+ var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
+ var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
+
+ // Source-site instance referencing template 42.
+ var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };
+
+ var template = new Template("PumpTemplate") { Id = 42 };
+ template.NativeAlarmSources.Add(
+ new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });
+
+ var siteRepo = Substitute.For();
+ siteRepo.GetDataConnectionByIdAsync(5, Arg.Any()).Returns(conn);
+ siteRepo.GetSiteByIdAsync(2, Arg.Any()).Returns(targetSite);
+ siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any())
+ .Returns(new List());
+ siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any())
+ .Returns(new List());
+ // Source-site instances (purpose-built load with overrides eagerly loaded).
+ siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any())
+ .Returns(new List { sourceInstance });
+ _services.AddScoped(_ => siteRepo);
+
+ // Only template 42 (instantiated on the source site) is scanned.
+ _templateRepo.GetTemplateByIdAsync(42, Arg.Any()).Returns(template);
+
+ var actor = CreateActor();
+ var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
+
+ actor.Tell(envelope);
+
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ // Error names the template and its native-alarm-source.
+ Assert.Contains("PumpTemplate", response.Error);
+ Assert.Contains("PumpFaults", response.Error);
+ Assert.Equal(1, conn.SiteId);
+ siteRepo.DidNotReceive().UpdateDataConnectionAsync(
+ Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public void MoveDataConnection_BlockedByInstanceOverrideOnSourceSite_ReturnsError()
+ {
+ // A source-site instance has a native-alarm-source override whose
+ // ConnectionNameOverride matches the moving connection -> blocked.
+ var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
+ var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
+
+ var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };
+ sourceInstance.NativeAlarmSourceOverrides.Add(
+ new InstanceNativeAlarmSourceOverride("PumpFaults")
+ { Id = 1, InstanceId = 7, ConnectionNameOverride = "Opc1" });
+
+ var siteRepo = Substitute.For();
+ siteRepo.GetDataConnectionByIdAsync(5, Arg.Any()).Returns(conn);
+ siteRepo.GetSiteByIdAsync(2, Arg.Any()).Returns(targetSite);
+ siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any())
+ .Returns(new List());
+ siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any())
+ .Returns(new List());
+ siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any())
+ .Returns(new List { sourceInstance });
+ _services.AddScoped(_ => siteRepo);
+
+ // Template 42 has no matching native-alarm-source; the override is the blocker.
+ _templateRepo.GetTemplateByIdAsync(42, Arg.Any())
+ .Returns(new Template("PumpTemplate") { Id = 42 });
+
+ var actor = CreateActor();
+ var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
+
+ actor.Tell(envelope);
+
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ // Error names the instance and its override.
+ Assert.Contains("Tank01", response.Error);
+ Assert.Contains("PumpFaults", response.Error);
+ Assert.Equal(1, conn.SiteId);
+ siteRepo.DidNotReceive().UpdateDataConnectionAsync(
+ Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public void MoveDataConnection_TemplateReferencedOnlyByOtherSite_DoesNotBlock()
+ {
+ // Regression for the over-blocking false positive: a template references
+ // the connection NAME ("Opc1") but is instantiated ONLY on another site.
+ // Templates are site-agnostic and ConnectionName resolves against the
+ // DEPLOYING site's pool, so moving site 1's "Opc1" does not break the
+ // other site's deployment. The move must succeed.
+ var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
+ var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
+
+ // The SOURCE site (1) has an instance using template 99, which does NOT
+ // reference "Opc1". A different template (42) referencing "Opc1" exists
+ // but is instantiated only on the target/other site, so it is never loaded.
+ var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 99 };
+
+ var siteRepo = Substitute.For();
+ siteRepo.GetDataConnectionByIdAsync(5, Arg.Any()).Returns(conn);
+ siteRepo.GetSiteByIdAsync(2, Arg.Any()).Returns(targetSite);
+ siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any())
+ .Returns(new List());
+ siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any())
+ .Returns(new List());
+ siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any())
+ .Returns(new List { sourceInstance });
+ siteRepo.UpdateDataConnectionAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ siteRepo.SaveChangesAsync(Arg.Any()).Returns(1);
+ _services.AddScoped(_ => siteRepo);
+
+ // Template 99 (the one actually on the source site) does not reference "Opc1".
+ _templateRepo.GetTemplateByIdAsync(99, Arg.Any())
+ .Returns(new Template("OtherTemplate") { Id = 99 });
+ // Template 42 references "Opc1" but lives only on another site: if the
+ // handler scoped its scan correctly it must NEVER load template 42. We
+ // still stub it to prove the cross-site reference is irrelevant.
+ var crossSiteTemplate = new Template("PumpTemplate") { Id = 42 };
+ crossSiteTemplate.NativeAlarmSources.Add(
+ new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });
+ _templateRepo.GetTemplateByIdAsync(42, Arg.Any()).Returns(crossSiteTemplate);
+
+ var actor = CreateActor();
+ var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
+
+ actor.Tell(envelope);
+
+ // Move SUCCEEDS: the cross-site template reference must not block.
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Equal(envelope.CorrelationId, response.CorrelationId);
+ Assert.Equal(2, conn.SiteId);
+ siteRepo.Received(1).UpdateDataConnectionAsync(
+ Arg.Is(c => c.Id == 5 && c.SiteId == 2),
+ Arg.Any());
+ // The off-site template (42) must never be scanned.
+ _templateRepo.DidNotReceive().GetTemplateByIdAsync(42, Arg.Any());
+ }
}