feat(m9/T24a): guarded move-data-connection-between-sites command + handler

This commit is contained in:
Joseph Doherty
2026-06-18 11:20:58 -04:00
parent e6191ec55a
commit 48111b50fd
5 changed files with 299 additions and 3 deletions
@@ -70,13 +70,26 @@ public interface ISiteRepository
/// <returns>A task that represents the asynchronous operation.</returns>
Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default);
// Instances (for deletion constraint checks)
// Instances (for deletion / move constraint checks)
/// <summary>Retrieves all instances deployed to a site.</summary>
/// <param name="siteId">The site primary key to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of <see cref="Instance"/> entities for the given site.</returns>
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the distinct instances that have at least one
/// <see cref="InstanceConnectionBinding"/> 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.
/// </summary>
/// <param name="dataConnectionId">The data connection primary key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to the distinct referencing <see cref="Instance"/> entities (empty when none).</returns>
Task<IReadOnlyList<Instance>> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, CancellationToken cancellationToken = default);
/// <summary>Saves all pending changes to the database.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to the number of state entries written to the database.</returns>
@@ -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);
/// <summary>
/// Moves a data connection from its current site to <paramref name="TargetSiteId"/>.
/// 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 <see cref="Entities.Instances.InstanceConnectionBinding"/> 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 <c>ConnectionName</c> / instance
/// <c>ConnectionNameOverride</c>) are also checked and the move is blocked rather
/// than silently creating an ambiguous/orphaned state. No auto-rewrite is performed.
/// </summary>
/// <param name="DataConnectionId">Primary key of the connection to move.</param>
/// <param name="TargetSiteId">Primary key of the destination site.</param>
public record MoveDataConnectionCommand(int DataConnectionId, int TargetSiteId);
@@ -125,13 +125,37 @@ public class SiteRepository : ISiteRepository
return Task.CompletedTask;
}
// --- Instances (for deletion constraint checks) ---
// --- Instances (for deletion / move constraint checks) ---
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> 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<Instance>();
return await _dbContext.Instances
.Where(i => instanceIds.Contains(i.Id))
.ToListAsync(cancellationToken);
}
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
private static async Task<object?> HandleMoveDataConnection(IServiceProvider sp, MoveDataConnectionCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISiteRepository>();
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<string>();
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
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
@@ -2072,4 +2072,145 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementUnauthorized>(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<ManagementUnauthorized>(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<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
// No name collision at the target site.
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
// No bindings reference the connection -> move allowed.
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
// No native-alarm-source overrides on the source site.
siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
siteRepo.UpdateDataConnectionAsync(Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_services.AddScoped(_ => siteRepo);
// No templates reference the connection by name.
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template>());
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// SiteId is mutated to the target.
Assert.Equal(2, conn.SiteId);
siteRepo.Received(1).UpdateDataConnectionAsync(
Arg.Is<Commons.Entities.Sites.DataConnection>(c => c.Id == 5 && c.SiteId == 2),
Arg.Any<CancellationToken>());
_auditService.Received(1).LogAsync(
"testuser", "Move", "DataConnection", "5", "Opc1",
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_TargetSiteMissing_ReturnsError()
{
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(99, Arg.Any<CancellationToken>())
.Returns((Commons.Entities.Sites.Site?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 99), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("99", response.Error);
// SiteId unchanged; no write occurred.
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_NameCollisionAtTarget_ReturnsError()
{
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 collidingConn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 2) { Id = 9 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection> { collidingConn });
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Opc1", response.Error);
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_BlockedByBinding_ReturnsErrorNamingInstances()
{
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 blockingInstance = new Instance("Tank01") { Id = 7, SiteId = 1 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
// A binding references the connection -> move must be rejected.
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { blockingInstance });
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
// Error names the blocking instance.
Assert.Contains("Tank01", response.Error);
// SiteId unchanged; no write occurred.
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
}