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; // Core.Abstractions.HistoryReadResult (driver-side samples) collides with Opc.Ua.HistoryReadResult // (service-layer per-node result). Alias the driver type so the stub's interface implementations // are unambiguous. using DriverHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// End-to-end test that a real OPC UA client's HistoryRead service reaches a fake driver's /// via 's /// HistoryReadRawModified / HistoryReadProcessed / HistoryReadAtTime / /// HistoryReadEvents overrides. Boots the full OPC UA stack + a stub /// driver, opens a client session, issues each HistoryRead /// variant, and asserts the client receives the expected per-kind payload. /// [Trait("Category", "Integration")] public sealed class HistoryReadIntegrationTests : IAsyncLifetime { private static readonly int Port = 48600 + Random.Shared.Next(0, 99); private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaHistoryTest"; private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-history-test-{Guid.NewGuid():N}"); private DriverHost _driverHost = null!; private OpcUaApplicationHost _server = null!; private HistoryDriver _driver = null!; public async ValueTask InitializeAsync() { _driverHost = new DriverHost(); _driver = new HistoryDriver(); await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None); var options = new OpcUaServerOptions { EndpointUrl = _endpoint, ApplicationName = "OtOpcUaHistoryTest", ApplicationUri = "urn:OtOpcUa:Server:HistoryTest", 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 HistoryReadRaw_round_trips_driver_samples_to_the_client() { using var session = await OpenSessionAsync(); var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); var nodeId = new NodeId("raw.var", nsIndex); // The Opc.Ua client exposes HistoryRead via Session.HistoryRead. We construct a // ReadRawModifiedDetails (IsReadModified=false → raw path) and a single // HistoryReadValueId targeting the driver-backed variable. var details = new ReadRawModifiedDetails { StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), EndTime = new DateTime(2024, 1, 1, 0, 0, 10, DateTimeKind.Utc), NumValuesPerNode = 100, IsReadModified = false, ReturnBounds = false, }; var extObj = new ExtensionObject(details); var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, out var results, out _); results.Count.ShouldBe(1); results[0].StatusCode.Code.ShouldBe(StatusCodes.Good, $"HistoryReadRaw returned {results[0].StatusCode}"); var hd = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData); hd.DataValues.Count.ShouldBe(_driver.RawSamplesReturned, "one DataValue per driver sample"); hd.DataValues[0].Value.ShouldBe(_driver.FirstRawValue); } [Fact] public async Task HistoryReadProcessed_maps_Average_aggregate_and_routes_to_ReadProcessedAsync() { using var session = await OpenSessionAsync(); var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); var nodeId = new NodeId("proc.var", nsIndex); var details = new ReadProcessedDetails { StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc), ProcessingInterval = 10_000, // 10s buckets AggregateType = [ObjectIds.AggregateFunction_Average], }; var extObj = new ExtensionObject(details); var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, out var results, out _); results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); _driver.LastProcessedAggregate.ShouldBe(HistoryAggregateType.Average, "MapAggregate must translate ObjectIds.AggregateFunction_Average → driver enum"); _driver.LastProcessedInterval.ShouldBe(TimeSpan.FromSeconds(10)); } [Fact] public async Task HistoryReadProcessed_returns_BadAggregateNotSupported_for_unmapped_aggregate() { using var session = await OpenSessionAsync(); var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); var nodeId = new NodeId("proc.var", nsIndex); var details = new ReadProcessedDetails { StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc), ProcessingInterval = 10_000, // TimeAverage is a valid OPC UA aggregate NodeId but not one the driver implements — // the override returns BadAggregateNotSupported per Part 13 rather than coercing. AggregateType = [ObjectIds.AggregateFunction_TimeAverage], }; var extObj = new ExtensionObject(details); var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, out var results, out _); results[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported); } [Fact] public async Task HistoryReadAtTime_forwards_timestamp_list_to_driver() { using var session = await OpenSessionAsync(); var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); var nodeId = new NodeId("atTime.var", nsIndex); var t1 = new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc); var t2 = new DateTime(2024, 3, 1, 10, 0, 30, DateTimeKind.Utc); var details = new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t1, t2 } }; var extObj = new ExtensionObject(details); var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, out var results, out _); results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); _driver.LastAtTimeRequestedTimes.ShouldNotBeNull(); _driver.LastAtTimeRequestedTimes!.Count.ShouldBe(2); _driver.LastAtTimeRequestedTimes[0].ShouldBe(t1); _driver.LastAtTimeRequestedTimes[1].ShouldBe(t2); } [Fact] public async Task HistoryReadEvents_returns_HistoryEvent_with_BaseEventType_field_list() { using var session = await OpenSessionAsync(); // Events target the driver-root notifier (not a specific variable) which is the // conventional pattern for alarm-history browse. var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); var nodeId = new NodeId("history-driver", nsIndex); // EventFilter must carry at least one SelectClause or the stack rejects it as // BadEventFilterInvalid before our override runs — empty filters are spec-forbidden. // We populate the standard BaseEventType selectors any real client would send; my // override's BuildHistoryEvent ignores the specific clauses and emits the canonical // field list anyway (the richer "respect exact SelectClauses" behavior is on the PR 38 // follow-up list). 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.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time); filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.ReceiveTime); var details = new ReadEventDetails { StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), EndTime = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc), NumValuesPerNode = 10, Filter = filter, }; var extObj = new ExtensionObject(details); var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, out var results, out _); results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); var he = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData); he.Events.Count.ShouldBe(_driver.EventsReturned); he.Events[0].EventFields.Count.ShouldBe(6, "BaseEventType default field layout is 6 entries"); } private async Task OpenSessionAsync() { var cfg = new ApplicationConfiguration { ApplicationName = "OtOpcUaHistoryTestClient", ApplicationUri = "urn:OtOpcUa:HistoryTestClient", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-own"), SubjectName = "CN=OtOpcUaHistoryTestClient", }, 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, "OtOpcUaHistoryTestClientSession", 60000, new UserIdentity(new AnonymousIdentityToken()), null); } /// /// Stub driver that implements so the service dispatch /// can be verified without bringing up a real Galaxy or Historian. Captures the last- /// seen arguments so tests can assert what the service handler forwarded. /// private sealed class HistoryDriver : IDriver, ITagDiscovery, IReadable, IHistoryProvider { public string DriverInstanceId => "history-driver"; public string DriverType => "HistoryStub"; public int RawSamplesReturned => 3; public int FirstRawValue => 100; public int EventsReturned => 2; public HistoryAggregateType? LastProcessedAggregate { get; private set; } public TimeSpan? LastProcessedInterval { get; private set; } public IReadOnlyList? LastAtTimeRequestedTimes { get; private set; } 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) { // Every variable must be Historized for HistoryRead to route — the node-manager's // stack base class checks the bit before dispatching. builder.Variable("raw", "raw", new DriverAttributeInfo("raw.var", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false)); builder.Variable("proc", "proc", new DriverAttributeInfo("proc.var", DriverDataType.Float64, false, null, SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false)); builder.Variable("atTime", "atTime", new DriverAttributeInfo("atTime.var", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false)); return Task.CompletedTask; } public Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { var now = DateTime.UtcNow; IReadOnlyList r = [.. fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now))]; return Task.FromResult(r); } public Task ReadRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) { var samples = new List(); for (var i = 0; i < RawSamplesReturned; i++) { samples.Add(new DataValueSnapshot( Value: FirstRawValue + i, StatusCode: StatusCodes.Good, SourceTimestampUtc: startUtc.AddSeconds(i), ServerTimestampUtc: startUtc.AddSeconds(i))); } return Task.FromResult(new DriverHistoryReadResult(samples, null)); } public Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) { LastProcessedAggregate = aggregate; LastProcessedInterval = interval; return Task.FromResult(new DriverHistoryReadResult( [new DataValueSnapshot(1.0, StatusCodes.Good, startUtc, startUtc)], null)); } public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) { LastAtTimeRequestedTimes = timestampsUtc; var samples = timestampsUtc .Select(t => new DataValueSnapshot(42, StatusCodes.Good, t, t)) .ToArray(); return Task.FromResult(new DriverHistoryReadResult(samples, null)); } public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) { var events = new List(); for (var i = 0; i < EventsReturned; i++) { events.Add(new HistoricalEvent( EventId: $"e{i}", SourceName: sourceName, EventTimeUtc: startUtc.AddHours(i), ReceivedTimeUtc: startUtc.AddHours(i).AddSeconds(1), Message: $"Event {i}", Severity: (ushort)(500 + i))); } return Task.FromResult(new HistoricalEventsResult(events, null)); } } }