feat(mtconnect): read-only MTConnect Agent driver (P1) — 23 tasks + 5-leg live gate #506

Merged
dohertj2 merged 34 commits from feat/mtconnect-driver into master 2026-07-27 14:02:29 -04:00
2 changed files with 118 additions and 2 deletions
Showing only changes of commit c232a3ce67 - Show all commits
@@ -23,6 +23,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately (Task 15).
/// </para>
/// <para>
/// <b>Accepted gap: reachable ≠ deployable.</b> Because only <c>agentUri</c> is read, a
/// config can show green on Test Connect and still fail to deploy — e.g. a non-positive
/// <c>requestTimeoutMs</c> or a malformed tag entry the factory's full parse would reject.
/// This is the direct consequence of the independent-parsing decision above, judged
/// acceptable because Test Connect's job is "is the endpoint there", not "will this exact
/// config deploy".
/// </para>
/// <para>
/// <b>Deliberately DOES reuse <see cref="MTConnectAgentClient"/> and
/// <see cref="MTConnectProbeParser"/> for the actual round trip.</b> Hand-rolling the HTTP
/// call would have to re-derive the exact behaviour the client already provides: per-call
@@ -63,6 +71,18 @@ public sealed class MTConnectDriverProbe : IDriverProbe
/// <summary>Replaces a non-positive caller <c>timeout</c> — see the type remarks.</summary>
private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Caps an excessive caller <c>timeout</c>. <c>CancellationTokenSource.CancelAfter(TimeSpan)</c>'s
/// legal range tops out near ~49.7 days (<see cref="uint.MaxValue"/> ms minus one); an
/// uncapped pathological value (e.g. a caller passing <c>TimeSpan.FromDays(1000)</c>) throws
/// <see cref="ArgumentOutOfRangeException"/> straight out of
/// <c>CancelAfter</c>, which would violate the "Never throws" contract just as surely as a
/// non-positive timeout meaning "wait forever" would (arch-review 01/S-6 — this is the same
/// defect class, the high end rather than the low end). 10 minutes is generous for a
/// reachability probe and leaves an enormous margin under the legal ceiling.
/// </summary>
private static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -131,8 +151,15 @@ public sealed class MTConnectDriverProbe : IDriverProbe
var safeUri = RedactedDisplay(parsed);
// arch-review 01/S-6: a non-positive timeout must never mean "wait forever".
// arch-review 01/S-6: a non-positive timeout must never mean "wait forever" (low end), and a
// pathologically large one must never overrun CancelAfter's legal range (high end) — see
// MaxTimeout.
var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout;
if (effectiveTimeout > MaxTimeout)
{
effectiveTimeout = MaxTimeout;
}
var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds));
var sw = Stopwatch.StartNew();
@@ -187,7 +214,12 @@ public sealed class MTConnectDriverProbe : IDriverProbe
}
catch (Exception ex)
{
return new(false, ex.Message, null);
// Last-resort catch for an exception type none of the above enumerate. Deliberately does
// NOT forward ex.Message: unlike the typed catches above (each individually audited for
// whether its message can carry the raw AgentUri/userinfo), an unenumerated type has no
// such audit, so ex.Message is an open door for a credential leak. safeUri + the
// exception's TYPE name is enough for an operator to act on without that risk.
return new(false, $"Probe against {safeUri} failed unexpectedly ({ex.GetType().Name}).", null);
}
}
@@ -232,4 +232,88 @@ public sealed class MTConnectDriverProbeTests
/// <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");
}
}