feat(probe): Galaxy Test-Connect does a gRPC ping (auth-rejection counts as reachable)

This commit is contained in:
Joseph Doherty
2026-06-16 06:48:40 -04:00
parent b663ae6eff
commit 2d688c2a6d
2 changed files with 286 additions and 9 deletions
@@ -0,0 +1,163 @@
using System.Net;
using System.Net.Sockets;
using Grpc.Core;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Unit tests for <see cref="GalaxyDriverProbe"/>. The probe does a TCP preflight followed by
/// a lightweight gRPC ping (the read-only <c>GalaxyRepository.TestConnection</c> RPC) against
/// the mxaccessgw, classifying the resulting <see cref="StatusCode"/>. An auth rejection
/// (<see cref="StatusCode.Unauthenticated"/> / <see cref="StatusCode.PermissionDenied"/>)
/// counts as reachable because it proves a live gRPC server answered. The
/// <see cref="GalaxyDriverProbe.ClassifyRpc"/> mapping is unit-tested directly here without a
/// live gateway; the happy <c>OK</c> + auth-rejected paths are verified live against the real
/// gateway (10.100.0.48:5120).
/// </summary>
[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 ──────────────────────────────────────────────────────────
/// <summary>Invalid JSON returns Ok=false with a message containing "invalid".</summary>
[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 ───────────────────────────────────────────────
/// <summary>
/// Config JSON whose Gateway.Endpoint is empty returns Ok=false with a message
/// indicating no host/port was found.
/// </summary>
[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 ────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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) ───────────────────────────
/// <summary>A gRPC <c>OK</c> status maps to a reachable, gateway-confirmed result.</summary>
[Fact]
public void ClassifyRpc_Ok_is_reachable()
{
var (ok, message) = GalaxyDriverProbe.ClassifyRpc(StatusCode.OK, "host", 5120);
ok.ShouldBeTrue();
message.ShouldBe("gateway gRPC OK");
}
/// <summary>
/// A gRPC <c>Unauthenticated</c> status counts as reachable — an auth rejection proves a
/// live gateway gRPC server answered.
/// </summary>
[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);
}
/// <summary>A gRPC <c>PermissionDenied</c> status also counts as reachable.</summary>
[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);
}
/// <summary>
/// A gRPC <c>Unavailable</c> status is a transport failure — Ok=false with a message that
/// names the host, port and the failing status code.
/// </summary>
[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");
}
/// <summary>A gRPC <c>DeadlineExceeded</c> status is likewise a handshake failure.</summary>
[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");
}
}