6c63bf8fab
Driver.MTConnect-001 (Medium): StreamVerdict.Reopen reset the reconnect ladder
unconditionally. The InvalidDataException catch that routes an Agent OUT_OF_RANGE
to a /current re-baseline also catches an unparseable chunk and an over-sized
multipart part - neither of which a re-baseline can resolve - so the pump reopened
and failed with no delay at any point: two HTTP requests and one Warning per
iteration, forever. The Reopen arm now uses the same discriminator the transient
arm does (did THIS connection deliver a chunk?), so a buffer roll behind a working
stream still reopens instantly while a re-baseline that resolved nothing advances
BackoffFor.
Driver.MTConnect-002 (Medium): MTConnectDriverProbe requested {AgentUri}/probe
while MTConnectAgentClient requests {AgentUri}/{DeviceName}/probe, so a mistyped or
since-renamed deviceName showed GREEN on Test Connect and then Faulted the driver
at InitializeAsync (an unknown device path answers MTConnectError under HTTP 200).
The probe's minimal DTO now reads deviceName - the rule is 'which keys compose the
URL', not 'which keys are cheap to read'.
Driver.MTConnect-008 (Low): corrected two stale claims that a since-changed
RawBrowseCommitMapper invalidated (see Driver.MTConnect-003, which remains open and
belongs in AdminUI), and made DiscoverAsync log the options snapshot it
deliberately read once instead of the field.
Tests: 538 pass (534 base + 4 new, each red before its fix); AdminUI.Tests 1466 pass.
379 lines
16 KiB
C#
379 lines
16 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();
|
|
}
|
|
|
|
// ── review follow-up: the probe must request the URL the DRIVER will request ──
|
|
|
|
/// <summary>
|
|
/// <b>Test Connect has to probe the driver's own request path, device scope included.</b>
|
|
/// <c>MTConnectAgentClient.BuildRequestUri</c> narrows every read to
|
|
/// <c>{AgentUri}/{DeviceName}/probe</c>, so a probe that requested the agent-wide
|
|
/// <c>{AgentUri}/probe</c> answers a different question from the one the operator asked. A
|
|
/// mistyped or since-renamed <c>deviceName</c> then shows GREEN on Test Connect and Faults the
|
|
/// driver at <c>InitializeAsync</c> — the Agent answers that path with an <c>MTConnectError</c>
|
|
/// under HTTP 200, which the parser surfaces as <see cref="InvalidDataException"/>.
|
|
/// <para>
|
|
/// This is a different class from the accepted "reachable ≠ deployable" gap the type
|
|
/// documents (a bad tag entry, a non-positive timeout): those do not change the URL, and
|
|
/// the whole point of a reachability probe is which endpoint it reaches.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Configured_deviceName_scopes_the_probe_request_the_way_the_driver_does()
|
|
{
|
|
Uri? requested = null;
|
|
var probe = ProbeWithHandler((request, _) =>
|
|
{
|
|
requested = request.RequestUri;
|
|
|
|
return Task.FromResult(XmlResponse(ValidProbeXml));
|
|
});
|
|
|
|
var r = await probe.ProbeAsync(
|
|
"{\"agentUri\":\"http://stub-agent/\",\"deviceName\":\"VMC-1\"}", QuickTimeout, CancellationToken.None);
|
|
|
|
r.Ok.ShouldBeTrue();
|
|
requested.ShouldNotBeNull();
|
|
requested.AbsolutePath.ShouldBe("/VMC-1/probe");
|
|
}
|
|
|
|
/// <summary>
|
|
/// …and with no <c>deviceName</c> the probe stays agent-wide, exactly as the driver does. The
|
|
/// falsification control for the test above: unconditionally inserting a segment would pass it
|
|
/// while breaking every unscoped config.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_absent_deviceName_leaves_the_probe_agent_wide()
|
|
{
|
|
Uri? requested = null;
|
|
var probe = ProbeWithHandler((request, _) =>
|
|
{
|
|
requested = request.RequestUri;
|
|
|
|
return Task.FromResult(XmlResponse(ValidProbeXml));
|
|
});
|
|
|
|
var r = await probe.ProbeAsync(
|
|
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
|
|
|
|
r.Ok.ShouldBeTrue();
|
|
requested.ShouldNotBeNull();
|
|
requested.AbsolutePath.ShouldBe("/probe");
|
|
}
|
|
|
|
// ── 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");
|
|
}
|
|
}
|