Merge pull request 'Task #219 — Server-integration test coverage for IAlarmSource dispatch path' (#197) from task-219-alarm-history-integration into v2

This commit was merged in pull request #197.
This commit is contained in:
2026-04-20 23:36:26 -04:00

View File

@@ -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;
/// <summary>
/// Task #219 — server-integration coverage for the <see cref="IAlarmSource"/> wiring
/// path. Boots the full OPC UA stack + a fake <see cref="IAlarmSource"/> driver, opens a
/// client session, and verifies via browse/read that DiscoverAsync's
/// <c>MarkAsAlarmCondition</c> calls produce addressable AlarmConditionState nodes in the
/// driver's namespace and that firing <c>OnAlarmEvent</c> routes through
/// <c>GenericDriverNodeManager</c>'s forwarder into <c>DriverNodeManager.ConditionSink</c>
/// without throwing.
///
/// Companion to <see cref="HistoryReadIntegrationTests"/> which covers the
/// <see cref="IHistoryProvider"/> 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
/// <see cref="DriverNodeManager"/>'s predefined-node index, so reading those child
/// attributes through an OPC UA client returns <c>BadNodeIdUnknown</c>. OPC UA Part 9
/// event propagation (subscribe-on-Server + ConditionRefresh) is likewise out of scope
/// until the node manager wires <c>HasNotifier</c> + child-node registration. The
/// existing Core-level <c>GenericDriverNodeManagerTests</c> cover the in-memory alarm-sink
/// fan-out semantics directly.
/// </summary>
[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<OpcUaApplicationHost>.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<ISession> 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);
}
/// <summary>
/// Stub <see cref="IAlarmSource"/> driver. <see cref="DiscoverAsync"/> emits two alarm-
/// bearing variables (so tag-scoped fan-out can be asserted); <see cref="RaiseAlarm"/>
/// fires <see cref="OnAlarmEvent"/> exactly like a real driver would.
/// </summary>
private sealed class AlarmDriver : IDriver, ITagDiscovery, IAlarmSource
{
public string DriverInstanceId => "alarm-driver";
public string DriverType => "AlarmStub";
public event EventHandler<AlarmEventArgs>? 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<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> _, CancellationToken __)
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __)
=> Task.CompletedTask;
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __)
=> Task.CompletedTask;
}
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
{
public string DiagnosticId { get; } = diagnosticId;
}
}