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"); } }