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
@@ -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>());
}
}