Phase 0 — mechanical rename ZB.MOM.WW.LmxOpcUa.* → ZB.MOM.WW.OtOpcUa.*
Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.
Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.
Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
166
tests/ZB.MOM.WW.OtOpcUa.Tests/Integration/AccessLevelTests.cs
Normal file
166
tests/ZB.MOM.WW.OtOpcUa.Tests/Integration/AccessLevelTests.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class AccessLevelTests
|
||||
{
|
||||
private static FakeGalaxyRepository CreateRepoWithSecurityLevels()
|
||||
{
|
||||
return new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false
|
||||
}
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "FreeAttr",
|
||||
FullTagReference = "TestObj.FreeAttr", MxDataType = 5, SecurityClassification = 0
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "OperateAttr",
|
||||
FullTagReference = "TestObj.OperateAttr", MxDataType = 5, SecurityClassification = 1
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "SecuredAttr",
|
||||
FullTagReference = "TestObj.SecuredAttr", MxDataType = 5, SecurityClassification = 2
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "VerifiedAttr",
|
||||
FullTagReference = "TestObj.VerifiedAttr", MxDataType = 5, SecurityClassification = 3
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "TuneAttr",
|
||||
FullTagReference = "TestObj.TuneAttr", MxDataType = 5, SecurityClassification = 4
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "ConfigAttr",
|
||||
FullTagReference = "TestObj.ConfigAttr", MxDataType = 5, SecurityClassification = 5
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "ViewOnlyAttr",
|
||||
FullTagReference = "TestObj.ViewOnlyAttr", MxDataType = 5, SecurityClassification = 6
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that writable Galaxy security classifications publish OPC UA variables with read-write access.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadWriteAttribute_HasCurrentReadOrWrite_AccessLevel()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
foreach (var attrName in new[] { "FreeAttr", "OperateAttr", "TuneAttr", "ConfigAttr" })
|
||||
{
|
||||
var nodeId = client.MakeNodeId($"TestObj.{attrName}");
|
||||
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
|
||||
((byte)accessLevel.Value).ShouldBe(AccessLevels.CurrentReadOrWrite,
|
||||
$"{attrName} should be ReadWrite");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that secured and view-only Galaxy classifications publish OPC UA variables with read-only access.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadOnlyAttribute_HasCurrentRead_AccessLevel()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
foreach (var attrName in new[] { "SecuredAttr", "VerifiedAttr", "ViewOnlyAttr" })
|
||||
{
|
||||
var nodeId = client.MakeNodeId($"TestObj.{attrName}");
|
||||
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
|
||||
((byte)accessLevel.Value).ShouldBe(AccessLevels.CurrentRead,
|
||||
$"{attrName} should be ReadOnly");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the bridge rejects writes against Galaxy attributes whose security classification is read-only.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_ToReadOnlyAttribute_IsRejected()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestObj.ViewOnlyAttr");
|
||||
var result = client.Write(nodeId, "test");
|
||||
StatusCode.IsBad(result).ShouldBeTrue("Write to ReadOnly attribute should be rejected");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that writes succeed for Galaxy attributes whose security classification permits operator updates.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_ToReadWriteAttribute_Succeeds()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient, CreateRepoWithSecurityLevels());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestObj.OperateAttr");
|
||||
var result = client.Write(nodeId, "test");
|
||||
StatusCode.IsGood(result).ShouldBeTrue("Write to ReadWrite attribute should succeed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration tests verifying dynamic address space changes via a real OPC UA client.
|
||||
/// Tests browse, subscribe, add/remove nodes at runtime, and subscription quality changes.
|
||||
/// </summary>
|
||||
public class AddressSpaceRebuildTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that the initial browsed hierarchy matches the seeded Galaxy model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_ReturnsInitialHierarchy()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Browse from ZB root
|
||||
var zbNode = client.MakeNodeId("ZB");
|
||||
var children = await client.BrowseAsync(zbNode);
|
||||
|
||||
children.ShouldContain(c => c.Name == "DEV");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that adding a Galaxy object and rebuilding exposes the new node to OPC UA clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_AfterAddingObject_NewNodeAppears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Verify initial state — browse TestMachine_001
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var initialChildren = await client.BrowseAsync(machineNode);
|
||||
initialChildren.ShouldNotContain(c => c.Name == "NewReceiver");
|
||||
|
||||
// Add a new object to the hierarchy
|
||||
fixture.GalaxyRepository!.Hierarchy.Add(new GalaxyObjectInfo
|
||||
{
|
||||
GobjectId = 100, TagName = "NewReceiver_001",
|
||||
ContainedName = "NewReceiver", BrowseName = "NewReceiver",
|
||||
ParentGobjectId = 3, IsArea = false // parent = TestMachine_001
|
||||
});
|
||||
fixture.GalaxyRepository.Attributes.Add(new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = 100, TagName = "NewReceiver_001",
|
||||
AttributeName = "NewAttr", FullTagReference = "NewReceiver_001.NewAttr",
|
||||
MxDataType = 5, IsArray = false
|
||||
});
|
||||
|
||||
// Trigger rebuild
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500); // allow rebuild to complete
|
||||
|
||||
// Browse again — new node should appear
|
||||
var updatedChildren = await client.BrowseAsync(machineNode);
|
||||
updatedChildren.ShouldContain(c => c.Name == "NewReceiver");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that removing a Galaxy object and rebuilding removes the node from the OPC UA hierarchy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_AfterRemovingObject_NodeDisappears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Verify MESReceiver exists initially
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var initialChildren = await client.BrowseAsync(machineNode);
|
||||
initialChildren.ShouldContain(c => c.Name == "MESReceiver");
|
||||
|
||||
// Remove MESReceiver and its attributes from hierarchy
|
||||
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001");
|
||||
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001");
|
||||
|
||||
// Trigger rebuild
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Browse again — MESReceiver should be gone
|
||||
var updatedChildren = await client.BrowseAsync(machineNode);
|
||||
updatedChildren.ShouldNotContain(c => c.Name == "MESReceiver");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that subscriptions on deleted nodes receive a bad-quality notification after rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_RemovedNode_PublishesBadQuality()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Subscribe to an attribute that will be removed
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInBatchID");
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId, 100);
|
||||
|
||||
// Collect notifications
|
||||
var notifications = new List<MonitoredItemNotification>();
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n)
|
||||
notifications.Add(n);
|
||||
};
|
||||
|
||||
await Task.Delay(500); // let initial subscription settle
|
||||
|
||||
// Remove MESReceiver and its attributes
|
||||
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001");
|
||||
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001");
|
||||
|
||||
// Trigger rebuild — nodes get deleted
|
||||
fixture.Service.TriggerRebuild();
|
||||
|
||||
// Wait for publish cycle to deliver Bad status
|
||||
await Task.Delay(2000);
|
||||
|
||||
// The subscription should have received a Bad quality notification
|
||||
// after the node was deleted during rebuild
|
||||
notifications.ShouldContain(n => StatusCode.IsBad(n.Value.StatusCode));
|
||||
|
||||
await sub.DeleteAsync(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that subscriptions on surviving nodes continue to work after a partial rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_SurvivingNode_StillWorksAfterRebuild()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Subscribe to an attribute that will survive the rebuild
|
||||
var nodeId = client.MakeNodeId("TestMachine_001.MachineID");
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId, 100);
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
// Remove only MESReceiver (MachineID on TestMachine_001 survives)
|
||||
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001");
|
||||
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001");
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(1000);
|
||||
|
||||
// The surviving node should still be browsable
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var children = await client.BrowseAsync(machineNode);
|
||||
children.ShouldContain(c => c.Name == "MachineID");
|
||||
|
||||
await sub.DeleteAsync(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that adding a Galaxy attribute and rebuilding exposes a new OPC UA variable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_AddAttribute_NewVariableAppears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var initialChildren = await client.BrowseAsync(machineNode);
|
||||
initialChildren.ShouldNotContain(c => c.Name == "NewSensor");
|
||||
|
||||
// Add a new attribute
|
||||
fixture.GalaxyRepository!.Attributes.Add(new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001",
|
||||
AttributeName = "NewSensor", FullTagReference = "TestMachine_001.NewSensor",
|
||||
MxDataType = 4, IsArray = false // Double
|
||||
});
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
var updatedChildren = await client.BrowseAsync(machineNode);
|
||||
updatedChildren.ShouldContain(c => c.Name == "NewSensor");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that removing a Galaxy attribute and rebuilding removes the OPC UA variable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_RemoveAttribute_VariableDisappears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var initialChildren = await client.BrowseAsync(machineNode);
|
||||
initialChildren.ShouldContain(c => c.Name == "MachineCode");
|
||||
|
||||
// Remove MachineCode attribute
|
||||
fixture.GalaxyRepository!.Attributes.RemoveAll(a =>
|
||||
a.TagName == "TestMachine_001" && a.AttributeName == "MachineCode");
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
var updatedChildren = await client.BrowseAsync(machineNode);
|
||||
updatedChildren.ShouldNotContain(c => c.Name == "MachineCode");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that rebuilds preserve subscription bookkeeping for nodes that survive the metadata refresh.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Rebuild_PreservesSubscriptionBookkeeping_ForSurvivingNodes()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
var mxClient = fixture.MxAccessClient!;
|
||||
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(200);
|
||||
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that transferred monitored items recreate MXAccess subscriptions when the service has no local
|
||||
/// subscription state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TransferSubscriptions_RestoresMxAccessSubscriptionState_WhenLocalStateIsMissing()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
var mxClient = fixture.MxAccessClient!;
|
||||
|
||||
nodeManager.RestoreTransferredSubscriptions(new[]
|
||||
{
|
||||
"TestMachine_001.MachineID",
|
||||
"TestMachine_001.MachineID"
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that transferring monitored items does not double-count subscriptions already tracked in memory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TransferSubscriptions_DoesNotDoubleCount_WhenSubscriptionAlreadyTracked()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
var mxClient = fixture.MxAccessClient!;
|
||||
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.RestoreTransferredSubscriptions(new[]
|
||||
{
|
||||
"TestMachine_001.MachineID"
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end integration tests that boot a real LmxNodeManager against fake Galaxy data and verify
|
||||
/// the template-based alarm object filter actually suppresses alarm condition creation in both the
|
||||
/// full build path and the subtree rebuild path after a simulated Galaxy redeploy.
|
||||
/// </summary>
|
||||
public class AlarmObjectFilterIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Filter_Empty_AllAlarmsTracked()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: CreateRepoWithMixedTemplates(),
|
||||
alarmTrackingEnabled: true);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager.ShouldNotBeNull();
|
||||
// Two alarm attributes total (one per object), no filter → both tracked.
|
||||
fixture.NodeManager!.AlarmConditionCount.ShouldBe(2);
|
||||
fixture.NodeManager.AlarmFilterEnabled.ShouldBeFalse();
|
||||
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filter_MatchesOneTemplate_OnlyMatchingAlarmTracked()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: CreateRepoWithMixedTemplates(),
|
||||
alarmTrackingEnabled: true,
|
||||
alarmObjectFilters: new[] { "TestMachine*" });
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager.ShouldNotBeNull();
|
||||
fixture.NodeManager!.AlarmFilterEnabled.ShouldBeTrue();
|
||||
fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1);
|
||||
fixture.NodeManager.AlarmConditionCount.ShouldBe(1);
|
||||
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filter_MatchesParent_PropagatesToChild()
|
||||
{
|
||||
var attrs = new List<GalaxyAttributeInfo>();
|
||||
attrs.AddRange(AlarmWithInAlarm(1, "Parent_001", "AlarmA"));
|
||||
attrs.AddRange(AlarmWithInAlarm(2, "Child_001", "AlarmB"));
|
||||
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, tag: "Parent_001", template: "TestMachine"),
|
||||
Obj(2, parent: 1, tag: "Child_001", template: "UnrelatedWidget")
|
||||
},
|
||||
Attributes = attrs
|
||||
};
|
||||
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: repo,
|
||||
alarmTrackingEnabled: true,
|
||||
alarmObjectFilters: new[] { "TestMachine*" });
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager!.AlarmConditionCount.ShouldBe(2);
|
||||
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filter_NoMatch_ZeroAlarmConditions()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: CreateRepoWithMixedTemplates(),
|
||||
alarmTrackingEnabled: true,
|
||||
alarmObjectFilters: new[] { "NotInGalaxy*" });
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager!.AlarmConditionCount.ShouldBe(0);
|
||||
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0);
|
||||
fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filter_GalaxyDollarPrefix_Normalized()
|
||||
{
|
||||
// Template chain stored as "$TestMachine" must match operator pattern "TestMachine*".
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, tag: "Obj_1", template: "$TestMachine")
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>(AlarmWithInAlarm(1, "Obj_1", "AlarmX"))
|
||||
};
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: repo,
|
||||
alarmTrackingEnabled: true,
|
||||
alarmObjectFilters: new[] { "TestMachine*" });
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager!.AlarmConditionCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private static FakeGalaxyRepository CreateRepoWithMixedTemplates()
|
||||
{
|
||||
var attrs = new List<GalaxyAttributeInfo>();
|
||||
attrs.AddRange(AlarmWithInAlarm(1, "TestMachine_001", "MachineAlarm"));
|
||||
attrs.AddRange(AlarmWithInAlarm(2, "Pump_001", "PumpAlarm"));
|
||||
return new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, tag: "TestMachine_001", template: "TestMachine"),
|
||||
Obj(2, parent: 0, tag: "Pump_001", template: "Pump")
|
||||
},
|
||||
Attributes = attrs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a Galaxy alarm attribute plus its companion <c>.InAlarm</c> sub-attribute. The alarm
|
||||
/// creation path in LmxNodeManager skips objects whose alarm attribute has no InAlarm variable
|
||||
/// in the tag→node map, so tests must include both rows for alarm conditions to materialize.
|
||||
/// </summary>
|
||||
private static IEnumerable<GalaxyAttributeInfo> AlarmWithInAlarm(int gobjectId, string tag, string attrName)
|
||||
{
|
||||
yield return new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = gobjectId,
|
||||
TagName = tag,
|
||||
AttributeName = attrName,
|
||||
FullTagReference = $"{tag}.{attrName}",
|
||||
MxDataType = 1,
|
||||
IsAlarm = true
|
||||
};
|
||||
yield return new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = gobjectId,
|
||||
TagName = tag,
|
||||
AttributeName = attrName + ".InAlarm",
|
||||
FullTagReference = $"{tag}.{attrName}.InAlarm",
|
||||
MxDataType = 1,
|
||||
IsAlarm = false
|
||||
};
|
||||
}
|
||||
|
||||
private static GalaxyObjectInfo Obj(int id, int parent, string tag, string template) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
ParentGobjectId = parent,
|
||||
TagName = tag,
|
||||
ContainedName = tag,
|
||||
BrowseName = tag,
|
||||
IsArea = false,
|
||||
TemplateChain = new List<string> { template }
|
||||
};
|
||||
|
||||
private static GalaxyAttributeInfo AlarmAttr(int gobjectId, string tag, string attrName) => new()
|
||||
{
|
||||
GobjectId = gobjectId,
|
||||
TagName = tag,
|
||||
AttributeName = attrName,
|
||||
FullTagReference = $"{tag}.{attrName}",
|
||||
MxDataType = 1,
|
||||
IsAlarm = true
|
||||
};
|
||||
}
|
||||
}
|
||||
203
tests/ZB.MOM.WW.OtOpcUa.Tests/Integration/ArrayWriteTests.cs
Normal file
203
tests/ZB.MOM.WW.OtOpcUa.Tests/Integration/ArrayWriteTests.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies OPC UA indexed array writes against the bridge's whole-array runtime update behavior.
|
||||
/// </summary>
|
||||
public class ArrayWriteTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that writing a single array element updates the correct slot while preserving the rest of the array.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_SingleArrayElement_UpdatesWholeArrayValue()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(
|
||||
Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray());
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Browse path: TestMachine_001 -> MESReceiver -> MoveInPartNumbers
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
|
||||
var before = client.Read(nodeId).Value as string[];
|
||||
before.ShouldNotBeNull();
|
||||
before.Length.ShouldBe(50);
|
||||
before[1].ShouldBe("PART-01");
|
||||
|
||||
var status = client.Write(nodeId, new[] { "UPDATED-PART" }, "1");
|
||||
StatusCode.IsGood(status).ShouldBe(true);
|
||||
|
||||
var after = client.Read(nodeId).Value as string[];
|
||||
after.ShouldNotBeNull();
|
||||
after.Length.ShouldBe(50);
|
||||
after[0].ShouldBe("PART-00");
|
||||
after[1].ShouldBe("UPDATED-PART");
|
||||
after[2].ShouldBe("PART-02");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that array nodes use bracketless OPC UA node identifiers while still exposing one-dimensional array
|
||||
/// metadata.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ArrayNode_UsesBracketlessNodeId_AndPublishesArrayDimensions()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(
|
||||
Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray());
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
|
||||
var value = client.Read(nodeId).Value as string[];
|
||||
value.ShouldNotBeNull();
|
||||
value.Length.ShouldBe(50);
|
||||
|
||||
var valueRank = client.ReadAttribute(nodeId, Attributes.ValueRank).Value;
|
||||
valueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
|
||||
var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[];
|
||||
dimensions.ShouldNotBeNull();
|
||||
dimensions.ShouldBe(new uint[] { 50 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a null runtime value for a statically sized array is exposed as a typed fixed-length array.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Read_NullStaticArray_ReturnsDefaultTypedArray()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null);
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
|
||||
var value = client.Read(nodeId).Value as string[];
|
||||
value.ShouldNotBeNull();
|
||||
value.Length.ShouldBe(50);
|
||||
value.ShouldAllBe(v => v == string.Empty);
|
||||
|
||||
var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[];
|
||||
dimensions.ShouldBe(new uint[] { 50 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that an indexed write also updates the published OPC UA value seen by subscribed clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_SingleArrayElement_PublishesUpdatedArrayToSubscribers()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(
|
||||
Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray());
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
var notifications = new ConcurrentBag<MonitoredItemNotification>();
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId, 100);
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification notification)
|
||||
notifications.Add(notification);
|
||||
};
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
var status = client.Write(nodeId, new[] { "UPDATED-PART" }, "1");
|
||||
StatusCode.IsGood(status).ShouldBe(true);
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
notifications.Any(n =>
|
||||
n.Value.Value is string[] values &&
|
||||
values.Length == 50 &&
|
||||
values[0] == "PART-00" &&
|
||||
values[1] == "UPDATED-PART" &&
|
||||
values[2] == "PART-02").ShouldBe(true);
|
||||
|
||||
await sub.DeleteAsync(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that indexed writes succeed even when the current runtime array value is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_SingleArrayElement_WhenCurrentArrayIsNull_UsesDefaultArray()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null);
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
var status = client.Write(nodeId, new[] { "UPDATED-PART" }, "1");
|
||||
StatusCode.IsGood(status).ShouldBe(true);
|
||||
|
||||
var after = client.Read(nodeId).Value as string[];
|
||||
after.ShouldNotBeNull();
|
||||
after.Length.ShouldBe(50);
|
||||
after[0].ShouldBe(string.Empty);
|
||||
after[1].ShouldBe("UPDATED-PART");
|
||||
after[2].ShouldBe(string.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class HistorizingFlagTests
|
||||
{
|
||||
private static FakeGalaxyRepository CreateRepo()
|
||||
{
|
||||
return new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false
|
||||
}
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "HistorizedAttr",
|
||||
FullTagReference = "TestObj.HistorizedAttr", MxDataType = 2, IsHistorized = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "NormalAttr",
|
||||
FullTagReference = "TestObj.NormalAttr", MxDataType = 5, IsHistorized = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "AlarmAttr",
|
||||
FullTagReference = "TestObj.AlarmAttr", MxDataType = 1, IsAlarm = true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that historized Galaxy attributes advertise OPC UA historizing support and history-read access.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HistorizedAttribute_HasHistorizingTrue_AndHistoryReadAccess()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepo());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestObj.HistorizedAttr");
|
||||
var historizing = client.ReadAttribute(nodeId, Attributes.Historizing);
|
||||
((bool)historizing.Value).ShouldBeTrue();
|
||||
|
||||
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
|
||||
var level = (byte)accessLevel.Value;
|
||||
(level & AccessLevels.HistoryRead).ShouldBe(AccessLevels.HistoryRead,
|
||||
"HistoryRead bit should be set");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that non-historized Galaxy attributes do not claim OPC UA history support.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NormalAttribute_HasHistorizingFalse_AndNoHistoryReadAccess()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepo());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestObj.NormalAttr");
|
||||
var historizing = client.ReadAttribute(nodeId, Attributes.Historizing);
|
||||
((bool)historizing.Value).ShouldBeFalse();
|
||||
|
||||
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
|
||||
var level = (byte)accessLevel.Value;
|
||||
(level & AccessLevels.HistoryRead).ShouldBe(0,
|
||||
"HistoryRead bit should not be set");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class IncrementalSyncTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that adding a new Galaxy object and attribute causes the corresponding OPC UA node subtree to appear after
|
||||
/// sync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_AddObject_NewNodeAppears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Verify initial state
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("TestArea"));
|
||||
children.Select(c => c.Name).ShouldContain("TestMachine_001");
|
||||
children.Select(c => c.Name).ShouldNotContain("NewObject");
|
||||
|
||||
// Add a new object
|
||||
fixture.GalaxyRepository!.Hierarchy.Add(new GalaxyObjectInfo
|
||||
{
|
||||
GobjectId = 100, TagName = "NewObject_001", ContainedName = "NewObject",
|
||||
BrowseName = "NewObject", ParentGobjectId = 2, IsArea = false
|
||||
});
|
||||
fixture.GalaxyRepository.Attributes.Add(new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = 100, TagName = "NewObject_001", AttributeName = "Status",
|
||||
FullTagReference = "NewObject_001.Status", MxDataType = 5
|
||||
});
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(1000);
|
||||
|
||||
// Reconnect in case session was disrupted during rebuild
|
||||
using var client2 = new OpcUaTestClient();
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// New object should appear when browsing parent
|
||||
children = await client2.BrowseAsync(client2.MakeNodeId("TestArea"));
|
||||
children.Select(c => c.Name).ShouldContain("NewObject",
|
||||
$"Browse returned: [{string.Join(", ", children.Select(c => c.Name))}]");
|
||||
|
||||
// Original object should still be there
|
||||
children.Select(c => c.Name).ShouldContain("TestMachine_001");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that removing a Galaxy object tears down the corresponding OPC UA subtree without affecting siblings.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_RemoveObject_NodeDisappears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Verify MESReceiver exists
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001"));
|
||||
children.Select(c => c.Name).ShouldContain("MESReceiver");
|
||||
|
||||
// Remove MESReceiver (gobject_id 5) and its attributes
|
||||
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.GobjectId == 5);
|
||||
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.GobjectId == 5);
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
// MESReceiver should be gone
|
||||
children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001"));
|
||||
children.Select(c => c.Name).ShouldNotContain("MESReceiver");
|
||||
|
||||
// DelmiaReceiver should still be there
|
||||
children.Select(c => c.Name).ShouldContain("DelmiaReceiver");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that adding a Galaxy attribute creates a new OPC UA variable during incremental rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_AddAttribute_NewVariableAppears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Add a new attribute to TestMachine_001
|
||||
fixture.GalaxyRepository!.Attributes.Add(new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", AttributeName = "NewAttr",
|
||||
FullTagReference = "TestMachine_001.NewAttr", MxDataType = 5
|
||||
});
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001"));
|
||||
children.Select(c => c.Name).ShouldContain("NewAttr");
|
||||
children.Select(c => c.Name).ShouldContain("MachineID");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that subscriptions on unchanged objects continue receiving data after unrelated subtree rebuilds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_UnchangedObject_SubscriptionSurvives()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Subscribe to MachineID on TestMachine_001
|
||||
var nodeId = client.MakeNodeId("TestMachine_001.MachineID");
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId);
|
||||
await Task.Delay(500);
|
||||
|
||||
// Modify a DIFFERENT object (MESReceiver) — TestMachine_001 should be unaffected
|
||||
var mesAttr = fixture.GalaxyRepository!.Attributes
|
||||
.First(a => a.GobjectId == 5 && a.AttributeName == "MoveInBatchID");
|
||||
mesAttr.SecurityClassification = 2; // change something
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Push a value change through MXAccess — subscription should still deliver
|
||||
mxClient.SimulateDataChange("TestMachine_001.MachineID", Vtq.Good("UPDATED"));
|
||||
await Task.Delay(1000);
|
||||
|
||||
var lastValue = (item.LastValue as MonitoredItemNotification)?.Value?.Value;
|
||||
lastValue.ShouldBe("UPDATED");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a rebuild request with no repository changes leaves the published namespace intact.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_NoChanges_NothingHappens()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Trigger rebuild with no changes
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Everything should still work
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001"));
|
||||
children.Select(c => c.Name).ShouldContain("MachineID");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
430
tests/ZB.MOM.WW.OtOpcUa.Tests/Integration/MultiClientTests.cs
Normal file
430
tests/ZB.MOM.WW.OtOpcUa.Tests/Integration/MultiClientTests.cs
Normal file
@@ -0,0 +1,430 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration tests verifying multi-client subscription sync and concurrent operations.
|
||||
/// </summary>
|
||||
public class MultiClientTests
|
||||
{
|
||||
// ── Subscription Sync ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that multiple OPC UA clients subscribed to the same tag all receive the same runtime update.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MultipleClients_SubscribeToSameTag_AllReceiveDataChanges()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var clients = new List<OpcUaTestClient>();
|
||||
var notifications = new ConcurrentDictionary<int, List<MonitoredItemNotification>>();
|
||||
var subscriptions = new List<Subscription>();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
clients.Add(client);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestMachine_001.MachineID");
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId, 100);
|
||||
subscriptions.Add(sub);
|
||||
|
||||
var clientIndex = i;
|
||||
notifications[clientIndex] = new List<MonitoredItemNotification>();
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n)
|
||||
notifications[clientIndex].Add(n);
|
||||
};
|
||||
}
|
||||
|
||||
await Task.Delay(500); // let subscriptions settle
|
||||
|
||||
// Simulate data change
|
||||
fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "MACHINE_42");
|
||||
await Task.Delay(1000); // let publish cycle deliver
|
||||
|
||||
// All 3 clients should have received the notification
|
||||
for (var i = 0; i < 3; i++)
|
||||
notifications[i].Count.ShouldBeGreaterThan(0, $"Client {i} did not receive notification");
|
||||
|
||||
foreach (var sub in subscriptions) await sub.DeleteAsync(true);
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that one client disconnecting does not stop remaining clients from receiving updates.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Client_Disconnects_OtherClientsStillReceive()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var client1 = new OpcUaTestClient();
|
||||
var client2 = new OpcUaTestClient();
|
||||
var client3 = new OpcUaTestClient();
|
||||
await client1.ConnectAsync(fixture.EndpointUrl);
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
await client3.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var notifications1 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
var notifications3 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
|
||||
var (sub1, item1) = await client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
var (sub2, _) = await client2.SubscribeAsync(client2.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
var (sub3, item3) = await client3.SubscribeAsync(client3.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
|
||||
item1.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications1.Add(n);
|
||||
};
|
||||
item3.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications3.Add(n);
|
||||
};
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
// Disconnect client 2
|
||||
client2.Dispose();
|
||||
|
||||
await Task.Delay(500); // let server process disconnect
|
||||
|
||||
// Simulate data change — should not crash, clients 1+3 should still receive
|
||||
fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "AFTER_DISCONNECT");
|
||||
await Task.Delay(1000);
|
||||
|
||||
notifications1.Count.ShouldBeGreaterThan(0,
|
||||
"Client 1 should still receive after client 2 disconnected");
|
||||
notifications3.Count.ShouldBeGreaterThan(0,
|
||||
"Client 3 should still receive after client 2 disconnected");
|
||||
|
||||
await sub1.DeleteAsync(true);
|
||||
await sub3.DeleteAsync(true);
|
||||
client1.Dispose();
|
||||
client3.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that one client unsubscribing does not interrupt delivery to other subscribed clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Client_Unsubscribes_OtherClientsStillReceive()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var client1 = new OpcUaTestClient();
|
||||
var client2 = new OpcUaTestClient();
|
||||
await client1.ConnectAsync(fixture.EndpointUrl);
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var notifications2 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
|
||||
var (sub1, _) = await client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
var (sub2, item2) = await client2.SubscribeAsync(client2.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
item2.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications2.Add(n);
|
||||
};
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
// Client 1 unsubscribes
|
||||
await sub1.DeleteAsync(true);
|
||||
await Task.Delay(500);
|
||||
|
||||
// Simulate data change — client 2 should still receive
|
||||
fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "AFTER_UNSUB");
|
||||
await Task.Delay(1000);
|
||||
|
||||
notifications2.Count.ShouldBeGreaterThan(0,
|
||||
"Client 2 should still receive after client 1 unsubscribed");
|
||||
|
||||
await sub2.DeleteAsync(true);
|
||||
client1.Dispose();
|
||||
client2.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that clients subscribed to different tags only receive updates for their own monitored data.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MultipleClients_SubscribeToDifferentTags_EachGetsOwnData()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var client1 = new OpcUaTestClient();
|
||||
var client2 = new OpcUaTestClient();
|
||||
await client1.ConnectAsync(fixture.EndpointUrl);
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var notifications1 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
var notifications2 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
|
||||
var (sub1, item1) = await client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
var (sub2, item2) =
|
||||
await client2.SubscribeAsync(client2.MakeNodeId("DelmiaReceiver_001.DownloadPath"), 100);
|
||||
|
||||
item1.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications1.Add(n);
|
||||
};
|
||||
item2.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications2.Add(n);
|
||||
};
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
// Only change MachineID
|
||||
fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "CHANGED");
|
||||
await Task.Delay(1000);
|
||||
|
||||
notifications1.Count.ShouldBeGreaterThan(0, "Client 1 should receive MachineID change");
|
||||
// Client 2 subscribed to DownloadPath, should NOT receive MachineID change
|
||||
// (it may have received initial BadWaitingForInitialData, but not the "CHANGED" value)
|
||||
var client2HasMachineIdValue = notifications2.Any(n =>
|
||||
n.Value.Value is string s && s == "CHANGED");
|
||||
client2HasMachineIdValue.ShouldBe(false, "Client 2 should not receive MachineID data");
|
||||
|
||||
await sub1.DeleteAsync(true);
|
||||
await sub2.DeleteAsync(true);
|
||||
client1.Dispose();
|
||||
client2.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Concurrent Operation Tests ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that concurrent browse operations from several clients all complete successfully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentBrowseFromMultipleClients_AllSucceed()
|
||||
{
|
||||
// Tests concurrent browse operations from 5 clients — browses don't go through MxAccess
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var clients = new List<OpcUaTestClient>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var c = new OpcUaTestClient();
|
||||
await c.ConnectAsync(fixture.EndpointUrl);
|
||||
clients.Add(c);
|
||||
}
|
||||
|
||||
var nodes = new[]
|
||||
{
|
||||
"ZB", "TestMachine_001", "DelmiaReceiver_001",
|
||||
"MESReceiver_001", "TestMachine_001"
|
||||
};
|
||||
|
||||
// All 5 clients browse simultaneously
|
||||
var browseTasks = clients.Select((c, i) =>
|
||||
c.BrowseAsync(c.MakeNodeId(nodes[i]))).ToArray();
|
||||
|
||||
var results = await Task.WhenAll(browseTasks);
|
||||
|
||||
results.Length.ShouldBe(5);
|
||||
foreach (var r in results)
|
||||
r.ShouldNotBeEmpty();
|
||||
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that concurrent browse requests return consistent results across clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentBrowse_AllReturnSameResults()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var clients = new List<OpcUaTestClient>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var c = new OpcUaTestClient();
|
||||
await c.ConnectAsync(fixture.EndpointUrl);
|
||||
clients.Add(c);
|
||||
}
|
||||
|
||||
// All browse TestMachine_001 simultaneously
|
||||
var browseTasks = clients.Select(c =>
|
||||
c.BrowseAsync(c.MakeNodeId("TestMachine_001"))).ToArray();
|
||||
|
||||
var results = await Task.WhenAll(browseTasks);
|
||||
|
||||
// All should get identical child lists
|
||||
var firstResult = results[0].Select(r => r.Name).OrderBy(n => n).ToList();
|
||||
for (var i = 1; i < results.Length; i++)
|
||||
{
|
||||
var thisResult = results[i].Select(r => r.Name).OrderBy(n => n).ToList();
|
||||
thisResult.ShouldBe(firstResult, $"Client {i} got different browse results");
|
||||
}
|
||||
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that simultaneous browse and subscribe operations do not interfere with one another.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentBrowseAndSubscribe_NoInterference()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var clients = new List<OpcUaTestClient>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var c = new OpcUaTestClient();
|
||||
await c.ConnectAsync(fixture.EndpointUrl);
|
||||
clients.Add(c);
|
||||
}
|
||||
|
||||
// 2 browse + 2 subscribe simultaneously
|
||||
var tasks = new Task[]
|
||||
{
|
||||
clients[0].BrowseAsync(clients[0].MakeNodeId("TestMachine_001")),
|
||||
clients[1].BrowseAsync(clients[1].MakeNodeId("ZB")),
|
||||
clients[2].SubscribeAsync(clients[2].MakeNodeId("TestMachine_001.MachineID"), 200),
|
||||
clients[3].SubscribeAsync(clients[3].MakeNodeId("DelmiaReceiver_001.DownloadPath"), 200)
|
||||
};
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
// All should complete without errors
|
||||
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that concurrent subscribe, read, and browse operations complete without deadlocking the server.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentSubscribeAndRead_NoDeadlock()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var client1 = new OpcUaTestClient();
|
||||
var client2 = new OpcUaTestClient();
|
||||
var client3 = new OpcUaTestClient();
|
||||
await client1.ConnectAsync(fixture.EndpointUrl);
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
await client3.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// All three operate simultaneously — should not deadlock
|
||||
var timeout = Task.Delay(TimeSpan.FromSeconds(15));
|
||||
var operations = Task.WhenAll(
|
||||
client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 200)
|
||||
.ContinueWith(t => (object)t.Result),
|
||||
Task.Run(() => (object)client2.Read(client2.MakeNodeId("DelmiaReceiver_001.DownloadPath"))),
|
||||
client3.BrowseAsync(client3.MakeNodeId("TestMachine_001"))
|
||||
.ContinueWith(t => (object)t.Result)
|
||||
);
|
||||
|
||||
var completed = await Task.WhenAny(operations, timeout);
|
||||
completed.ShouldBe(operations, "Operations should complete before timeout (possible deadlock)");
|
||||
|
||||
client1.Dispose();
|
||||
client2.Dispose();
|
||||
client3.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that repeated client churn does not leave the server in an unstable state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RapidConnectDisconnect_ServerStaysStable()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
// Rapidly connect, browse, disconnect — 10 iterations
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("ZB"));
|
||||
children.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
// After all that churn, server should still be responsive
|
||||
using var finalClient = new OpcUaTestClient();
|
||||
await finalClient.ConnectAsync(fixture.EndpointUrl);
|
||||
var finalChildren = await finalClient.BrowseAsync(finalClient.MakeNodeId("TestMachine_001"));
|
||||
finalChildren.ShouldContain(c => c.Name == "MachineID");
|
||||
finalChildren.ShouldContain(c => c.Name == "DelmiaReceiver");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class PermissionEnforcementTests
|
||||
{
|
||||
private static FakeAuthenticationProvider CreateTestAuthProvider()
|
||||
{
|
||||
return new FakeAuthenticationProvider()
|
||||
.AddUser("readonly", "readonly123", AppRoles.ReadOnly)
|
||||
.AddUser("writeop", "writeop123", AppRoles.WriteOperate)
|
||||
.AddUser("writetune", "writetune123", AppRoles.WriteTune)
|
||||
.AddUser("writeconfig", "writeconfig123", AppRoles.WriteConfigure)
|
||||
.AddUser("alarmack", "alarmack123", AppRoles.AlarmAck)
|
||||
.AddUser("admin", "admin123", AppRoles.ReadOnly, AppRoles.WriteOperate, AppRoles.WriteTune,
|
||||
AppRoles.WriteConfigure, AppRoles.AlarmAck);
|
||||
}
|
||||
|
||||
private static AuthenticationConfiguration CreateAuthConfig(bool anonymousCanWrite = false)
|
||||
{
|
||||
return new AuthenticationConfiguration
|
||||
{
|
||||
AllowAnonymous = true,
|
||||
AnonymousCanWrite = anonymousCanWrite
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymousRead_Allowed()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("hello");
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
mxClient,
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var result = client.Read(client.MakeNodeId("TestMachine_001.MachineID"));
|
||||
result.StatusCode.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymousWrite_Denied_WhenAnonymousCanWriteFalse()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
authConfig: CreateAuthConfig(false),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymousWrite_Allowed_WhenAnonymousCanWriteTrue()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial");
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
mxClient,
|
||||
authConfig: CreateAuthConfig(true),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOnlyUser_Write_Denied()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "readonly", password: "readonly123");
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteOperateUser_Write_Allowed()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial");
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
mxClient,
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "writeop", password: "writeop123");
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmAckOnlyUser_Write_Denied()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "alarmack", password: "alarmack123");
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminUser_Write_Allowed()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial");
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
mxClient,
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "admin", password: "admin123");
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidPassword_ConnectionRejected()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(async () =>
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "readonly", password: "wrongpassword"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
188
tests/ZB.MOM.WW.OtOpcUa.Tests/Integration/RedundancyTests.cs
Normal file
188
tests/ZB.MOM.WW.OtOpcUa.Tests/Integration/RedundancyTests.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class RedundancyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Server_WithRedundancyDisabled_ReportsNone()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport);
|
||||
((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.None);
|
||||
|
||||
var serviceLevel = client.Read(VariableIds.Server_ServiceLevel);
|
||||
((byte)serviceLevel.Value).ShouldBe((byte)255);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_WithRedundancyEnabled_ReportsConfiguredMode()
|
||||
{
|
||||
var redundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Mode = "Warm",
|
||||
Role = "Primary",
|
||||
ServiceLevelBase = 200,
|
||||
ServerUris = new List<string> { "urn:test:primary", "urn:test:secondary" }
|
||||
};
|
||||
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: redundancy,
|
||||
applicationUri: "urn:test:primary");
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport);
|
||||
((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.Warm);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_Primary_HasHigherServiceLevel_ThanSecondary()
|
||||
{
|
||||
var sharedUris = new List<string> { "urn:test:primary", "urn:test:secondary" };
|
||||
|
||||
var primaryRedundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Primary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
var secondaryRedundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Secondary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
|
||||
var primaryFixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: primaryRedundancy, applicationUri: "urn:test:primary");
|
||||
var secondaryFixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: secondaryRedundancy, applicationUri: "urn:test:secondary",
|
||||
serverName: "TestGalaxy2");
|
||||
|
||||
await primaryFixture.InitializeAsync();
|
||||
await secondaryFixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var primaryClient = new OpcUaTestClient();
|
||||
await primaryClient.ConnectAsync(primaryFixture.EndpointUrl);
|
||||
var primaryLevel = (byte)primaryClient.Read(VariableIds.Server_ServiceLevel).Value;
|
||||
|
||||
using var secondaryClient = new OpcUaTestClient();
|
||||
await secondaryClient.ConnectAsync(secondaryFixture.EndpointUrl);
|
||||
var secondaryLevel = (byte)secondaryClient.Read(VariableIds.Server_ServiceLevel).Value;
|
||||
|
||||
primaryLevel.ShouldBeGreaterThan(secondaryLevel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await secondaryFixture.DisposeAsync();
|
||||
await primaryFixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_WithRedundancyEnabled_ExposesServerUriArray()
|
||||
{
|
||||
var serverUris = new List<string> { "urn:test:server1", "urn:test:server2" };
|
||||
var redundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Primary",
|
||||
ServiceLevelBase = 200, ServerUris = serverUris
|
||||
};
|
||||
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: redundancy, applicationUri: "urn:test:server1");
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var uriArrayValue = client.Read(VariableIds.Server_ServerRedundancy_ServerUriArray);
|
||||
|
||||
// ServerUriArray may not be exposed if the SDK doesn't create the non-transparent
|
||||
// redundancy node type automatically. If the value is null, the server logged a
|
||||
// warning and the test is informational rather than a hard failure.
|
||||
if (uriArrayValue.Value != null)
|
||||
{
|
||||
var uris = (string[])uriArrayValue.Value;
|
||||
uris.Length.ShouldBe(2);
|
||||
uris.ShouldContain("urn:test:server1");
|
||||
uris.ShouldContain("urn:test:server2");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoServers_BothExposeSameRedundantSet()
|
||||
{
|
||||
var sharedUris = new List<string> { "urn:test:a", "urn:test:b" };
|
||||
var configA = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Primary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
var configB = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Secondary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
|
||||
var fixtureA = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: configA, applicationUri: "urn:test:a");
|
||||
var fixtureB = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: configB, applicationUri: "urn:test:b",
|
||||
serverName: "TestGalaxy2");
|
||||
|
||||
await fixtureA.InitializeAsync();
|
||||
await fixtureB.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var clientA = new OpcUaTestClient();
|
||||
await clientA.ConnectAsync(fixtureA.EndpointUrl);
|
||||
var modeA = (int)clientA.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value;
|
||||
|
||||
using var clientB = new OpcUaTestClient();
|
||||
await clientB.ConnectAsync(fixtureB.EndpointUrl);
|
||||
var modeB = (int)clientB.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value;
|
||||
|
||||
modeA.ShouldBe((int)RedundancySupport.Warm);
|
||||
modeB.ShouldBe((int)RedundancySupport.Warm);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixtureB.DisposeAsync();
|
||||
await fixtureA.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user