diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index cea2f062..eb31b42e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -85,6 +85,10 @@ public sealed class AdminOperationsActor : ReceiveActor // derivation, cross-cluster/namespace binding, driver-namespace compat, signal collisions, // …) gates the deploy. A green build in this repo does not prove the config is valid; this // is the last guard before a bad address space (or a non-derived EquipmentId) ships. + // NOTE: a BadDuplicateExternalIdentifier rejection from the reservation pre-flight can + // (rarely) be a false positive if an external-id reservation release has not yet been + // committed/visible when the snapshot is read — operators seeing a spurious one should + // check ExternalIdReservation state before re-submitting. var draft = await DraftSnapshotFactory.FromConfigDbAsync(db); var errors = DraftValidator.Validate(draft); if (errors.Count > 0) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs index 8898c43c..39840f33 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs @@ -141,6 +141,54 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase verify.Deployments.Count().ShouldBe(0); } + /// Verifies that a DriverInstance whose NamespaceId lives in a different cluster + /// triggers BadCrossClusterNamespaceBinding and routes to the Rejected branch through + /// the actor — no coordinator dispatch, no Deployment row. + /// Seeded: Namespace in cluster "MAIN" + DriverInstance in cluster "SITE-A" referencing that + /// namespace. NamespaceKind.Equipment + DriverType "ModbusTcp" satisfies the compat rule so + /// only the cross-cluster rule fires. + [Fact] + public void StartDeployment_rejects_on_cross_cluster_namespace_binding() + { + const string nsId = "ns-main-equipment"; + + var dbFactory = NewInMemoryDbFactory(); + using (var db = dbFactory.CreateDbContext()) + { + db.Namespaces.Add(new Configuration.Entities.Namespace + { + NamespaceId = nsId, + ClusterId = "MAIN", + Kind = Configuration.Enums.NamespaceKind.Equipment, + NamespaceUri = "urn:zb:main:equipment", + }); + db.DriverInstances.Add(new Configuration.Entities.DriverInstance + { + DriverInstanceId = "drv-site-a-01", + ClusterId = "SITE-A", + NamespaceId = nsId, // cross-cluster: drv is SITE-A, ns is MAIN + Name = "site-a-modbus", + DriverType = "ModbusTcp", // compatible with Equipment ns — no compat error + DriverConfig = "{}", + }); + db.SaveChanges(); + } + + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); + + actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); + + coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected); + reply.Message.ShouldNotBeNull(); + reply.Message.ShouldContain("BadCrossClusterNamespaceBinding"); + + using var verify = dbFactory.CreateDbContext(); + verify.Deployments.Count().ShouldBe(0); + } + /// Verifies that starting a deployment is refused when another is in flight. [Fact] public void StartDeployment_refuses_when_another_is_in_flight()