feat(m9/T24a): guarded move-data-connection-between-sites command + handler
This commit is contained in:
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user