diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs index fe28c346..3838db99 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs @@ -19,7 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// timeout, a cancellation — becomes a red , so the AdminUI has nothing to /// catch. /// The resolved connection string never reaches the result message. A red result names the -/// failure class, never the string — the same credential discipline the factory and the driver keep. +/// failure class, never the string — the same credential discipline the factory and the driver keep. +/// That includes the JSON parser: a malformed config is reported as a class, with the parser's own +/// message withheld exactly as SqlDriverBrowser.ParseDocument withholds it, because the malformed +/// text may itself be a pasted connection string. /// public sealed class SqlDriverProbe : IDriverProbe { @@ -80,7 +83,16 @@ public sealed class SqlDriverProbe : IDriverProbe } catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException) { - return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null); + // The parser's message is WITHHELD, matching SqlDriverBrowser.ParseDocument. Nothing has been + // extracted at this point, so there is no known secret to redact the message against — and the + // blob both types read is the one the AdminUI Sql picker composes, which carries a top-level + // `connectionString` literal. A malformed paste is therefore exactly the text that reaches here, + // and System.Text.Json quotes the offending token and states the JSON path and byte position. + return new DriverProbeResult( + false, + "Config JSON is not valid JSON. (The parser's message is withheld because the malformed " + + "text may carry a connection string.)", + null); } if (dto is null) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs index e8eb97a7..54235cee 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs @@ -79,6 +79,38 @@ public sealed class SqlDriverProbeTests result.Message.ShouldNotBeNullOrWhiteSpace(); } + /// + /// A malformed config is reported as a failure CLASS, never as the parser's own message — the same + /// discipline SqlDriverBrowser.ParseDocument keeps, and for the same reason: the blob the two + /// read is the one the AdminUI Sql picker composes, which carries a top-level connectionString + /// literal, so a malformed paste is exactly the text that reaches here. + /// 's message quotes the offending token and states the JSON path + /// and the byte position. None of it may leave the probe: at this point nothing has been extracted, + /// so there is no known secret to redact against. + /// + [Fact] + public async Task Probe_malformedConfig_withholdsTheParserMessage() + { + using var fixture = new SqlitePollFixture(); + var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); + + // A pasted literal whose password carries an unquoted ';' — mis-quoted exactly the way an operator's + // paste is, so the parse fails partway THROUGH the credential. + const string malformed = + """{"provider":"SqlServer","connectionString":Password=Sup3r;SecretTail}"""; + + var result = await probe.ProbeAsync(malformed, TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldNotContain("LineNumber"); + result.Message.ShouldNotContain("BytePositionInLine"); + result.Message.ShouldNotContain("Sup3r"); + result.Message.ShouldNotContain("SecretTail"); + // Still actionable: it names what is wrong and why the detail is missing. + result.Message.ShouldContain("not valid JSON"); + } + [Fact] public async Task Probe_providerNumberSpelling_parses_soTestConnectMatchesDeploy() {