diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md
index f960bb9d..603cdb6c 100644
--- a/docs/drivers/MTConnect.md
+++ b/docs/drivers/MTConnect.md
@@ -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 |
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
index ab946fcb..13e2bfab 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
@@ -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.");
///
/// The boundary parameter of a multipart/* response, or null 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)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs
index 611eff06..210bf153 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs
@@ -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;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
index ae1ac7bf..3990ab31 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
@@ -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
/// host:port convention: several MTConnect drivers in one server disambiguate on the
/// Admin dashboard by endpoint rather than by driver-instance id.
///
- public string HostName => Volatile.Read(ref _options).AgentUri;
+ public string HostName => AgentDisplay(Volatile.Read(ref _options));
+
+ ///
+ /// R06:T-8 — the redacted display form of ' AgentUri (no user:pass@),
+ /// used for , every log line and every message that can reach health text. Computed once
+ /// per options document and cached by reference.
+ ///
+ /// The options whose Agent is being named.
+ /// The display form; identical to the configured value when it carries no credentials.
+ 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;
+ }
+
+ /// The last computed , keyed by the options instance it was computed for.
+ private AgentDisplayCache? _agentDisplay;
+
+ private sealed record AgentDisplayCache(MTConnectDriverOptions Options, string Display);
///
// 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)));
}
/// Publishes without downgrading a Faulted driver.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectUriRedaction.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectUriRedaction.cs
new file mode 100644
index 00000000..5cff4513
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectUriRedaction.cs
@@ -0,0 +1,60 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// Review 2026-10-21 R06:T-8 — the display form of an Agent URI for every log line, exception message,
+/// DriverHealth.LastError and HostName this driver produces. An AgentUri may carry
+/// user:pass@; the Test-Connect probe already redacted it (MTConnectDriverProbe.RedactedDisplay)
+/// while the driver itself logged and surfaced the raw string — and every request URI built from it.
+///
+///
+/// A URI with no user-info (and, for the configured string, no query or fragment) is returned unchanged,
+/// 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 @ is replaced wholesale:
+/// there is no reliable way to find the credential in it.
+///
+internal static class MTConnectUriRedaction
+{
+ /// Placeholder for a configured value that cannot be parsed and might carry a credential.
+ internal const string Unparseable = "(agentUri withheld: not an absolute URI)";
+
+ /// The display form of a configured AgentUri.
+ /// The configured value.
+ /// The value with any user-info (and query / fragment) removed.
+ 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}";
+ }
+
+ /// The display form of a request URI built from the AgentUri (its query is the request's own).
+ /// The request URI.
+ /// The URI as text, without user-info.
+ 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();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectUriRedactionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectUriRedactionTests.cs
new file mode 100644
index 00000000..5c90a821
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectUriRedactionTests.cs
@@ -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;
+
+///
+/// Review 2026-10-21 R06:T-8 — the driver logged and surfaced the raw AgentUri (which may carry
+/// user:pass@) as its HostName, in every log line and in exception messages that reach
+/// DriverHealth.LastError → /hosts, while its own Test-Connect probe deliberately redacted it.
+///
+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");
+
+ /// The host key, every log line and the health text of a credentialed driver carry no password.
+ [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);
+ }
+ }
+
+ /// The production client's own exception messages (they become health text) carry no password.
+ [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(async () => await client.CurrentAsync(Ct));
+
+ ex.Message.ShouldContain("fixture-agent:5000");
+ ex.Message.ShouldNotContain(Secret);
+ }
+
+ /// The stream-ended exception (it becomes the pump's Degrade text) carries no password.
+ [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(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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+ }
+
+ private sealed class SingleDocumentHandler : HttpMessageHandler
+ {
+ protected override Task 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"),
+ });
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/RecordingDriverLogger.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/RecordingDriverLogger.cs
index a2193020..0cf72708 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/RecordingDriverLogger.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/RecordingDriverLogger.cs
@@ -18,6 +18,24 @@ internal sealed class RecordingDriverLogger : ILogger
/// Formatted lines, in order.
public List Errors { get; } = [];
+ /// Formatted lines, in order.
+ public List Infos { get; } = [];
+
+ /// Every recorded line at Information and above, with its exception's message appended.
+ public IReadOnlyList AllSnapshot()
+ {
+ var all = new List();
+ foreach (var sink in new[] { Infos, Warnings, Errors })
+ {
+ lock (sink)
+ {
+ all.AddRange(sink);
+ }
+ }
+
+ return all;
+ }
+
///
public IDisposable? BeginScope(TState state)
where TState : notnull => null;
@@ -39,6 +57,7 @@ internal sealed class RecordingDriverLogger : ILogger
{
LogLevel.Warning => Warnings,
LogLevel.Error => Errors,
+ LogLevel.Information => Infos,
_ => null,
};
@@ -50,7 +69,7 @@ internal sealed class RecordingDriverLogger : ILogger
lock (sink)
{
- sink.Add(formatter(state, exception));
+ sink.Add(exception is null ? formatter(state, exception) : $"{formatter(state, exception)} | {exception.Message}");
}
}