feat(probe): Galaxy Test-Connect does a gRPC ping (auth-rejection counts as reachable)
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user