7 integration tests in Server.Tests were left behind by the path-based NodeId rename (#134). Each was constructing test NodeIds in the old "FullReference" shape ("TestFolder.Var1", "raw.var", "AlphaFolder.Var1", "plcaddr-temperature"), which the node manager no longer mints — the new shape is `{driverId}/{folder-path}/{browseName}` per OPC UA Part 3 §5.2.2 NodeId immutability. Fixed by re-deriving each test NodeId from the actual browse path the test fixture's driver registers: - OpcUaServerIntegrationTests: "TestFolder.Var1" → "fake/TestFolder/Var1" - HistoryReadIntegrationTests (4 tests): "raw.var" → "history-driver/raw", "proc.var" → "history-driver/proc" (×2), "atTime.var" → "history-driver/atTime" - MultipleDriverInstancesIntegrationTests: "AlphaFolder.Var1" → "alpha/AlphaFolder/Var1"; "BetaFolder.Var1" → "beta/BetaFolder/Var1" - OpcUaEquipmentWalkerIntegrationTests: "plcaddr-temperature" → "galaxy-prod/warsaw/line-a/oven-3/Temperature" (the walker uses Tag.Name as the browseName; the FullReference lives in TagConfig but no longer surfaces in the NodeId path) Server.Tests now 277/277 green excluding LiveLdap. Clears the regression flagged during the #124 verification run.
359 lines
18 KiB
C#
359 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// End-to-end test that a real OPC UA client's HistoryRead service reaches a fake driver's
|
|
/// <see cref="IHistoryProvider"/> via <see cref="DriverNodeManager"/>'s
|
|
/// <c>HistoryReadRawModified</c> / <c>HistoryReadProcessed</c> / <c>HistoryReadAtTime</c> /
|
|
/// <c>HistoryReadEvents</c> overrides. Boots the full OPC UA stack + a stub
|
|
/// <see cref="IHistoryProvider"/> driver, opens a client session, issues each HistoryRead
|
|
/// variant, and asserts the client receives the expected per-kind payload.
|
|
/// </summary>
|
|
[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<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 HistoryReadRaw_round_trips_driver_samples_to_the_client()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
|
// Path-based NodeId per #134 — `{driverId}/{browseName}` since DiscoverAsync registers
|
|
// variables at the driver root rather than under a folder.
|
|
var nodeId = new NodeId("history-driver/raw", 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("history-driver/proc", 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("history-driver/proc", 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("history-driver/atTime", 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<ISession> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stub driver that implements <see cref="IHistoryProvider"/> 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.
|
|
/// </summary>
|
|
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<DateTime>? 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<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
IReadOnlyList<DataValueSnapshot> r =
|
|
[.. fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now))];
|
|
return Task.FromResult(r);
|
|
}
|
|
|
|
public Task<DriverHistoryReadResult> ReadRawAsync(
|
|
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var samples = new List<DataValueSnapshot>();
|
|
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<DriverHistoryReadResult> 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<DriverHistoryReadResult> ReadAtTimeAsync(
|
|
string fullReference, IReadOnlyList<DateTime> 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<HistoricalEventsResult> ReadEventsAsync(
|
|
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var events = new List<HistoricalEvent>();
|
|
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));
|
|
}
|
|
}
|
|
}
|