Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceStartFailureTests.cs
T
Joseph Doherty b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
test: migrate AdminUI/ControlPlane/Runtime/Host tests to xunit.v3 (#756)
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via
VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask,
SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass
TestContext.Current.CancellationToken. Test counts unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 08:57:33 -05:00

214 lines
9.3 KiB
C#

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;
/// <summary>
/// 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 (<see cref="IOpcUaEndpointState"/>) the ready/active tiers read, a driver-only
/// node rethrows so the process exits, and <c>StopAsync</c> actually stops the SDK.
/// </summary>
public sealed class OtOpcUaServerHostedServiceStartFailureTests
{
private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(30);
/// <summary>
/// Port already bound ⇒ the SDK cannot listen ⇒ the state is Failed, the probe is Unhealthy and the
/// real <c>/health/ready</c> endpoint answers 503 — while StartAsync itself returns (fused node).
/// Positive control: the same endpoint answers 200 once the state is Started.
/// </summary>
[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);
}
}
/// <summary>A driver-only node has nothing else to stay up for: the failed start propagates.</summary>
[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<Exception>(() => service.StartAsync(deadline.Token));
state.Status.ShouldBe(OpcUaEndpointStatus.Failed);
}
finally
{
await service.DisposeAsync();
squatter.Stop();
Cleanup(pki);
}
}
/// <summary>
/// A clean start is Started and listening; <c>StopAsync</c> closes the port (connection refused, not
/// a stale-but-listening endpoint) and marks Stopped; <c>DisposeAsync</c> twice afterwards is a no-op.
/// </summary>
[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<SocketException>(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);
/// <summary>The production health registration (driver role) over the supplied state, on a loopback Kestrel.</summary>
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<IOpcUaEndpointState>(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<HealthCheckServiceOptions>(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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}