using System.Net;
using System.Net.Http;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
///
/// Task 14 — : the AdminUI Test Connect reachability probe.
/// Response-shape cases (non-MTConnect body, MTConnectError-under-200, a valid device model) are
/// exercised through a stubbed via the internal test-seam
/// constructor, so no socket is needed; the unreachable-agent and non-positive-timeout cases use
/// a real (deliberately unroutable/refused) TCP target, mirroring the plan's canned example.
///
[Trait("Category", "Unit")]
public sealed class MTConnectDriverProbeTests
{
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
// ── stub handler ─────────────────────────────────────────────────────────
/// Answers every request with a canned response (or throws) via a delegate, with no socket.
private sealed class StubHandler(Func> respond)
: HttpMessageHandler
{
protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) =>
respond(request, ct);
}
private static HttpResponseMessage XmlResponse(string xml) => new(HttpStatusCode.OK)
{
Content = new StringContent(xml, System.Text.Encoding.UTF8, "application/xml"),
};
private static MTConnectDriverProbe ProbeWithHandler(
Func> respond) =>
new(new StubHandler(respond));
private const string ValidProbeXml =
"""
""";
private const string MTConnectErrorXml =
"""
Could not find the device named 'nope'.
""";
private const string NotMTConnectXml =
"""
""";
// ── from the plan (verbatim) ─────────────────────────────────────────────
[Fact]
public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
[Fact]
public async Task Probe_on_blank_config_returns_not_ok()
=> (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();
// ── unparseable JSON ──────────────────────────────────────────────────────
[Fact]
public async Task Unparseable_json_returns_not_ok_and_does_not_throw()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldContain("invalid", Case.Insensitive);
}
// ── malformed / relative URI ─────────────────────────────────────────────
[Theory]
[InlineData("not a uri")]
[InlineData("/relative/path")]
[InlineData("ftp://host/probe")]
public async Task Malformed_or_non_http_agentUri_returns_not_ok(string agentUri)
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync($"{{\"agentUri\":\"{agentUri}\"}}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
// ── credential redaction ─────────────────────────────────────────────────
[Fact]
public async Task AgentUri_credentials_never_appear_in_the_message()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://sneaky-user:sneaky-pass@127.0.0.1:1/\"}",
TimeSpan.FromMilliseconds(300),
CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldNotContain("sneaky-pass");
r.Message.ShouldNotContain("sneaky-user:sneaky-pass");
}
// ── 200 body that is not MTConnect at all ────────────────────────────────
[Fact]
public async Task NonMTConnect_200_body_returns_not_ok()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(NotMTConnectXml)));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Latency.ShouldBeNull();
}
// ── MTConnectError under HTTP 200 ────────────────────────────────────────
[Fact]
public async Task MTConnectError_under_200_returns_not_ok_with_agents_own_message()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(MTConnectErrorXml)));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldContain("NO_DEVICE");
r.Message.ShouldContain("Could not find the device named 'nope'.");
}
// ── valid MTConnectDevices body ───────────────────────────────────────────
[Fact]
public async Task Valid_probe_document_returns_ok_true_with_latency()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeTrue();
r.Message.ShouldNotBeNull();
r.Latency.ShouldNotBeNull();
r.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
r.Latency!.Value.ShouldBeLessThan(QuickTimeout);
}
// ── timeout bounded (hard hang-risk guard) ───────────────────────────────
[Fact(Timeout = 5000)]
public async Task Frozen_peer_times_out_within_the_requested_deadline_not_hang()
{
var probe = ProbeWithHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
throw new UnreachableException();
});
var sw = System.Diagnostics.Stopwatch.StartNew();
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.FromMilliseconds(300), CancellationToken.None);
sw.Stop();
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldContain("timed out", Case.Insensitive);
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(4));
}
// ── non-positive timeout does not mean "wait forever" ────────────────────
[Fact(Timeout = 8000)]
public async Task Non_positive_timeout_falls_back_to_a_bounded_default_not_infinite()
{
var probe = ProbeWithHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
throw new UnreachableException();
});
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.Zero, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
[Fact]
public async Task Negative_timeout_on_unreachable_agent_still_returns_promptly()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromSeconds(-1), CancellationToken.None);
r.Ok.ShouldBeFalse();
}
/// Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.
private sealed class UnreachableException : Exception;
// ── review follow-up: pathological (out-of-CancelAfter-range) timeout ────
///
/// Reproduces the reviewer's finding: CancelAfter's legal range tops out near ~49.7
/// days, and the caller-supplied timeout reached it uncapped (only floored on the low
/// end). A caller with no clamp of its own — unlike today's only caller,
/// AdminOperationsActor, which clamps to [1,60]s — must still get a
/// , never an escaping
/// : that is the whole point of the "Never throws"
/// contract.
///
[Fact]
public async Task Pathologically_large_timeout_does_not_throw()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromDays(1000), CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
// ── review follow-up: unenumerated exception type must not leak ex.Message ──
/// Message text crafted to look like it embedded a credentialed URI, exactly what an unaudited ex.Message could leak.
private sealed class LeakyException()
: Exception("dial failed for http://leaky-user:leaky-pass@internal-host/probe (boom)");
///
/// Reproduces the reviewer's finding: the final catch (Exception ex) forwarded
/// ex.Message verbatim for any type not enumerated above it, breaking the redaction
/// discipline every other catch was audited for. No currently-reachable exception type
/// exploits this, but nothing stopped a future one from doing so either — this test closes
/// that open door by asserting the final catch never echoes exception text.
///
[Fact]
public async Task Unenumerated_exception_type_does_not_leak_ex_Message_via_final_catch()
{
var probe = ProbeWithHandler((_, _) => Task.FromException(new LeakyException()));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Message.ShouldNotContain("leaky-pass");
r.Message.ShouldNotContain("leaky-user:leaky-pass");
}
// ── minor: genuine non-2xx status (not just connection-refused) ──────────
[Fact]
public async Task NonSuccess_status_code_returns_not_ok()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent(string.Empty),
}));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
r.Latency.ShouldBeNull();
}
// ── minor: credentialed agentUri + a SUCCESSFUL response must still redact ──
[Fact]
public async Task Credentialed_agentUri_is_redacted_even_on_a_successful_probe()
{
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-user:stub-pass@stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeTrue();
r.Message.ShouldNotBeNull();
r.Message.ShouldNotContain("stub-pass");
r.Message.ShouldNotContain("stub-user:stub-pass");
}
}