Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs
T
Joseph Doherty c232a3ce67 fix(mtconnect): cap CancelAfter overflow + stop leaking ex.Message in probe fallback (review follow-up)
Two review findings on Task 14's MTConnectDriverProbe, both red-first:

- CancelAfter(effectiveTimeout) sat before the try block with only a low-end
  floor on a non-positive timeout, never a high-end cap. A pathological
  timeout (e.g. TimeSpan.FromDays(1000)) threw an uncaught
  ArgumentOutOfRangeException straight out of CancelAfter's ~49.7-day legal
  range, violating the probe's own "Never throws" contract. Not reachable
  through today's only caller (AdminOperationsActor clamps to [1,60]s and
  wraps the call), but a landmine for any future caller without that clamp.
  Fixed by clamping effectiveTimeout to a new MaxTimeout (10 minutes).

- The final `catch (Exception ex) { return new(false, ex.Message, null); }`
  forwarded ex.Message verbatim for any exception type not enumerated above
  it, breaking the redaction discipline every other catch was audited for
  (AgentUri may carry userinfo credentials). Not exploitable by any
  currently-reachable exception type, but an open door for a future one.
  Fixed to build a message from the already-redacted safeUri + the
  exception's type name instead of its message.

Also: a genuine non-2xx stub-handler test (prior HttpRequestException
coverage was connection-refused only), a credentialed-agentUri +
successful-probe redaction test, and a one-line doc remark on the accepted
reachable-vs-deployable gap.
2026-07-27 13:58:06 -04:00

320 lines
13 KiB
C#

using System.Net;
using System.Net.Http;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 14 — <see cref="MTConnectDriverProbe"/>: the AdminUI Test Connect reachability probe.
/// Response-shape cases (non-MTConnect body, MTConnectError-under-200, a valid device model) are
/// exercised through a stubbed <see cref="HttpMessageHandler"/> 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.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MTConnectDriverProbeTests
{
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
// ── stub handler ─────────────────────────────────────────────────────────
/// <summary>Answers every request with a canned response (or throws) via a delegate, with no socket.</summary>
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond) =>
new(new StubHandler(respond));
private const string ValidProbeXml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:1.3">
<Header creationTime="2026-07-24T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
<Devices>
<Device id="dev1" name="StubDevice">
<DataItems>
<DataItem id="dev1_avail" category="EVENT" type="AVAILABILITY"/>
</DataItems>
</Device>
</Devices>
</MTConnectDevices>
""";
private const string MTConnectErrorXml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Header creationTime="2026-07-24T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
<Errors>
<Error errorCode="NO_DEVICE">Could not find the device named 'nope'.</Error>
</Errors>
</MTConnectError>
""";
private const string NotMTConnectXml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<Foo><Bar/></Foo>
""";
// ── 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();
}
/// <summary>Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.</summary>
private sealed class UnreachableException : Exception;
// ── review follow-up: pathological (out-of-CancelAfter-range) timeout ────
/// <summary>
/// Reproduces the reviewer's finding: <c>CancelAfter</c>'s legal range tops out near ~49.7
/// days, and the caller-supplied <c>timeout</c> reached it uncapped (only floored on the low
/// end). A caller with no clamp of its own — unlike today's only caller,
/// <c>AdminOperationsActor</c>, which clamps to [1,60]s — must still get a
/// <see cref="Core.Abstractions.DriverProbeResult"/>, never an escaping
/// <see cref="ArgumentOutOfRangeException"/>: that is the whole point of the "Never throws"
/// contract.
/// </summary>
[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 ──
/// <summary>Message text crafted to look like it embedded a credentialed URI, exactly what an unaudited <c>ex.Message</c> could leak.</summary>
private sealed class LeakyException()
: Exception("dial failed for http://leaky-user:leaky-pass@internal-host/probe (boom)");
/// <summary>
/// Reproduces the reviewer's finding: the final <c>catch (Exception ex)</c> forwarded
/// <c>ex.Message</c> 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.
/// </summary>
[Fact]
public async Task Unenumerated_exception_type_does_not_leak_ex_Message_via_final_catch()
{
var probe = ProbeWithHandler((_, _) => Task.FromException<HttpResponseMessage>(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");
}
}