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 — end-to-end server integration coverage for the /// dispatch path. Boots the full OPC UA stack + a fake driver, /// opens a client session, raises a driver-side transition, and asserts it propagates /// through GenericDriverNodeManager's alarm forwarder into /// DriverNodeManager.ConditionSink, updates the server-side /// AlarmConditionState child attributes (Severity / Message / ActiveState), and /// flows out to an OPC UA subscription on the Server object's EventNotifier. /// /// Companion to which covers the /// dispatch path; together they close the server-side /// integration gap for optional driver capabilities (plan decision #62). /// [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"); children.ShouldContainKey("Message"); children.ShouldContainKey("ActiveState"); // Severity / Message / ActiveState.Id reflect the driver-fired transition — verifies // the forwarder → ConditionSink.OnTransition → alarm.ClearChangeMasks pipeline // landed the new values in addressable child nodes. DriverNodeManager's // AssignSymbolicDescendantIds keeps each child reachable under the node manager's // namespace so Read resolves against the predefined-node dictionary. var severity = session.ReadValue(children["Severity"]); var message = session.ReadValue(children["Message"]); severity.Value.ShouldBe((ushort)700); // AlarmSeverity.High → 700 (MapSeverity) ((LocalizedText)message.Value).Text.ShouldBe("Level exceeded upper-upper"); // ActiveState exposes its boolean Id as a HasProperty child. var activeBrowse = new BrowseDescriptionCollection { new() { NodeId = children["ActiveState"], BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HasProperty, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All, }, }; session.Browse(null, null, 0, activeBrowse, out var activeChildren, out _); var idRef = activeChildren[0].References.Single(r => r.BrowseName.Name == "Id"); var activeId = session.ReadValue(ExpandedNodeId.ToNodeId(idRef.NodeId, session.NamespaceUris)); activeId.Value.ShouldBe(true); } [Fact] public async Task Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier() { // AddRootNotifier registers the AlarmConditionState as a Server-object notifier // source, so a subscription with an EventFilter on Server receives the // ReportEvent calls ConditionSink emits per-transition. using var session = await OpenSessionAsync(); var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; session.AddSubscription(subscription); await subscription.CreateAsync(); var received = new List(); var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var filter = new EventFilter(); filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId); filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName); filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message); filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity); filter.WhereClause = new ContentFilter(); filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand { Value = new Variant(ObjectTypeIds.AlarmConditionType) }); var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier, NodeClass = NodeClass.Object, SamplingInterval = 0, QueueSize = 100, Filter = filter, }; item.Notification += (_, e) => { if (e.NotificationValue is EventFieldList fields) { lock (received) { received.Add(fields); gate.TrySetResult(); } } }; subscription.AddItem(item); await subscription.ApplyChangesAsync(); // Give the publish loop a tick to establish before firing. await Task.Delay(200); _driver.RaiseAlarm(new AlarmEventArgs( new FakeHandle("sub"), "Tank.HiHi", "cond-x", "Active", "High-high tripped", AlarmSeverity.Critical, DateTime.UtcNow)); var delivered = await Task.WhenAny(gate.Task, Task.Delay(TimeSpan.FromSeconds(10))); delivered.ShouldBe(gate.Task, "alarm event must arrive at the client within 10s"); EventFieldList first; lock (received) first = received[0]; // Filter field order: 0=EventId, 1=SourceName, 2=Message, 3=Severity. ((LocalizedText)first.EventFields[2].Value).Text.ShouldBe("High-high tripped"); first.EventFields[3].Value.ShouldBe((ushort)900); // Critical → 900 } [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; } }