From acf31fd943fe4dfe0b6b5b32265df6816a551aa3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 23:33:45 -0400 Subject: [PATCH] =?UTF-8?q?Task=20#219=20=E2=80=94=20Server-integration=20?= =?UTF-8?q?test=20coverage=20for=20IAlarmSource=20dispatch=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AlarmSubscribeIntegrationTests alongside HistoryReadIntegrationTests so both optional driver capabilities — IHistoryProvider (already covered) and IAlarmSource (new) — have end-to-end coverage that boots the full OPC UA stack and exercises the wiring path from driver event → GenericDriverNodeManager forwarder → DriverNodeManager ConditionSink through a real Session. Two tests: 1. Driver_alarm_transition_updates_server_side_AlarmConditionState_node — a fake IAlarmSource declares an IsAlarm=true variable, calls MarkAsAlarmCondition in DiscoverAsync, and fires OnAlarmEvent for that source. Verifies the client can browse the alarm condition node at FullReference + ".Condition" and reads the DisplayName back through Session.Read. 2. Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace — two IsAlarm variables each produce their own addressable AlarmConditionState, proving the CapturingHandle per-variable registration works. Scoped-out (documented in the class docstring): the stack exposes AlarmConditionState's inherited children (Severity / Message / ActiveState / …) with Foundation-namespace NodeIds that DriverNodeManager does not add to its predefined-node index, so reading those child attributes through a client returns BadNodeIdUnknown. OPC UA Part 9 event propagation (subscribe-on-Server + ConditionRefresh) is likewise out of reach until the node manager wires HasNotifier + child-node registration. The existing Core-level GenericDriverNodeManagerTests cover the in-memory alarm-sink fan-out semantics. Full Server.Tests suite: 238 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AlarmSubscribeIntegrationTests.cs | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AlarmSubscribeIntegrationTests.cs diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AlarmSubscribeIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AlarmSubscribeIntegrationTests.cs new file mode 100644 index 0000000..a5320df --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AlarmSubscribeIntegrationTests.cs @@ -0,0 +1,268 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Security; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +/// +/// Task #219 — server-integration coverage for the wiring +/// path. Boots the full OPC UA stack + a fake driver, opens a +/// client session, and verifies via browse/read that DiscoverAsync's +/// MarkAsAlarmCondition calls produce addressable AlarmConditionState nodes in the +/// driver's namespace and that firing OnAlarmEvent routes through +/// GenericDriverNodeManager's forwarder into DriverNodeManager.ConditionSink +/// without throwing. +/// +/// Companion to which covers the +/// dispatch path; together they close the server-side +/// integration gap for optional driver capabilities (plan decision #62). +/// +/// Known server-side scoping (not a regression introduced here): the stack exposes the +/// AlarmConditionState type's inherited children (Severity / Message / ActiveState / …) +/// with Foundation-namespace NodeIds (ns=0) that aren't added to +/// 's predefined-node index, so reading those child +/// attributes through an OPC UA client returns BadNodeIdUnknown. OPC UA Part 9 +/// event propagation (subscribe-on-Server + ConditionRefresh) is likewise out of scope +/// until the node manager wires HasNotifier + child-node registration. The +/// existing Core-level GenericDriverNodeManagerTests cover the in-memory alarm-sink +/// fan-out semantics directly. +/// +[Trait("Category", "Integration")] +public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime +{ + private static readonly int Port = 48700 + Random.Shared.Next(0, 99); + private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaAlarmTest"; + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-test-{Guid.NewGuid():N}"); + + private DriverHost _driverHost = null!; + private OpcUaApplicationHost _server = null!; + private AlarmDriver _driver = null!; + + public async ValueTask InitializeAsync() + { + _driverHost = new DriverHost(); + _driver = new AlarmDriver(); + await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None); + + var options = new OpcUaServerOptions + { + EndpointUrl = _endpoint, + ApplicationName = "OtOpcUaAlarmTest", + ApplicationUri = "urn:OtOpcUa:Server:AlarmTest", + PkiStoreRoot = _pkiRoot, + AutoAcceptUntrustedClientCertificates = true, + HealthEndpointsEnabled = false, + }; + + _server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(), + NullLoggerFactory.Instance, NullLogger.Instance); + await _server.StartAsync(CancellationToken.None); + } + + public async ValueTask DisposeAsync() + { + await _server.DisposeAsync(); + await _driverHost.DisposeAsync(); + try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ } + } + + [Fact] + public async Task Driver_alarm_transition_updates_server_side_AlarmConditionState_node() + { + using var session = await OpenSessionAsync(); + var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver"); + + _driver.RaiseAlarm(new AlarmEventArgs( + SubscriptionHandle: new FakeHandle("sub"), + SourceNodeId: "Tank.HiHi", + ConditionId: "cond-1", + AlarmType: "Active", + Message: "Level exceeded upper-upper", + Severity: AlarmSeverity.High, + SourceTimestampUtc: DateTime.UtcNow)); + + // The alarm-condition node's identifier is the driver full-reference + ".Condition" + // (DriverNodeManager.VariableHandle.MarkAsAlarmCondition). Server-side state changes + // are applied synchronously under DriverNodeManager.Lock inside ConditionSink.OnTransition, + // so by the time RaiseAlarm returns the node state has been flushed. + var conditionNodeId = new NodeId("Tank.HiHi.Condition", nsIndex); + + // Browse the condition node for the well-known Part-9 child variables. The stack + // materializes Severity / Message / ActiveState / AckedState as children below the + // AlarmConditionState; their NodeIds are allocated by the stack so we discover them + // by BrowseName rather than guessing. + var browseDescriptions = new BrowseDescriptionCollection + { + new() + { + NodeId = conditionNodeId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All, + }, + }; + session.Browse(null, null, 0, browseDescriptions, out var browseResults, out _); + var children = browseResults[0].References + .ToDictionary(r => r.BrowseName.Name, + r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris), + StringComparer.Ordinal); + + children.ShouldContainKey("Severity", + "browse did not return Severity child; full list: " + + string.Join(", ", browseResults[0].References.Select(r => $"{r.BrowseName.Name}={r.NodeId}"))); + children.ShouldContainKey("Message"); + children.ShouldContainKey("ActiveState"); + + // NB: the stack exposes AlarmConditionState's inherited children with Foundation-namespace + // NodeIds (ns=0). The DriverNodeManager registers only the parent alarm object via + // AddPredefinedNode; reading child attributes through the OPC UA client returns + // BadNodeIdUnknown because the stack-assigned child NodeIds aren't in the node + // manager's predefined-node index. Asserting state-mutation via a client-side read + // is therefore out of reach at this integration layer — the Core-level + // GenericDriverNodeManagerTests cover the in-memory alarm-sink fan-out directly. + // + // What this test *does* verify through the OPC UA client: the alarm node itself is + // reachable via browse (proving MarkAsAlarmCondition registered it as a predefined + // node), its displayed name matches the driver's AlarmConditionInfo.SourceName, and + // firing the transition does not throw out of ConditionSink.OnTransition (which would + // fail the test at RaiseAlarm since the event handler is invoked synchronously). + var nodesToRead = new ReadValueIdCollection + { + new() { NodeId = conditionNodeId, AttributeId = Attributes.DisplayName }, + }; + session.Read(null, 0, TimestampsToReturn.Neither, nodesToRead, out var values, out _); + values[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + ((LocalizedText)values[0].Value).Text.ShouldBe("Tank.HiHi"); + } + + [Fact] + public async Task Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace() + { + // Tag-scoped alarm wiring: DiscoverAsync declares two IsAlarm variables and calls + // MarkAsAlarmCondition on each. The server-side DriverNodeManager wraps each call in + // a CapturingHandle that creates a sibling AlarmConditionState + registers a sink + // under the driver full-reference. Browse should show both condition nodes with + // distinct NodeIds using the FullReference + ".Condition" convention. + using var session = await OpenSessionAsync(); + var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver"); + + _driver.RaiseAlarm(new AlarmEventArgs( + new FakeHandle("sub"), "Tank.HiHi", "c", "Active", "first", AlarmSeverity.High, + DateTime.UtcNow)); + + var attrs = new ReadValueIdCollection + { + new() { NodeId = new NodeId("Tank.HiHi.Condition", nsIndex), AttributeId = Attributes.DisplayName }, + new() { NodeId = new NodeId("Heater.OverTemp.Condition", nsIndex), AttributeId = Attributes.DisplayName }, + }; + session.Read(null, 0, TimestampsToReturn.Neither, attrs, out var results, out _); + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + results[1].StatusCode.Code.ShouldBe(StatusCodes.Good); + ((LocalizedText)results[0].Value).Text.ShouldBe("Tank.HiHi"); + ((LocalizedText)results[1].Value).Text.ShouldBe("Heater.OverTemp"); + } + + private async Task OpenSessionAsync() + { + var cfg = new ApplicationConfiguration + { + ApplicationName = "OtOpcUaAlarmTestClient", + ApplicationUri = "urn:OtOpcUa:AlarmTestClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(_pkiRoot, "client-own"), + SubjectName = "CN=OtOpcUaAlarmTestClient", + }, + TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") }, + TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") }, + RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") }, + AutoAcceptUntrustedCertificates = true, + AddAppCertToTrustedStore = true, + }, + TransportConfigurations = new TransportConfigurationCollection(), + TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, + }; + await cfg.Validate(ApplicationType.Client); + cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client }; + await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize); + + var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false); + var endpointConfig = EndpointConfiguration.Create(cfg); + var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig); + + return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaAlarmTestClientSession", 60000, + new UserIdentity(new AnonymousIdentityToken()), null); + } + + /// + /// Stub driver. emits two alarm- + /// bearing variables (so tag-scoped fan-out can be asserted); + /// fires exactly like a real driver would. + /// + private sealed class AlarmDriver : IDriver, ITagDiscovery, IAlarmSource + { + public string DriverInstanceId => "alarm-driver"; + public string DriverType => "AlarmStub"; + + public event EventHandler? OnAlarmEvent; + + public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; + public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; + + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) + { + var tank = builder.Folder("Tank", "Tank"); + var hiHi = tank.Variable("HiHi", "HiHi", new DriverAttributeInfo( + "Tank.HiHi", DriverDataType.Boolean, false, null, + SecurityClassification.FreeAccess, false, IsAlarm: true)); + hiHi.MarkAsAlarmCondition(new AlarmConditionInfo( + "Tank.HiHi", AlarmSeverity.High, "High-high alarm")); + + var heater = builder.Folder("Heater", "Heater"); + var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo( + "Heater.OverTemp", DriverDataType.Boolean, false, null, + SecurityClassification.FreeAccess, false, IsAlarm: true)); + ot.MarkAsAlarmCondition(new AlarmConditionInfo( + "Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature")); + return Task.CompletedTask; + } + + public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args); + + public Task SubscribeAlarmsAsync( + IReadOnlyList _, CancellationToken __) + => Task.FromResult(new FakeHandle("sub")); + + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __) + => Task.CompletedTask; + + public Task AcknowledgeAsync( + IReadOnlyList _, CancellationToken __) + => Task.CompletedTask; + } + + private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle + { + public string DiagnosticId { get; } = diagnosticId; + } +}