using System.Net; using System.Net.Sockets; using Grpc.Core; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; /// /// Unit tests for . The probe does a TCP preflight followed by /// a lightweight gRPC ping (the read-only GalaxyRepository.TestConnection RPC) against /// the mxaccessgw, classifying the resulting . An auth rejection /// ( / ) /// counts as reachable because it proves a live gRPC server answered. The /// mapping is unit-tested directly here without a /// live gateway; the happy OK + auth-rejected paths are verified live against the real /// gateway (10.100.0.48:5120). /// [Trait("Category", "Unit")] public sealed class GalaxyDriverProbeTests { private readonly GalaxyDriverProbe _probe = new(); // A complete-enough GalaxyDriverOptions JSON skeleton; the {0} placeholder is the // gateway endpoint. All other nested records carry their required positional members. private static string ConfigJson(string endpoint, bool useTls = false) => $$""" { "Gateway": { "Endpoint": "{{endpoint}}", "ApiKeySecretRef": "env:GALAXY_MXGW_API_KEY", "UseTls": {{(useTls ? "true" : "false")}} }, "MxAccess": { "ClientName": "probe-test" }, "Repository": {}, "Reconnect": {} } """; // ── 1. Invalid JSON ────────────────────────────────────────────────────────── /// Invalid JSON returns Ok=false with a message containing "invalid". [Fact] public async Task InvalidJson_returns_false_with_invalid_message() { var result = await _probe.ProbeAsync( "not-json", TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("invalid", Case.Insensitive); result.Latency.ShouldBeNull(); } // ── 2. Config with no endpoint ─────────────────────────────────────────────── /// /// Config JSON whose Gateway.Endpoint is empty returns Ok=false with a message /// indicating no host/port was found. /// [Fact] public async Task NoEndpoint_returns_false_with_no_host_port_message() { var result = await _probe.ProbeAsync( ConfigJson(endpoint: ""), TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("no host", Case.Insensitive); result.Latency.ShouldBeNull(); } // ── 3. Unreachable closed port ──────────────────────────────────────────────── /// /// Pointing at a TCP port that is not open returns Ok=false. Depending on how far the /// probe gets, the message is the TCP "Connect failed", a gRPC "handshake failed", or a /// "timed out" message — all of which are acceptable failure signals for an unreachable /// gateway. /// [Fact] public async Task ClosedPort_returns_false() { // Bind a port, capture it, then release it so nothing is listening when we probe. var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); var endpoint = $"http://127.0.0.1:{port}"; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var result = await _probe.ProbeAsync( ConfigJson(endpoint), TimeSpan.FromSeconds(3), cts.Token); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldSatisfyAllConditions( () => (result.Message.Contains("Connect failed", StringComparison.OrdinalIgnoreCase) || result.Message.Contains("handshake failed", StringComparison.OrdinalIgnoreCase) || result.Message.Contains("timed out", StringComparison.OrdinalIgnoreCase)) .ShouldBeTrue($"unexpected message: '{result.Message}'")); } // ── 4. ClassifyRpc mapping (pure, no live gateway) ─────────────────────────── /// A gRPC OK status maps to a reachable, gateway-confirmed result. [Fact] public void ClassifyRpc_Ok_is_reachable() { var (ok, message) = GalaxyDriverProbe.ClassifyRpc(StatusCode.OK, "host", 5120); ok.ShouldBeTrue(); message.ShouldBe("gateway gRPC OK"); } /// /// A gRPC Unauthenticated status counts as reachable — an auth rejection proves a /// live gateway gRPC server answered. /// [Fact] public void ClassifyRpc_Unauthenticated_is_reachable_auth_not_checked() { var (ok, message) = GalaxyDriverProbe.ClassifyRpc(StatusCode.Unauthenticated, "host", 5120); ok.ShouldBeTrue(); message.ShouldContain("auth not checked", Case.Insensitive); } /// A gRPC PermissionDenied status also counts as reachable. [Fact] public void ClassifyRpc_PermissionDenied_is_reachable_auth_not_checked() { var (ok, message) = GalaxyDriverProbe.ClassifyRpc(StatusCode.PermissionDenied, "host", 5120); ok.ShouldBeTrue(); message.ShouldContain("auth not checked", Case.Insensitive); } /// /// A gRPC Unavailable status is a transport failure — Ok=false with a message that /// names the host, port and the failing status code. /// [Fact] public void ClassifyRpc_Unavailable_is_not_reachable() { var (ok, message) = GalaxyDriverProbe.ClassifyRpc(StatusCode.Unavailable, "host", 5120); ok.ShouldBeFalse(); message.ShouldContain("Unavailable"); message.ShouldContain("host:5120"); } /// A gRPC DeadlineExceeded status is likewise a handshake failure. [Fact] public void ClassifyRpc_DeadlineExceeded_is_not_reachable() { var (ok, message) = GalaxyDriverProbe.ClassifyRpc(StatusCode.DeadlineExceeded, "host", 5120); ok.ShouldBeFalse(); message.ShouldContain("DeadlineExceeded"); message.ShouldContain("host:5120"); } }