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}"); } }