using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// Local loopback evidence that late binding replaces the SDK default on the wire. public sealed class ServiceLevelBindingReplayTests { // Since #885 the server starts at 0, so the 0 case no longer distinguishes "replayed" from "not replayed" — it pins // that a requested 0 still reads 0. The 100 case is the replay evidence. [Theory] [InlineData((byte)100)] [InlineData((byte)0)] public async Task Late_binding_replays_latest_request_to_standard_ServiceLevel_read(byte latest) { using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); deadline.CancelAfter(TimeSpan.FromSeconds(15)); var ct = deadline.Token; var pki = Path.Combine(Path.GetTempPath(), $"otopcua-replay-{Guid.NewGuid():N}"); var clientPki = TestClientSecurity.AllocatePkiRoot(); var port = 0; try { var deferred = new DeferredServiceLevelPublisher(); deferred.Publish(240); deferred.Publish(latest); var started = await OpcUaHostStart.StartAsync(p => { port = p; return new OpcUaApplicationHost(new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.Replay", ApplicationUri = $"urn:OtOpcUa.Replay:{Path.GetFileName(pki)}", OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki, }, NullLogger.Instance); }, () => new OtOpcUaSdkServer(), ct); await using var host = started.Host; deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance, NullLogger.Instance)); var config = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { })) { ApplicationName = "OtOpcUa.ReplayClient", ApplicationUri = $"urn:OtOpcUa.ReplayClient:{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, SecurityConfiguration = TestClientSecurity.Build(clientPki), ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10_000 }, TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 }, }; await config.ValidateAsync(ApplicationType.Client, ct); var endpoint = await CoreClientUtils.SelectEndpointAsync(config, $"opc.tcp://localhost:{port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct); using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( config, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(config)), updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelReplay", sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct); var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct); read.StatusCode.ShouldBe(StatusCodes.Good); read.ClrValue()!.ShouldBe(latest); } finally { if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true); if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true); } } /// /// lmxopcua#801: SDK 2.0 recomputes Server.ServiceLevel from session headroom on every session create/close /// and overwrites any value >= 200 unless a provider has claimed it. On the docker-dev pair that turned the /// Primary's 250 and the Secondary's 240 into 255 on both nodes as soon as a client connected. A level published /// BEFORE a session opens and one published while a session is open must both survive later sessions opening /// and closing. /// [Theory] [InlineData((byte)250)] [InlineData((byte)240)] [InlineData((byte)200)] public async Task Published_level_survives_later_sessions(byte level) { using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); deadline.CancelAfter(TimeSpan.FromSeconds(30)); var ct = deadline.Token; var pki = Path.Combine(Path.GetTempPath(), $"otopcua-claim-{Guid.NewGuid():N}"); var clientPki = TestClientSecurity.AllocatePkiRoot(); var port = 0; try { var started = await OpcUaHostStart.StartAsync(p => { port = p; return new OpcUaApplicationHost(new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.Claim", ApplicationUri = $"urn:OtOpcUa.Claim:{Path.GetFileName(pki)}", OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki, }, NullLogger.Instance); }, () => new OtOpcUaSdkServer(), ct); await using var host = started.Host; var publisher = new SdkServiceLevelPublisher(started.Server.CurrentInstance, NullLogger.Instance); publisher.Publish(level); var url = $"opc.tcp://localhost:{port}/OtOpcUa"; // SessionCreated: the first session opening. using (var first = await OpenAsync(url, clientPki, ct)) { (await ReadServiceLevelAsync(first, ct)).ShouldBe(level, "the first session opening must not displace the published level"); // An explicit CloseSession so the server raises SessionClosing (Dispose alone may leave the session to time out). await first.CloseAsync(5_000, true, ct); } using var second = await OpenAsync(url, clientPki, ct); (await ReadServiceLevelAsync(second, ct)).ShouldBe(level, "a session closing then another opening must not displace it"); // A level published while a session is open (230 keeps every case inside the SDK's >= 200 overwrite band) // must survive another session opening AND, read on the surviving session, that session closing on its own. const byte later = 230; publisher.Publish(later); using (var third = await OpenAsync(url, clientPki, ct)) { (await ReadServiceLevelAsync(third, ct)).ShouldBe(later, "a level published while a session is open must survive the next one"); await third.CloseAsync(5_000, true, ct); } (await ReadServiceLevelAsync(second, ct)).ShouldBe(later, "a session closing on its own must not displace it"); } finally { if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true); if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true); } } /// /// lmxopcua#885: a node that has not yet received its first redundancy snapshot has requested no level. The SDK starts /// Server.ServiceLevel at 255, so a restarting pair node advertised full service, out-ranking the real Primary /// (250), for the ~10 s it took to join. It must read (0) from its /// first session, through the bind with nothing to replay and across session churn, and still settle to the level /// the redundancy layer publishes once it has one (a single-node deployment's 250). /// [Fact] public async Task Node_with_no_requested_level_advertises_the_startup_level_until_one_is_published() { using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); deadline.CancelAfter(TimeSpan.FromSeconds(30)); var ct = deadline.Token; var pki = Path.Combine(Path.GetTempPath(), $"otopcua-startup-{Guid.NewGuid():N}"); var clientPki = TestClientSecurity.AllocatePkiRoot(); var port = 0; try { var started = await OpcUaHostStart.StartAsync(p => { port = p; return new OpcUaApplicationHost(new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.Startup", ApplicationUri = $"urn:OtOpcUa.Startup:{Path.GetFileName(pki)}", OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki, }, NullLogger.Instance); }, () => new OtOpcUaSdkServer(), ct); await using var host = started.Host; var url = $"opc.tcp://localhost:{port}/OtOpcUa"; // Before the host binds a publisher: the SDK-start window. using (var early = await OpenAsync(url, clientPki, ct)) { (await ReadServiceLevelAsync(early, ct)).ShouldBe(OtOpcUaSdkServer.StartupServiceLevel, "an endpoint that has not been told its redundancy level must not advertise the SDK's 255"); await early.CloseAsync(5_000, true, ct); } // The bind with nothing to replay (no snapshot yet), then session churn under the claim. var deferred = new DeferredServiceLevelPublisher(); deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance, NullLogger.Instance)); using var session = await OpenAsync(url, clientPki, ct); (await ReadServiceLevelAsync(session, ct)).ShouldBe(OtOpcUaSdkServer.StartupServiceLevel, "binding with no requested level must leave the startup level standing"); // The first snapshot arrives: a single-node deployment settles to its steady Primary value. deferred.Publish(250); using (var later = await OpenAsync(url, clientPki, ct)) await later.CloseAsync(5_000, true, ct); (await ReadServiceLevelAsync(session, ct)).ShouldBe((byte)250, "the published level must replace the startup level"); } finally { if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true); if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true); } } private static async Task OpenAsync(string url, string clientPki, CancellationToken ct) { var config = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { })) { ApplicationName = "OtOpcUa.ClaimClient", ApplicationUri = $"urn:OtOpcUa.ClaimClient:{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, SecurityConfiguration = TestClientSecurity.Build(clientPki), ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10_000 }, TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 }, }; await config.ValidateAsync(ApplicationType.Client, ct); var endpoint = await CoreClientUtils.SelectEndpointAsync(config, url, false, DefaultTelemetry.Create(_ => { }), ct); return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( config, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(config)), updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelClaim", sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct); } private static async Task ReadServiceLevelAsync(Opc.Ua.Client.ISession session, CancellationToken ct) { var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct); read.StatusCode.ShouldBe(StatusCodes.Good); return (byte)read.ClrValue()!; } }