feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal

Opens one transient DbConnection from a form-supplied driver-config blob and
hands ownership to SqlBrowseSession, which closes it on disposal. A database is
named either by connectionStringRef — resolved in the AdminUI process from
Sql__ConnectionStrings__<ref>, with an unresolvable ref naming the exact missing
variable — or by a pasted, session-only literal. The literal wins and the ref is
then not read; it cannot arrive from a persisted blob (SqlDriverConfigDto has no
such property), so its presence proves an operator typed it now.

Credential hygiene is the point of the type: no cached config field, nothing
persisted, and no connection text in any log line or exception. Live-probed:
Microsoft.Data.SqlClient and Microsoft.Data.Sqlite keep connection-string values
out of SqlException/SqliteException, but their connection-string PARSER echoes an
unrecognised keyword verbatim — and an unquoted ';' inside a password splits, so
the tail of the password is reported as a keyword (Password=Sup3r;SecretTail =>
"Keyword not supported: 'secrettail;connect timeout'."). The parser's message is
therefore never surfaced; every other failure additionally passes through a
substring redactor that drops the inner exception when it fires.

DriverType comes from SqlDriver.DriverTypeName, the driver's interim local
constant — DriverTypeNames.Sql is added by the driver-factory task.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 15:02:17 -04:00
parent e3e3f5fb04
commit 9e7fa5d11f
2 changed files with 785 additions and 0 deletions
@@ -0,0 +1,384 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// Covers <see cref="SqlDriverBrowser"/>'s open side: which of the two ways to name a database wins, what
/// an unresolvable reference tells the operator, which providers this build carries — and, above all,
/// that a connection string never escapes into a log line or an exception.
/// <para>The successful-open cases run through the linked <see cref="SqliteDialect"/> against the real
/// seeded <see cref="SqliteBrowseFixture"/>, injected via the browser's <c>dialectSelector</c>
/// constructor parameter. That parameter is the production extension point for a P2 provider, not a test
/// hatch — the same conclusion the driver author reached for <c>SqlDriver</c>'s <c>factory</c>
/// parameter.</para>
/// </summary>
public sealed class SqlDriverBrowserTests
{
/// <summary>
/// The credential every hygiene assertion hunts for. Distinctive enough that a substring match
/// anywhere in an exception or a log line is proof of a leak, not a coincidence.
/// </summary>
private const string Password = "Sup3rSecret-OtOpcUa-Probe";
/// <summary>
/// A host that cannot resolve, so the connect fails in DNS rather than on a timer. <c>.invalid</c> is
/// reserved by RFC 2606 and can never be registered.
/// </summary>
private const string DeadHost = "otopcua-no-such-host.invalid";
private static Func<SqlProvider, ISqlDialect?> SqliteSelector => static _ => new SqliteDialect();
// ---- identity ----
[Fact]
public void DriverType_isTheSqlDriversOwnInterimConstant()
{
// Deliberately asserted against SqlDriver.DriverTypeName rather than a literal: DriverTypeNames.Sql
// does not exist yet (adding it reddens DriverTypeNamesGuardTests until a factory is registered), so
// the driver's local constant is the single definition both sides must agree on.
new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName);
}
[Fact]
public void DefaultDialectSelector_carriesSqlServerOnly()
{
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => p != SqlProvider.SqlServer))
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
}
// ---- connection-string reference resolution ----
/// <summary>The plan's headline case: an unresolvable ref must name the exact variable to set.</summary>
[Fact]
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
}
/// <summary>
/// The default (production) path: no injected reader, a real process environment variable. Uses a
/// run-unique name and restores it in a <c>finally</c>, so a failure cannot leave it set for the rest
/// of the assembly — environment variables are process-global and every other test shares them.
/// </summary>
[Fact]
public async Task Open_refResolvedFromTheRealEnvironment_opensSession()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var reference = "OtOpcUaBrowseTest" + Guid.NewGuid().ToString("N");
var variable = SqlDriverBrowser.EnvironmentVariableFor(reference);
Environment.SetEnvironmentVariable(variable, db.ConnectionString);
try
{
var browser = new SqlDriverBrowser(SqliteSelector);
await using var session = await browser.OpenAsync(
ConfigJson(connectionStringRef: reference), CancellationToken.None);
var roots = await session.RootAsync(CancellationToken.None);
roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema);
}
finally
{
Environment.SetEnvironmentVariable(variable, null);
}
}
[Fact]
public async Task Open_neitherRefNorLiteral_failsNamingBothWays()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(), CancellationToken.None));
ex.Message.ShouldContain("connectionStringRef");
ex.Message.ShouldContain("connectionString");
}
[Fact]
public async Task Open_blankRef_failsRatherThanResolvingAnEmptyVariableName()
{
var browser = new SqlDriverBrowser(SqliteSelector, static _ => "should never be read");
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(connectionStringRef: " "), CancellationToken.None));
ex.Message.ShouldContain("connectionStringRef");
}
// ---- pasted literal, and its precedence ----
/// <summary>
/// A pasted literal opens a working session, and <b>the session owns the connection</b> — it is still
/// live after <c>OpenAsync</c> returns (the browser did not close it) and dead after the session is
/// disposed (nothing else will).
/// </summary>
[Fact]
public async Task Open_pastedLiteral_opensWorkingSessionThatOwnsTheConnection()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var browser = new SqlDriverBrowser(SqliteSelector);
var session = await browser.OpenAsync(
ConfigJson(connectionString: db.ConnectionString), CancellationToken.None);
var children = await session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None);
children.ShouldContain(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);
await session.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(() =>
session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None));
}
/// <summary>
/// Precedence: the pasted literal wins, and the reference is <b>not even read</b>. Proven by a reader
/// that records every call — an assertion on the resulting session alone would still pass if the ref
/// were consulted first and merely lost a tie-break.
/// </summary>
[Fact]
public async Task Open_refAndLiteralTogether_literalWinsAndRefIsNeverRead()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var reads = new List<string>();
var logger = new CapturingLogger<SqlDriverBrowser>();
var browser = new SqlDriverBrowser(
SqliteSelector,
name =>
{
reads.Add(name);
return "Data Source=/otopcua-no-such-directory/never.db";
},
logger);
await using var session = await browser.OpenAsync(
ConfigJson(connectionStringRef: "MesStaging", connectionString: db.ConnectionString),
CancellationToken.None);
reads.ShouldBeEmpty();
var roots = await session.RootAsync(CancellationToken.None);
roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema);
// The override is announced, naming the shadowed ref — never any connection text.
logger.Entries.ShouldContain(e => e.Contains("MesStaging", StringComparison.Ordinal));
logger.Entries.ShouldNotContain(e => e.Contains(db.ConnectionString, StringComparison.OrdinalIgnoreCase));
}
// ---- provider availability ----
[Fact]
public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("Postgres");
ex.Message.ShouldContain("not available in this build");
}
/// <summary>
/// Provider availability is answered before the environment is touched: "this build cannot browse
/// Postgres" is true whatever the ref resolves to, and the two failures must not mask each other.
/// </summary>
[Fact]
public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider()
{
var reads = new List<string>();
var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; });
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("not available in this build");
reads.ShouldBeEmpty();
}
// ---- malformed configuration ----
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task Open_emptyConfig_failsAskingForOne(string configJson)
{
var browser = new SqlDriverBrowser(SqliteSelector);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(configJson, CancellationToken.None));
ex.Message.ShouldContain("configuration");
}
/// <summary>
/// Malformed JSON must fail as malformed JSON — and must not echo the offending text back, because
/// the text a JSON parser chokes on may itself be a half-pasted connection string.
/// </summary>
[Fact]
public async Task Open_malformedJson_failsWithoutEchoingTheConfig()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var configJson = $$"""{"provider":"SqlServer","connectionString":"Password={{Password}}" """;
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(configJson, CancellationToken.None));
ex.Message.ShouldContain("not valid JSON");
ex.ToString().ShouldNotContain(Password, Case.Insensitive);
}
[Fact]
public async Task Open_jsonThatIsNotAnObject_fails()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync("[1,2,3]", CancellationToken.None));
ex.Message.ShouldContain("JSON object");
}
/// <summary>An unknown <c>provider</c> name is a bind failure, and must not echo the config either.</summary>
[Fact]
public async Task Open_unknownProviderName_failsWithoutEchoingTheConfig()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("NotARealProvider", connectionString: $"Server=x;Password={Password}"),
CancellationToken.None));
ex.Message.ShouldContain("could not be bound");
ex.ToString().ShouldNotContain(Password, Case.Insensitive);
}
// ---- credential hygiene ----
/// <summary>
/// The natural case the task names: a well-formed connection string carrying a password, pointed at a
/// host that does not exist. Nothing about the string may reach the exception.
/// </summary>
[Fact]
public async Task Open_unreachableHost_neverLeaksThePastedConnectionString()
{
var connectionString =
$"Server={DeadHost};Database=Mes;User ID=sa;Password={Password};Connect Timeout=1;"
+ "TrustServerCertificate=true";
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None));
ex.ToString().ShouldNotContain(Password, Case.Insensitive);
ex.ToString().ShouldNotContain(connectionString, Case.Insensitive);
// Still says something useful about *why*.
ex.Message.ShouldContain("could not open a connection");
}
/// <summary>
/// The leak that is real, and is the reason the parser's message is suppressed rather than trusted.
/// <para>ADO.NET expects a value containing <c>;</c> to be quoted. An unquoted one splits, and the
/// tail of the password is then parsed as a keyword — which
/// <c>Microsoft.Data.SqlClient</c> echoes verbatim (lower-cased) in
/// <c>Keyword not supported: '…'</c>. The first half of this test is the control: it proves the raw
/// provider really does leak, so the second half asserting the browser does not is not vacuous.</para>
/// </summary>
[Fact]
public async Task Open_semicolonBearingPassword_neverLeaksTheProvidersEchoedKeyword()
{
const string tail = "OtOpcUaTailSecret";
var connectionString = $"Server={DeadHost};Password=Sup3r;{tail};Connect Timeout=1";
// Control: the bare provider leaks the tail of the password into its own exception.
var raw = Should.Throw<ArgumentException>(() =>
{
using var connection = new SqlServerDialect().Factory.CreateConnection()!;
connection.ConnectionString = connectionString;
});
raw.ToString().ShouldContain(tail, Case.Insensitive);
// The browser does not.
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None));
ex.ToString().ShouldNotContain(tail, Case.Insensitive);
ex.Message.ShouldContain("malformed");
}
/// <summary>
/// Nothing the browser logs — on the success path or the failure path — carries connection text. The
/// ref name is fair game; the string never is.
/// </summary>
[Fact]
public async Task Open_neverLogsTheConnectionString()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var logger = new CapturingLogger<SqlDriverBrowser>();
var browser = new SqlDriverBrowser(SqliteSelector, static _ => null, logger);
await using (await browser.OpenAsync(
ConfigJson(connectionString: db.ConnectionString), CancellationToken.None))
{
// Opened purely for its log output.
}
var dead = $"Data Source=/otopcua-no-such-directory/never.db;Password={Password}";
await Should.ThrowAsync<Exception>(() =>
browser.OpenAsync(ConfigJson(connectionString: dead), CancellationToken.None));
logger.Entries.ShouldNotBeEmpty();
foreach (var entry in logger.Entries)
{
entry.ShouldNotContain(db.ConnectionString, Case.Insensitive);
entry.ShouldNotContain(Password, Case.Insensitive);
entry.ShouldNotContain("Data Source", Case.Insensitive);
}
}
// ---- helpers ----
/// <summary>
/// Builds a driver-config blob. Serialized rather than interpolated so a connection string containing
/// JSON metacharacters (backslashes in a <c>Data Source</c> path, quotes in a password) escapes
/// correctly instead of producing malformed JSON the test would then misattribute.
/// </summary>
private static string ConfigJson(
string provider = "SqlServer", string? connectionStringRef = null, string? connectionString = null)
{
var map = new Dictionary<string, object?> { ["provider"] = provider };
if (connectionStringRef is not null) map["connectionStringRef"] = connectionStringRef;
if (connectionString is not null) map["connectionString"] = connectionString;
return JsonSerializer.Serialize(map);
}
/// <summary>Records every formatted log message so the hygiene assertions can read them back.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<string> Entries { get; } = [];
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(formatter(state, exception));
}
}