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 0a779a68..ee779405 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs @@ -70,13 +70,26 @@ public interface ISiteRepository /// A task that represents the asynchronous operation. Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default); - // Instances (for deletion constraint checks) + // Instances (for deletion / move constraint checks) /// Retrieves all instances deployed to a site. /// The site primary key to filter by. /// Cancellation token. /// A task that resolves to a read-only list of entities for the given site. Task> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); + /// + /// Retrieves the distinct instances that have at least one + /// referencing the given data + /// connection. Used as the primary data-integrity guard for moving a + /// connection between sites: a bound connection cannot leave its site + /// without orphaning the (site-scoped) binding, so the move handler rejects + /// the move and names the returned instances as blockers. + /// + /// The data connection primary key. + /// Cancellation token. + /// A task that resolves to the distinct referencing entities (empty when none). + Task> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, 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.Commons/Messages/Management/DataConnectionCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/DataConnectionCommands.cs index 9cbf1065..08881df9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/DataConnectionCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/DataConnectionCommands.cs @@ -5,3 +5,18 @@ public record GetDataConnectionCommand(int DataConnectionId); public record CreateDataConnectionCommand(int SiteId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3); public record UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3); public record DeleteDataConnectionCommand(int DataConnectionId); + +/// +/// Moves a data connection from its current site to . +/// Designer-gated and heavily guarded (see the handler): the target site must +/// exist, the target site must not already own a connection with the same name, +/// and NO may reference +/// the connection (instances are site-scoped, so a bound connection cannot leave +/// its site without orphaning the binding). Name-based native-alarm-source +/// references (template ConnectionName / instance +/// ConnectionNameOverride) are also checked and the move is blocked rather +/// than silently creating an ambiguous/orphaned state. No auto-rewrite is performed. +/// +/// Primary key of the connection to move. +/// Primary key of the destination site. +public record MoveDataConnectionCommand(int DataConnectionId, int TargetSiteId); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs index 34499496..ad5d9680 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs @@ -125,13 +125,37 @@ public class SiteRepository : ISiteRepository return Task.CompletedTask; } - // --- Instances (for deletion constraint checks) --- + // --- Instances (for deletion / move constraint checks) --- /// public async Task> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) { return await _dbContext.Instances .Where(i => i.SiteId == siteId) + .Include(i => i.ConnectionBindings) + .Include(i => i.NativeAlarmSourceOverrides) + .AsSplitQuery() + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, CancellationToken cancellationToken = default) + { + // The distinct instance ids whose bindings reference the connection. + // Bindings are not exposed via their own DbSet on ISiteRepository, so + // resolve them through the InstanceConnectionBindings set, then load the + // owning instances (cheap; the blocker list is expected to be tiny). + var instanceIds = await _dbContext.InstanceConnectionBindings + .Where(b => b.DataConnectionId == dataConnectionId) + .Select(b => b.InstanceId) + .Distinct() + .ToListAsync(cancellationToken); + + if (instanceIds.Count == 0) + return Array.Empty(); + + return await _dbContext.Instances + .Where(i => instanceIds.Contains(i.Id)) .ToListAsync(cancellationToken); } diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 3f4a60f2..646080ae 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -187,7 +187,7 @@ public class ManagementActor : ReceiveActor or DeleteNotificationListCommand or UpdateSmtpConfigCommand or CreateDataConnectionCommand or UpdateDataConnectionCommand - or DeleteDataConnectionCommand + or DeleteDataConnectionCommand or MoveDataConnectionCommand or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand @@ -306,6 +306,7 @@ public class ManagementActor : ReceiveActor CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username), UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username), DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username), + MoveDataConnectionCommand cmd => await HandleMoveDataConnection(sp, cmd, user.Username), // External Systems ListExternalSystemsCommand => await HandleListExternalSystems(sp), @@ -1373,6 +1374,108 @@ public class ManagementActor : ReceiveActor return true; } + /// + /// Moves a data connection to another site (M9 / T24a). High-risk + data + /// integrity: every guard runs server-side BEFORE the write, and when in + /// doubt the move is BLOCKED with a clear error rather than risking an + /// orphaned binding/reference. No bindings or name references are rewritten. + /// + private static async Task HandleMoveDataConnection(IServiceProvider sp, MoveDataConnectionCommand cmd, string user) + { + var repo = sp.GetRequiredService(); + + var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId) + ?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found."); + + var sourceSiteId = conn.SiteId; + if (sourceSiteId == cmd.TargetSiteId) + throw new ManagementCommandException( + $"DataConnection '{conn.Name}' (ID {conn.Id}) already belongs to site {cmd.TargetSiteId}."); + + // Guard 1: target site must exist. + var targetSite = await repo.GetSiteByIdAsync(cmd.TargetSiteId) + ?? throw new ManagementCommandException($"Target site with ID {cmd.TargetSiteId} not found."); + + // Guard 2: no name collision at the target site. + var targetConnections = await repo.GetDataConnectionsBySiteIdAsync(cmd.TargetSiteId); + if (targetConnections.Any(c => c.Id != conn.Id + && string.Equals(c.Name, conn.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new ManagementCommandException( + $"Target site '{targetSite.Name}' (ID {cmd.TargetSiteId}) already has a data connection named '{conn.Name}'. " + + "Rename one of them before moving."); + } + + // Guard 3 (primary data-integrity guard): no InstanceConnectionBinding may + // reference the connection. Instances are site-scoped, so a bound + // connection cannot leave its site without orphaning the binding. + var blockingInstances = await repo.GetInstancesReferencingDataConnectionAsync(conn.Id); + if (blockingInstances.Count > 0) + { + var names = string.Join(", ", blockingInstances + .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase) + .Select(i => $"'{i.UniqueName}' (ID {i.Id})")); + throw new ManagementCommandException( + $"Cannot move data connection '{conn.Name}' (ID {conn.Id}): it is referenced by " + + $"{blockingInstances.Count} instance binding(s) on its current site. Rebind or delete the " + + $"following instance(s) first: {names}."); + } + + // Guard 4 (name-based native-alarm-source references): templates reference + // a connection by Name (TemplateNativeAlarmSource.ConnectionName) and + // 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. + var referenceBlockers = new List(); + + var templateRepo = sp.GetRequiredService(); + var templates = await templateRepo.GetAllTemplatesAsync(); + foreach (var template in templates) + { + foreach (var source in template.NativeAlarmSources) + { + if (string.Equals(source.ConnectionName, conn.Name, StringComparison.OrdinalIgnoreCase)) + { + referenceBlockers.Add( + $"template '{template.Name}' native-alarm-source '{source.Name}'"); + } + } + } + + // 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)) + { + referenceBlockers.Add( + $"instance '{instance.UniqueName}' (ID {instance.Id}) native-alarm-source override '{ovr.SourceCanonicalName}'"); + } + } + } + + if (referenceBlockers.Count > 0) + { + var details = string.Join(", ", referenceBlockers); + throw new ManagementCommandException( + $"Cannot move data connection '{conn.Name}' (ID {conn.Id}): its name is referenced by " + + $"native-alarm-source binding(s) that would be orphaned or made ambiguous by the move. " + + $"Resolve the following reference(s) first: {details}."); + } + + // All guards passed: persist the site change and audit. + conn.SiteId = cmd.TargetSiteId; + await repo.UpdateDataConnectionAsync(conn); + await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Move", "DataConnection", conn.Id.ToString(), conn.Name, conn); + return conn; + } + // ======================================================================== // External System handlers diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 34b19d8e..42bc003e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -2072,4 +2072,145 @@ public class ManagementActorTests : TestKit, IDisposable var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("Deployer", response.Message); } + + // ======================================================================== + // MoveDataConnectionCommand (M9 / T24a) — guarded cross-site move + // ======================================================================== + + [Fact] + public void MoveDataConnection_WithViewerRole_ReturnsUnauthorized() + { + var actor = CreateActor(); + var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Viewer"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("Designer", response.Message); + } + + [Fact] + public void MoveDataConnection_HappyPath_MovesSiteIdAndAudits() + { + 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 siteRepo = Substitute.For(); + siteRepo.GetDataConnectionByIdAsync(5, Arg.Any()).Returns(conn); + siteRepo.GetSiteByIdAsync(2, Arg.Any()).Returns(targetSite); + // No name collision at the target site. + siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any()) + .Returns(new List()); + // 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()) + .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