From 23cd47d54b4f01b0e2499406589dada958d3d259 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:25:27 -0400 Subject: [PATCH] feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14) --- .../MTConnectDriverProbe.cs | 200 +++++++++++++++ .../MTConnectDriverProbeTests.cs | 235 ++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs new file mode 100644 index 00000000..a6afb152 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs @@ -0,0 +1,200 @@ +using System.Diagnostics; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; + +/// +/// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a +/// single GET {AgentUri}/probe under the supplied deadline and reports success/failure +/// with latency — it does not construct or initialize an instance. +/// +/// +/// +/// Deliberately does NOT delegate to . That +/// method builds the full driver options document, including every authored +/// MTConnectTagDefinition (BuildTag can throw on a malformed tag entry that has +/// nothing to do with reachability). A probe only needs agentUri, so this type parses +/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can +/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for +/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape +/// (MTConnectDriverFactoryExtensions), which is authored separately (Task 15). +/// +/// +/// Deliberately DOES reuse and +/// for the actual round trip. Hand-rolling the HTTP +/// call would have to re-derive the exact behaviour the client already provides: per-call +/// bounding, a linked-CTS deadline, and — most importantly — +/// correct handling of an Agent's MTConnectError document served under HTTP 200 (so +/// EnsureSuccessStatusCode never fires). already +/// lifts the Agent's own error text into the thrown exception's message; re-parsing by hand +/// here would either duplicate that logic or under-validate (accepting any 200 response as +/// healthy, which is precisely the "empty-but-successful" defect class this codebase has hit +/// before, #485). The one-off cost of a full probe-document parse is negligible next to that +/// risk. +/// +/// +/// Never throws (the contract): every failure — unparseable +/// JSON, missing/malformed agentUri, DNS/TCP failure, a non-2xx response, a 200 body +/// that is not a valid MTConnectDevices document, and timeout — becomes +/// Ok = false with a message. Genuine caller cancellation is likewise converted rather +/// than left to propagate, matching ModbusDriverProbe's posture (its linked-CTS catch +/// does not distinguish the caller's token from its own deadline). +/// +/// +/// Non-positive timeout (arch-review 01/S-6). A 0 or negative +/// timeout does NOT mean "wait forever" — it is replaced with +/// (5s, matching 's own default) so an +/// operator-authorable field can never brick the probe UI. +/// +/// +/// Secrets discipline. AgentUri may carry userinfo credentials +/// (http://user:pass@host/). Every message this type constructs from the URI uses a +/// redacted display form () built ONLY from +/// /// +/// — never and never the raw config string — so a password can +/// never reach the AdminUI via the returned . +/// +/// +public sealed class MTConnectDriverProbe : IDriverProbe +{ + /// Replaces a non-positive caller timeout — see the type remarks. + private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, + }; + + /// Test seam: routes the reused through a stub handler so response-shape cases need no socket. + private readonly HttpMessageHandler? _handler; + + /// Creates the probe used in production (real sockets). + public MTConnectDriverProbe() + : this(handler: null) + { + } + + /// Test seam constructor — see . + internal MTConnectDriverProbe(HttpMessageHandler? handler) + { + _handler = handler; + } + + /// + public string DriverType => "MTConnect"; + + /// Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks. + private sealed class MTConnectProbeConfigDto + { + public string? AgentUri { get; set; } + } + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + MTConnectProbeConfigDto? dto; + try + { + dto = JsonSerializer.Deserialize(configJson, JsonOptions); + } + catch (Exception ex) + { + return new(false, $"Config JSON is invalid: {ex.Message}", null); + } + + if (dto is null) + { + return new(false, "Config JSON deserialized to null.", null); + } + + var agentUri = dto.AgentUri?.Trim(); + if (string.IsNullOrWhiteSpace(agentUri)) + { + return new(false, "Config has no agentUri to probe.", null); + } + + if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) || + (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) + { + return new(false, "Config's agentUri is not an absolute http(s) URI.", null); + } + + var safeUri = RedactedDisplay(parsed); + + // arch-review 01/S-6: a non-positive timeout must never mean "wait forever". + var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout; + var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds)); + + var sw = Stopwatch.StartNew(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(effectiveTimeout); + + var options = new MTConnectDriverOptions + { + AgentUri = agentUri, + RequestTimeoutMs = requestTimeoutMs, + }; + + try + { + using var client = new MTConnectAgentClient(options, _handler); + _ = await client.ProbeAsync(cts.Token).ConfigureAwait(false); + sw.Stop(); + + return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed); + } + catch (OperationCanceledException) + { + // Covers both the caller's token and our own CancelAfter deadline firing — see the type + // remarks on why cancellation is never allowed to propagate. + return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null); + } + catch (TimeoutException) + { + // MTConnectAgentClient's own request-deadline signal. + return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null); + } + catch (HttpRequestException ex) + { + // Connection failure or non-2xx status. HttpRequestException messages describe the + // socket endpoint or status code, never the request URI/userinfo, so ex.Message is safe. + return new(false, $"Request to {safeUri} failed: {ex.Message}", null); + } + catch (InvalidDataException ex) + { + // MTConnectProbeParser: not well-formed XML, not an MTConnectDevices document (including + // an MTConnectError-under-200, whose own errorCode/text ex.Message already carries), or + // a document with no devices. Never includes the request URI. + return new(false, $"Agent at {safeUri} did not return a valid MTConnect device model: {ex.Message}", null); + } + catch (ArgumentException) + { + // Defensive: MTConnectAgentClient's own AgentUri validation, reached only if this + // method's own Uri.TryCreate above passed but the client's stricter check disagreed. + // Never forward the exception's message verbatim — it can echo the raw (possibly + // credentialed) config string. + return new(false, "Config's agentUri was rejected by the MTConnect client as invalid.", null); + } + catch (Exception ex) + { + return new(false, ex.Message, null); + } + } + + /// + /// Builds a display form of for use in returned messages, composed + /// ONLY from scheme/host/port/path — never — so a credentialed + /// AgentUri can never leak a password into the AdminUI. + /// + private static string RedactedDisplay(Uri uri) + { + var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}"; + + return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}"; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs new file mode 100644 index 00000000..8e25ba29 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs @@ -0,0 +1,235 @@ +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; +}