fix(mtconnect): redact the Agent URI everywhere it is shown (R06:T-8)

The driver logged and surfaced the raw AgentUri (which may carry user:pass@) as
HostName (the /hosts key), in ~20 log lines and in exception messages that reach
DriverHealth.LastError, and the client's request-URI messages (timeouts, stream
end, not-a-stream, framing) embedded it too - while the Test-Connect probe
deliberately redacted it. New MTConnectUriRedaction (user-info, and for the
configured string its query, removed; a clean URI is unchanged so host keys do
not move); the driver computes its display form once per options document.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:00:40 -04:00
parent d4b9fa1c29
commit d67634cc4f
7 changed files with 263 additions and 33 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
| Key | Default | Notes |
|---|---|---|
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample` |
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample`. Any `user:pass@` (and query) is **redacted** from `HostName` (the `/hosts` key), every log line and every exception / health message (review 2026-10-21 T-8); a URI without credentials is shown unchanged |
| `DeviceName` | `null` | **Double duty.** (1) Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent. (2) The write path's fallback device — the device a write is addressed to when the probe model does not say which device declared the DataItem (see [Write-back](#write-back--observation-injection-off-by-default)) |
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
@@ -280,7 +280,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
_ = MTConnectStreamsParser.Parse(single);
throw new MTConnectStreamNotSupportedException(
$"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
}
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
@@ -293,7 +293,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
// The caller cancelling is the ONE contracted way out; anything else is reported.
live.ThrowIfCancellationRequested();
throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
throw new MTConnectStreamEndedException(reader.EndReason, MTConnectUriRedaction.Redact(uri), delivered);
}
// A frame carrying nothing but whitespace is transport filler, not a document. The
@@ -560,7 +560,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
}
private TimeoutException TimedOut(Uri uri) =>
new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
new($"MTConnect Agent request to '{MTConnectUriRedaction.Redact(uri)}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
/// <summary>
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
@@ -589,7 +589,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
if (string.IsNullOrEmpty(boundary))
{
throw new MTConnectStreamNotSupportedException(
$"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
}
return boundary;
@@ -632,7 +632,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
throw new ArgumentException(
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
$"{nameof(MTConnectDriverOptions.AgentUri)} '{MTConnectUriRedaction.Redact(agentUri)}' is not an absolute http(s) URI.", nameof(options));
}
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
@@ -768,7 +768,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
logger?.LogWarning(
"MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
uri);
MTConnectUriRedaction.Redact(uri));
}
private byte[]? Finish(MTConnectStreamEndReason reason)
@@ -937,7 +937,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
if (_buffer.Length >= MaxBufferBytes)
{
throw new InvalidDataException(
$"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
}
Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
@@ -954,7 +954,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException(
$"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
}
if (read <= 0)
@@ -132,7 +132,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
Interlocked.Increment(ref _sampleCountRaises);
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} is falling behind {AgentUri}'s /sample stream (chunks full at count {Count}, lag {Lag} observation(s)); reopening at sequence {Cursor} with count {NextCount}.",
_driverInstanceId, options.AgentUri, sampleCount, DescribeLag(), cursor, next);
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag(), cursor, next);
return next;
}
@@ -142,7 +142,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
state.CeilingWarned = true;
_logger.LogWarning(
"MTConnect driver {DriverInstanceId} is still behind {AgentUri}'s /sample stream at its count ceiling {Count} (lag {Lag} observation(s)): the machine produces more than count / SampleIntervalMs observations a second, so values are backlog-stale and the stream will re-baseline (dropping intermediate transitions) whenever it falls out of the Agent's buffer. Lower SampleIntervalMs or raise SampleCount.",
_driverInstanceId, options.AgentUri, sampleCount, DescribeLag());
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag());
}
return null;
@@ -1002,7 +1002,7 @@ public sealed partial class MTConnectDriver
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} could not write {Count} DataItem(s) ({DataItemIds}) on device '{DeviceName}' to {AgentUri}.",
_driverInstanceId, batch.Count, string.Join(", ", batch.Select(w => w.DataItemId)), device, options.AgentUri);
_driverInstanceId, batch.Count, string.Join(", ", batch.Select(w => w.DataItemId)), device, AgentDisplay(options));
foreach (var write in batch)
{
@@ -1029,7 +1029,7 @@ public sealed partial class MTConnectDriver
{
_logger.LogWarning(
"MTConnect driver {DriverInstanceId}: {AgentUri} refused the write of '{Value}' to DataItem '{DataItemId}' on device '{DeviceName}' — {AgentError}. Check the Agent's AllowPut / AllowPutFrom settings and that the DataItem exists.",
_driverInstanceId, options.AgentUri, batch[0].Text, batch[0].DataItemId, device, outcome.AgentError);
_driverInstanceId, AgentDisplay(options), batch[0].Text, batch[0].DataItemId, device, outcome.AgentError);
results[batch[0].Slot] = new WriteResult(BadCommunicationError);
return;
@@ -1370,7 +1370,7 @@ public sealed partial class MTConnectDriver
// downstream). Its data items are unreachable either way.
_logger.LogWarning(
"MTConnect driver {DriverInstanceId} skipped a device in {AgentUri}'s /probe model that declares neither a name nor an id; it cannot be given a browse path.",
_driverInstanceId, options.AgentUri);
_driverInstanceId, AgentDisplay(options));
continue;
}
@@ -1388,7 +1388,7 @@ public sealed partial class MTConnectDriver
{
_logger.LogWarning(
"MTConnect driver {DriverInstanceId} is scoped to device '{DeviceScope}', but {AgentUri}'s /probe model declares no device with that name or id ({DeclaredCount} declared). The browse is empty because the scope matched nothing, not because the Agent has nothing.",
_driverInstanceId, deviceScope, options.AgentUri, model.Devices.Count);
_driverInstanceId, deviceScope, AgentDisplay(options), model.Devices.Count);
return;
}
@@ -1398,7 +1398,7 @@ public sealed partial class MTConnectDriver
_driverInstanceId,
devices,
variables,
options.AgentUri,
AgentDisplay(options),
deviceScope is null or "" ? string.Empty : $" (scoped to '{deviceScope}')");
}
@@ -1568,7 +1568,33 @@ public sealed partial class MTConnectDriver
/// <c>host:port</c> convention: several MTConnect drivers in one server disambiguate on the
/// Admin dashboard by endpoint rather than by driver-instance id.
/// </summary>
public string HostName => Volatile.Read(ref _options).AgentUri;
public string HostName => AgentDisplay(Volatile.Read(ref _options));
/// <summary>
/// R06:T-8 — the redacted display form of <paramref name="options"/>' <c>AgentUri</c> (no <c>user:pass@</c>),
/// used for <see cref="HostName"/>, every log line and every message that can reach health text. Computed once
/// per options document and cached by reference.
/// </summary>
/// <param name="options">The options whose Agent is being named.</param>
/// <returns>The display form; identical to the configured value when it carries no credentials.</returns>
private string AgentDisplay(MTConnectDriverOptions options)
{
var cached = Volatile.Read(ref _agentDisplay);
if (cached is not null && ReferenceEquals(cached.Options, options))
{
return cached.Display;
}
var fresh = new AgentDisplayCache(options, MTConnectUriRedaction.Redact(options.AgentUri));
Volatile.Write(ref _agentDisplay, fresh);
return fresh.Display;
}
/// <summary>The last computed <see cref="AgentDisplay"/>, keyed by the options instance it was computed for.</summary>
private AgentDisplayCache? _agentDisplay;
private sealed record AgentDisplayCache(MTConnectDriverOptions Options, string Display);
/// <inheritdoc />
// One entry, always — an Agent is a single host, and the driver knows of it whether or not
@@ -1691,7 +1717,7 @@ public sealed partial class MTConnectDriver
_logger.LogDebug(
ex,
"MTConnect driver {DriverInstanceId} could not reach {AgentUri} on its connectivity probe.",
_driverInstanceId, options.AgentUri);
_driverInstanceId, AgentDisplay(options));
}
TransitionHostTo(reachable ? HostState.Running : HostState.Stopped);
@@ -1702,7 +1728,7 @@ public sealed partial class MTConnectDriver
_logger.LogError(
ex,
"MTConnect driver {DriverInstanceId} stopped its connectivity probe on an unexpected fault; host status for {AgentUri} will not be updated until the driver is re-initialized.",
_driverInstanceId, options.AgentUri);
_driverInstanceId, AgentDisplay(options));
}
}
@@ -1870,7 +1896,7 @@ public sealed partial class MTConnectDriver
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} dropped its cached /probe device model for {AgentUri} and raised rediscovery: agent instanceId is now {InstanceId}.",
_driverInstanceId, options.AgentUri, instanceId);
_driverInstanceId, AgentDisplay(options), instanceId);
try
{
@@ -1993,7 +2019,7 @@ public sealed partial class MTConnectDriver
// that is not running; without this, the resubscribe returns a handle, the caller is
// told SubscriptionEstablished, and the next successful read reports a perfectly green
// driver over a subscription that can never deliver a value.
var agentUri = Volatile.Read(ref _options).AgentUri;
var agentUri = AgentDisplay(Volatile.Read(ref _options));
_streamDegraded = true;
Degrade($"The MTConnect Agent at '{agentUri}' does not serve a framed /sample stream; subscriptions cannot deliver until the driver is re-initialized.");
@@ -2167,7 +2193,7 @@ public sealed partial class MTConnectDriver
_logger.LogError(
outcome.Error,
"MTConnect driver {DriverInstanceId} cannot stream /sample from {AgentUri}: the endpoint did not answer with a framed multipart stream. Subscriptions stay registered but will receive no updates until the driver is re-initialized; check for a proxy or load balancer in front of the Agent.",
_driverInstanceId, options.AgentUri);
_driverInstanceId, AgentDisplay(options));
return;
@@ -2205,7 +2231,7 @@ public sealed partial class MTConnectDriver
_logger.LogWarning(
outcome.Error,
"MTConnect driver {DriverInstanceId} lost the /sample stream from {AgentUri}; reconnecting from sequence {Cursor} (attempt {Attempt}, delivered={Delivered}).",
_driverInstanceId, options.AgentUri, cursor, attempt, outcome.DeliveredAny);
_driverInstanceId, AgentDisplay(options), cursor, attempt, outcome.DeliveredAny);
break;
}
@@ -2296,7 +2322,7 @@ public sealed partial class MTConnectDriver
{
_logger.LogWarning(
"MTConnect driver {DriverInstanceId} saw {AgentUri} restart (instanceId {Previous} -> {Current}); dropping every held observation and re-baselining.",
_driverInstanceId, options.AgentUri, instanceId, chunk.InstanceId);
_driverInstanceId, AgentDisplay(options), instanceId, chunk.InstanceId);
// Dropped BEFORE the re-baseline, and the subscribers are told: if the /current
// then fails, holding stale values from a dead device model would be worse than
@@ -2318,7 +2344,7 @@ public sealed partial class MTConnectDriver
{
_logger.LogWarning(
"MTConnect driver {DriverInstanceId} fell out of {AgentUri}'s buffer (expected sequence {Expected}, oldest retained {FirstSequence}); re-baselining from /current.",
_driverInstanceId, options.AgentUri, expected, chunk.FirstSequence);
_driverInstanceId, AgentDisplay(options), expected, chunk.FirstSequence);
return await RebaselineAsync(client, options, expected, instanceId, delivered, generation, ct)
.ConfigureAwait(false);
@@ -2364,7 +2390,7 @@ public sealed partial class MTConnectDriver
instanceId,
delivered,
new MTConnectStreamEndedException(
MTConnectStreamEndReason.ConnectionClosed, options.AgentUri, 0));
MTConnectStreamEndReason.ConnectionClosed, AgentDisplay(options), 0));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -2384,7 +2410,7 @@ public sealed partial class MTConnectDriver
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} could not resume {AgentUri}'s /sample stream at sequence {Cursor} (the Agent rejected it as out of range); re-baselining from /current.",
_driverInstanceId, options.AgentUri, cursor);
_driverInstanceId, AgentDisplay(options), cursor);
return await RebaselineAsync(client, options, expected, instanceId, delivered, generation, ct)
.ConfigureAwait(false);
@@ -2440,7 +2466,7 @@ public sealed partial class MTConnectDriver
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} re-baselined from {AgentUri}'s /current ({ObservationCount} observation(s), agent instanceId {InstanceId}) and resumes /sample from sequence {NextSequence}.",
_driverInstanceId,
options.AgentUri,
AgentDisplay(options),
current.Observations.Count,
current.InstanceId,
current.NextSequence);
@@ -3040,7 +3066,7 @@ public sealed partial class MTConnectDriver
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} initialized against {AgentUri}{DeviceScope}: {DeviceCount} device(s), {ObservationCount} primed observation(s), agent instanceId {InstanceId}.",
_driverInstanceId,
options.AgentUri,
AgentDisplay(options),
options.DeviceName is null ? string.Empty : $"/{options.DeviceName}",
probe.Devices.Count,
index.Count,
@@ -3072,7 +3098,7 @@ public sealed partial class MTConnectDriver
_logger.LogError(
ex,
"MTConnect driver {DriverInstanceId} failed to initialize against {AgentUri}; the driver is Faulted and serves no values.",
_driverInstanceId, options.AgentUri);
_driverInstanceId, AgentDisplay(options));
throw;
}
@@ -3376,7 +3402,7 @@ public sealed partial class MTConnectDriver
{
// A bare "A task was canceled" names neither the Agent nor the request that stalled.
throw new TimeoutException(
$"MTConnect {request} request for driver '{_driverInstanceId}' against '{Volatile.Read(ref _options).AgentUri}' did not complete within {timeoutMs} ms.");
$"MTConnect {request} request for driver '{_driverInstanceId}' against '{AgentDisplay(Volatile.Read(ref _options))}' did not complete within {timeoutMs} ms.");
}
}
@@ -3413,7 +3439,7 @@ public sealed partial class MTConnectDriver
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} could not read /current from {AgentUri}; the batch is reported Bad.",
_driverInstanceId, Volatile.Read(ref _options).AgentUri);
_driverInstanceId, AgentDisplay(Volatile.Read(ref _options)));
}
/// <summary>Publishes <see cref="DriverState.Degraded"/> without downgrading a Faulted driver.</summary>
@@ -0,0 +1,60 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Review 2026-10-21 R06:T-8 — the display form of an Agent URI for every log line, exception message,
/// <c>DriverHealth.LastError</c> and <c>HostName</c> this driver produces. An <c>AgentUri</c> may carry
/// <c>user:pass@</c>; the Test-Connect probe already redacted it (<c>MTConnectDriverProbe.RedactedDisplay</c>)
/// while the driver itself logged and surfaced the raw string — and every request URI built from it.
/// </summary>
/// <remarks>
/// A URI with no user-info (and, for the configured string, no query or fragment) is returned <b>unchanged</b>,
/// so the common case — and every host key derived from it — is byte-identical to what it was. One that does
/// carry user-info loses it (and, for the configured string, its query / fragment, which a reverse proxy may use
/// for a token). A configured string that is not an absolute URI but contains an <c>@</c> is replaced wholesale:
/// there is no reliable way to find the credential in it.
/// </remarks>
internal static class MTConnectUriRedaction
{
/// <summary>Placeholder for a configured value that cannot be parsed and might carry a credential.</summary>
internal const string Unparseable = "(agentUri withheld: not an absolute URI)";
/// <summary>The display form of a configured <c>AgentUri</c>.</summary>
/// <param name="agentUri">The configured value.</param>
/// <returns>The value with any user-info (and query / fragment) removed.</returns>
public static string Redact(string? agentUri)
{
if (string.IsNullOrWhiteSpace(agentUri))
{
return agentUri ?? string.Empty;
}
if (!Uri.TryCreate(agentUri.Trim(), UriKind.Absolute, out var uri))
{
return agentUri.Contains('@', StringComparison.Ordinal) ? Unparseable : agentUri;
}
if (uri.UserInfo.Length == 0 && uri.Query.Length == 0 && uri.Fragment.Length == 0)
{
return agentUri;
}
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
var path = uri.AbsolutePath == "/" ? string.Empty : uri.AbsolutePath;
return $"{uri.Scheme}://{uri.Host}{port}{path}";
}
/// <summary>The display form of a request URI built from the <c>AgentUri</c> (its query is the request's own).</summary>
/// <param name="uri">The request URI.</param>
/// <returns>The URI as text, without user-info.</returns>
public static string Redact(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
if (!uri.IsAbsoluteUri || uri.UserInfo.Length == 0)
{
return uri.ToString();
}
return new UriBuilder(uri) { UserName = string.Empty, Password = string.Empty }.Uri.ToString();
}
}
@@ -0,0 +1,125 @@
using System.Net;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Review 2026-10-21 R06:T-8 — the driver logged and surfaced the raw <c>AgentUri</c> (which may carry
/// <c>user:pass@</c>) as its <c>HostName</c>, in every log line and in exception messages that reach
/// <c>DriverHealth.LastError</c> → <c>/hosts</c>, while its own Test-Connect probe deliberately redacted it.
/// </summary>
public sealed class MTConnectUriRedactionTests
{
private const string Secret = "s3cretPw";
private const string CredentialedUri = "http://operator:" + Secret + "@fixture-agent:5000";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Theory]
[InlineData("http://fixture-agent:5000", "http://fixture-agent:5000")] // unchanged when clean
[InlineData("http://agent:5000/prefix", "http://agent:5000/prefix")]
[InlineData("http://u:p@agent:5000", "http://agent:5000")]
[InlineData("https://u:p@agent/prefix?token=abc", "https://agent/prefix")]
[InlineData("not a uri with u:p@host", MTConnectUriRedaction.Unparseable)]
[InlineData("not-a-uri", "not-a-uri")]
public void The_configured_uri_loses_its_user_info(string raw, string expected) =>
MTConnectUriRedaction.Redact(raw).ShouldBe(expected);
[Fact]
public void A_request_uri_loses_its_user_info_and_keeps_its_query() =>
MTConnectUriRedaction.Redact(new Uri("http://u:p@agent:5000/sample?from=5&count=10"))
.ShouldBe("http://agent:5000/sample?from=5&count=10");
/// <summary>The host key, every log line and the health text of a credentialed driver carry no password.</summary>
[Fact]
public async Task A_credentialed_agent_uri_never_reaches_host_name_logs_or_health()
{
var logger = new RecordingDriverLogger();
var client = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(
new MTConnectDriverOptions
{
AgentUri = CredentialedUri,
RequestTimeoutMs = 200,
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
},
"mt-t8",
_ => client,
logger);
await driver.InitializeAsync("{}", Ct);
try
{
// A /current that never answers: the read times out, degrades health and logs a Warning.
client.CurrentGate = new TaskCompletionSource();
await driver.ReadAsync(["dev1_pos"], Ct);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded, "positive control: the failure path that carries the URI ran");
health.LastError.ShouldNotBeNull().ShouldNotContain(Secret);
health.LastError.ShouldContain("fixture-agent:5000", Case.Sensitive, "the host is still named");
driver.HostName.ShouldBe("http://fixture-agent:5000");
((IHostConnectivityProbe)driver).GetHostStatuses().ShouldAllBe(s => !s.HostName.Contains(Secret));
var lines = logger.AllSnapshot();
lines.ShouldNotBeEmpty("positive control: the initialize line and the read warning were logged");
lines.ShouldContain(l => l.Contains("fixture-agent:5000"));
lines.ShouldAllBe(l => !l.Contains(Secret));
}
finally
{
client.CurrentGate?.TrySetResult();
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>The production client's own exception messages (they become health text) carry no password.</summary>
[Fact]
public async Task The_client_timeout_message_carries_no_password()
{
using var handler = new HangingHandler();
using var client = new MTConnectAgentClient(
new MTConnectDriverOptions { AgentUri = CredentialedUri, RequestTimeoutMs = 200 }, handler);
var ex = await Should.ThrowAsync<TimeoutException>(async () => await client.CurrentAsync(Ct));
ex.Message.ShouldContain("fixture-agent:5000");
ex.Message.ShouldNotContain(Secret);
}
/// <summary>The stream-ended exception (it becomes the pump's Degrade text) carries no password.</summary>
[Fact]
public async Task The_client_stream_messages_carry_no_password()
{
using var handler = new SingleDocumentHandler();
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = CredentialedUri }, handler);
var ex = await Should.ThrowAsync<MTConnectStreamNotSupportedException>(async () =>
{
await foreach (var _ in client.SampleAsync(1, Ct)) { }
});
ex.Message.ShouldContain("fixture-agent:5000");
ex.Message.ShouldNotContain(Secret);
}
private sealed class HangingHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK);
}
}
private sealed class SingleDocumentHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(File.ReadAllText("Fixtures/current.xml"), System.Text.Encoding.UTF8, "text/xml"),
});
}
}
@@ -18,6 +18,24 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
/// <summary>Formatted <see cref="LogLevel.Error"/> lines, in order.</summary>
public List<string> Errors { get; } = [];
/// <summary>Formatted <see cref="LogLevel.Information"/> lines, in order.</summary>
public List<string> Infos { get; } = [];
/// <summary>Every recorded line at Information and above, with its exception's message appended.</summary>
public IReadOnlyList<string> AllSnapshot()
{
var all = new List<string>();
foreach (var sink in new[] { Infos, Warnings, Errors })
{
lock (sink)
{
all.AddRange(sink);
}
}
return all;
}
/// <inheritdoc/>
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
@@ -39,6 +57,7 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
{
LogLevel.Warning => Warnings,
LogLevel.Error => Errors,
LogLevel.Information => Infos,
_ => null,
};
@@ -50,7 +69,7 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
lock (sink)
{
sink.Add(formatter(state, exception));
sink.Add(exception is null ? formatter(state, exception) : $"{formatter(state, exception)} | {exception.Message}");
}
}