9e7fa5d11f
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
385 lines
16 KiB
C#
385 lines
16 KiB
C#
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));
|
|
}
|
|
}
|