using System.Net; using System.Net.Sockets; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Issue #473 — the over-the-wire proof that a live condition event carries the mandatory /// BaseEventType identity fields EventType / SourceNode / SourceName. All three /// previously arrived null on every condition event: MaterialiseAlarmCondition never assigned /// them, and nothing downstream synthesises them (ReportEvent / InstanceStateSnapshot copy the /// condition's children verbatim), so a conforming client could not attribute an alarm to its source. /// /// This is the WIRE-level guard the node-level /// NodeManagerAlarmSourceFieldsTests cannot give: it proves the values survive the snapshot + /// serialization all the way into a real client's EventFieldList. The select clause is deliberately /// the exact one a real consumer uses — ScadaBridge's Data Connection Layer selects /// [EventType, SourceNode, SourceName, Time, Message, Severity] — so this test fails the way that /// client failed. /// /// Boots the real and drives the production /// , mirroring NativeAlarmMultiNotifierEventDeliveryTests. Heavy /// in-process server+client integration — runs in the serial integration pass. /// public sealed class NativeAlarmEventIdentityFieldDeliveryTests { private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields"; private const string ConditionClassServerUri = "urn:OtOpcUa.AlarmEventConditionClassFields"; private const string QualityServerUri = "urn:OtOpcUa.AlarmEventQualityField"; private const string RawDeviceFolder = "pymodbus/plc"; private const string RawAlarmPath = "pymodbus/plc/HR200"; private const string LeafName = "HR200"; private const string Equip1 = "EQ-filling-line1-station1"; private const string AlarmMessage = "HR200 over limit (identity-field test)"; // Field indices in the select clause built by BuildEventFilter — ScadaBridge's exact clause. private const int EventTypeIndex = 0; private const int SourceNodeIndex = 1; private const int SourceNameIndex = 2; private const int TimeIndex = 3; private const int MessageIndex = 4; private const int SeverityIndex = 5; // Field indices in BuildConditionClassEventFilter's clause (#475). A SEPARATE clause on purpose: the // ScadaBridge one above is mirrored field-for-field from the real consumer and its indices are load-bearing, // so appending to it would silently shift them. Message is re-selected here only as the collector's filter key. private const int ConditionClassIdIndex = 0; private const int ConditionClassNameIndex = 1; private const int ConditionClassMessageIndex = 2; // #477 — indices in BuildQualityEventFilter's clause [Quality, Message]. Again SEPARATE from the load-bearing // ScadaBridge clause so its indices don't shift. private const int QualityIndex = 0; private const int QualityMessageIndex = 1; /// A live native condition event delivers a populated EventType / SourceNode / SourceName to a /// Server-object subscriber using the standard BaseEventType select clause. [Fact] public async Task Condition_event_carries_populated_EventType_SourceNode_and_SourceName_on_the_wire() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-identity-{Guid.NewGuid():N}"); var port = AllocateFreePort(); var ct = TestContext.Current.CancellationToken; try { var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct); await using var _ = host; var sink = new SdkAddressSpaceSink(server.NodeManager!); WireCondition(sink); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); // Resolve the RAW namespace index CLIENT-side (from the server's advertised NamespaceArray) so the // expected SourceNode is built exactly as a real client would resolve it. var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); rawNs.ShouldBeGreaterThan((ushort)0); var collector = new EventCollector(MessageIndex); var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; subscription.FastEventCallback = collector.OnEvents; session.AddSubscription(subscription); await subscription.CreateAsync(ct); // The collector filters by our unique Message, so the item's ClientHandle is not needed here // (unlike the sibling multi-notifier test, which tallies delivery per monitored item). AddEventItem(subscription, ObjectIds.Server, BuildEventFilter()); await subscription.ApplyChangesAsync(ct); sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5)); var fields = collector.Fields.ShouldHaveSingleItem(); // --- the three fields this issue is about --- // EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause). fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType, "EventType must carry the concrete condition type, not null"); // SourceNode: the condition's own NodeId in the RAW namespace == ConditionId. fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs), "SourceNode must identify the source condition node, not null"); // SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf // name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName. fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath, "SourceName must carry the unique RawPath, not null"); fields[SourceNameIndex].Value.ShouldNotBe(LeafName, "SourceName is deliberately the unique RawPath, not the ambiguous leaf name"); // The rest of the standard envelope still arrives intact (guards against a regression that // populated identity at the cost of the existing fields). fields[TimeIndex].Value.ShouldBeOfType(); ((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage); fields[SeverityIndex].Value.ShouldBe((ushort)700); await subscription.DeleteAsync(true, ct); } finally { SafeDelete(pkiRoot + "-srv"); } } /// Issue #475 — a live condition event delivers a populated ConditionClassId / ConditionClassName. /// Both previously arrived unset (NodeId.Null / empty text) via the same mechanism as the #473 fields, so an /// HMI bucketing alarms by condition class dropped every OtOpcUa alarm into an unclassified bin. [Fact] public async Task Condition_event_carries_populated_ConditionClass_fields_on_the_wire() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-condclass-{Guid.NewGuid():N}"); var port = AllocateFreePort(); var ct = TestContext.Current.CancellationToken; try { var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ConditionClassServerUri, ct); await using var _ = host; var sink = new SdkAddressSpaceSink(server.NodeManager!); WireCondition(sink); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var collector = new EventCollector(ConditionClassMessageIndex); var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; subscription.FastEventCallback = collector.OnEvents; session.AddSubscription(subscription); await subscription.CreateAsync(ct); AddEventItem(subscription, ObjectIds.Server, BuildConditionClassEventFilter()); await subscription.ApplyChangesAsync(ct); sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5)); var fields = collector.Fields.ShouldHaveSingleItem(); // ConditionClassId — a resolvable class NodeId, NOT NodeId.Null. We model no condition classes, so // Part 9's "no class modelled" value (BaseConditionClassType) is the conformant report. fields[ConditionClassIdIndex].Value.ShouldBe(ObjectTypeIds.BaseConditionClassType, "ConditionClassId must carry a resolvable condition class, not null"); // ConditionClassName — the matching human-readable name, NOT empty text. var className = fields[ConditionClassNameIndex].Value.ShouldBeOfType(); className.Text.ShouldBe("BaseConditionClass", "ConditionClassName must name the reported condition class, not be empty"); await subscription.DeleteAsync(true, ct); } finally { SafeDelete(pkiRoot + "-srv"); } } /// Issue #477 — the over-the-wire proof that a native condition's source-data Quality tracks its /// driver's connectivity. A comms-lost source (WriteAlarmQuality(Bad)) delivers a condition event /// whose Quality is non-Good, and recovery (WriteAlarmQuality(Good)) delivers Good again — so a /// client can distinguish "genuinely inactive" from "we have lost contact". Before the fix the field was the /// accidentally-Good default (default(StatusCode) == Good) on every event regardless of the source. [Fact] public async Task Condition_event_Quality_tracks_source_connectivity_on_the_wire() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-quality-{Guid.NewGuid():N}"); var port = AllocateFreePort(); var ct = TestContext.Current.CancellationToken; try { var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", QualityServerUri, ct); await using var _ = host; var sink = new SdkAddressSpaceSink(server.NodeManager!); WireCondition(sink); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var collector = new EventCollector(QualityMessageIndex); var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; subscription.FastEventCallback = collector.OnEvents; session.AddSubscription(subscription); await subscription.CreateAsync(ct); AddEventItem(subscription, ObjectIds.Server, BuildQualityEventFilter()); await subscription.ApplyChangesAsync(ct); // Raise the alarm while the source is healthy → Quality Good. sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5)); StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value) .ShouldBeTrue("a healthy source's condition reports Good quality"); // Device goes comms-lost (connectivity path) → the next condition event carries a Bad Quality, while // the alarm stays active (annotation only — not asserted here, covered by the node-level test). sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => collector.Fields.Count >= 2, TimeSpan.FromSeconds(5)); StatusCode.IsBad((StatusCode)collector.Fields[^1][QualityIndex].Value) .ShouldBeTrue("a comms-lost source's condition must report non-Good quality on the wire"); // Device recovers → Quality returns to Good. sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => collector.Fields.Count >= 3, TimeSpan.FromSeconds(5)); StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value) .ShouldBeTrue("a recovered source's condition reports Good quality again"); await subscription.DeleteAsync(true, ct); } finally { SafeDelete(pkiRoot + "-srv"); } } /// Materialise the raw device folder + the native condition at its RawPath, plus one referencing /// equipment folder wired as a notifier (the production topology). private static void WireCondition(SdkAddressSpaceSink sink) { sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "plc", realm: AddressSpaceRealm.Raw); sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, LeafName, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true); sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns); } private static AlarmConditionSnapshot ActiveSnapshot() => new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage); private static MonitoredItem AddEventItem(Subscription subscription, NodeId source, EventFilter filter) { var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = source, AttributeId = Attributes.EventNotifier, Filter = filter, QueueSize = 100, SamplingInterval = 0, }; subscription.AddItem(item); return item; } /// ScadaBridge's exact select clause: [EventType, SourceNode, SourceName, Time, Message, Severity]. private static EventFilter BuildEventFilter() { var filter = new EventFilter(); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity); return filter; } /// #475's clause: [ConditionClassId, ConditionClassName, Message]. The two class fields are declared on /// ConditionType, not BaseEventType, so they must be selected against that type or the server returns no /// value for them regardless of the fix. Message rides along solely as the collector's filter key. private static EventFilter BuildConditionClassEventFilter() { var filter = new EventFilter(); filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassId); filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassName); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); return filter; } /// #477's clause: [Quality, Message]. Quality is declared on ConditionType, so it must be /// selected against that type. Message rides along as the collector's filter key (a quality-only change still /// snapshots the unchanged Message). private static EventFilter BuildQualityEventFilter() { var filter = new EventFilter(); filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.Quality); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); return filter; } /// Captures the delivered event field lists, filtered to our unique alarm Message so unrelated /// server events / refresh brackets never count. /// Position of the Message field in the select clause this collector is paired /// with — the two clauses here place it differently, so it cannot be a shared constant. private sealed class EventCollector(int messageIndex) { private readonly object _gate = new(); private readonly List _fields = new(); public IReadOnlyList Fields { get { lock (_gate) return _fields.ToList(); } } public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable) { lock (_gate) { foreach (var e in notification.Events) { if (e.EventFields.Count > messageIndex && e.EventFields[messageIndex].Value is LocalizedText lt && lt.Text == AlarmMessage) { _fields.Add(e.EventFields); } } } } } private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync( int port, string pkiRoot, string appUri, CancellationToken ct) { var options = new OpcUaApplicationHostOptions { ApplicationName = appUri, ApplicationUri = appUri, OpcUaPort = port, PublicHostname = "127.0.0.1", PkiStoreRoot = pkiRoot, EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, AutoAcceptUntrustedClientCertificates = true, }; var server = new OtOpcUaSdkServer(); var host = new OpcUaApplicationHost(options, NullLogger.Instance); await host.StartAsync(server, ct); return (server, host); } private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) { var appConfig = new ApplicationConfiguration { ApplicationName = "OtOpcUa.AlarmEventIdentityFieldsClient", ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; await appConfig.ValidateAsync(ApplicationType.Client, ct); appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; var endpoint = await CoreClientUtils.SelectEndpointAsync( appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct); var endpointConfiguration = EndpointConfiguration.Create(appConfig); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, sessionName: "AlarmEventIdentityFieldDeliveryTests", sessionTimeout: 60_000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); } private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(50); } condition().ShouldBeTrue("condition not met within timeout"); } private static int AllocateFreePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } private static void SafeDelete(string dir) { if (Directory.Exists(dir)) { try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } } } }