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:
Joseph Doherty
2026-04-17 13:57:47 -04:00
parent 5b8d708c58
commit 3b2defd94f
293 changed files with 841 additions and 722 deletions

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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