using System.Net; using System.Net.Sockets; using Akka.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Host.Health; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; /// /// R03:2.1 / 1.4 — a failed OPC UA SDK start used to be one Error line: the node stayed Ready, joined /// its mesh, and acked deploys through the Null sink while clients got connection-refused. The start /// outcome is now a fact () the ready/active tiers read, a driver-only /// node rethrows so the process exits, and StopAsync actually stops the SDK. /// public sealed class OtOpcUaServerHostedServiceStartFailureTests { private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(30); /// /// Port already bound ⇒ the SDK cannot listen ⇒ the state is Failed, the probe is Unhealthy and the /// real /health/ready endpoint answers 503 — while StartAsync itself returns (fused node). /// Positive control: the same endpoint answers 200 once the state is Started. /// [Fact] public async Task Port_in_use_marks_the_endpoint_failed_and_health_ready_answers_503() { using var deadline = new CancellationTokenSource(Deadline); using var squatter = SquatAllInterfaces(out var port); var pki = NewPki(); var state = new OpcUaEndpointState(); var fused = EffectiveRoles.Resolve(new[] { "admin", "driver" }, envRoles: null); var service = NewService(port, pki, state, fused); try { await service.StartAsync(deadline.Token); // must NOT throw on a fused node state.Status.ShouldBe(OpcUaEndpointStatus.Failed); state.FailureReason.ShouldNotBeNullOrWhiteSpace(); var probe = await new OpcUaEndpointHealthCheck(state).CheckHealthAsync(new HealthCheckContext(), TestContext.Current.CancellationToken); probe.Status.ShouldBe(HealthStatus.Unhealthy); probe.Description.ShouldNotBeNull(); probe.Description.ShouldContain("failed to start"); await using var web = BuildHealthHost(state); await web.StartAsync(deadline.Token); using var http = new HttpClient { BaseAddress = new Uri(web.Urls.First()) }; (await http.GetAsync("/health/ready", deadline.Token)).StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable); (await http.GetAsync("/health/active", deadline.Token)).StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable); // Positive control on the endpoint: the same host answers 200 for a started endpoint, so the // 503 above measured the probe and not a broken health pipeline. state.MarkStarted(); (await http.GetAsync("/health/ready", deadline.Token)).StatusCode.ShouldBe(HttpStatusCode.OK); await web.StopAsync(deadline.Token); } finally { await service.DisposeAsync(); squatter.Stop(); Cleanup(pki); } } /// A driver-only node has nothing else to stay up for: the failed start propagates. [Fact] public async Task Driver_only_node_rethrows_when_the_sdk_fails_to_start() { using var deadline = new CancellationTokenSource(Deadline); using var squatter = SquatAllInterfaces(out var port); var pki = NewPki(); var state = new OpcUaEndpointState(); var driverOnly = EffectiveRoles.Resolve(new[] { "driver" }, envRoles: null); var service = NewService(port, pki, state, driverOnly); try { await Should.ThrowAsync(() => service.StartAsync(deadline.Token)); state.Status.ShouldBe(OpcUaEndpointStatus.Failed); } finally { await service.DisposeAsync(); squatter.Stop(); Cleanup(pki); } } /// /// A clean start is Started and listening; StopAsync closes the port (connection refused, not /// a stale-but-listening endpoint) and marks Stopped; DisposeAsync twice afterwards is a no-op. /// [Fact] public async Task StopAsync_closes_the_port_and_DisposeAsync_is_idempotent() { using var deadline = new CancellationTokenSource(Deadline); var port = FreePort(); var pki = NewPki(); var state = new OpcUaEndpointState(); var service = NewService(port, pki, state, EffectiveRoles.Resolve(new[] { "driver" }, envRoles: null)); try { await service.StartAsync(deadline.Token); state.Status.ShouldBe(OpcUaEndpointStatus.Started); using (var client = new TcpClient()) { await client.ConnectAsync(IPAddress.Loopback, port, deadline.Token); client.Connected.ShouldBeTrue("positive control: the endpoint is listening after StartAsync"); } await service.StopAsync(deadline.Token); state.Status.ShouldBe(OpcUaEndpointStatus.Stopped); using (var client = new TcpClient()) { await Should.ThrowAsync(async () => await client.ConnectAsync(IPAddress.Loopback, port, deadline.Token)); } await service.DisposeAsync(); await service.DisposeAsync(); // idempotent } finally { await service.DisposeAsync(); Cleanup(pki); } } private static OtOpcUaServerHostedService NewService(int port, string pki, OpcUaEndpointState state, EffectiveRoles roles) => new( Options.Create(new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.StartFailure", ApplicationUri = $"urn:OtOpcUa.StartFailure:{Guid.NewGuid():N}", OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = pki, }), new DeferredAddressSpaceSink(), new DeferredServiceLevelPublisher(), NullOpcUaUserAuthenticator.Instance, NullHistorianDataSource.Instance, EmptyDataPlaneAuthoritySource.Instance, new ConfigurationBuilder().Build(), () => throw new NotSupportedException("no actor system required"), new ActorRegistry(), null, Options.Create(new AclOptions()), TimeProvider.System, NullLoggerFactory.Instance, endpointState: state, roles: roles); /// The production health registration (driver role) over the supplied state, on a loopback Kestrel. private static WebApplication BuildHealthHost(OpcUaEndpointState state) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Logging.ClearProviders(); builder.Services.AddSingleton(state); builder.Services.AddOtOpcUaHealth(hasAdmin: false, hasDriver: true); // The akka + cluster-primary probes need a running ActorSystem; keep only the probe under test so // the status measured is the endpoint's. (The registration shape itself is pinned separately.) builder.Services.Configure(o => { foreach (var r in o.Registrations.Where(r => r.Name != OpcUaEndpointHealthCheck.Name).ToList()) o.Registrations.Remove(r); }); var app = builder.Build(); app.MapOtOpcUaHealth(); return app; } /// /// Holds a port on the IPv4 AND IPv6 wildcard addresses. The SDK binds its listener on the /// wildcards, and on macOS a loopback-only squatter does not conflict with a wildcard bind (both /// sockets carry SO_REUSEADDR), so a loopback squatter would let the SDK start — the first run of /// this test proved exactly that. The SDK also only fails when NEITHER family binds. /// private static TcpListener SquatAllInterfaces(out int port) { var squatter = new TcpListener(IPAddress.IPv6Any, 0); squatter.Server.DualMode = true; squatter.ExclusiveAddressUse = true; squatter.Start(); port = ((IPEndPoint)squatter.LocalEndpoint).Port; return squatter; } private static int FreePort() { using var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); var port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return port; } private static string NewPki() => Path.Combine(Path.GetTempPath(), $"otopcua-startfail-{Guid.NewGuid():N}"); private static void Cleanup(string pki) { if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true); } }