Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs
T
Joseph Doherty 429ead837f feat(sql): construct Postgres and Oracle providers; MySql/Odbc stay reserved
Widens the two provider gates so an authored `{"provider":"Postgres"}` or
`{"provider":"Oracle"}` binds that provider's own dialect instead of failing
"not available in this build":

- `SqlDriverFactoryExtensions.CreateDialect` gains a Postgres and an Oracle
  arm. There is deliberately no fallback arm — a default resolving to
  SqlServerDialect would construct happily and then emit T-SQL at a Postgres
  or Oracle server. The rejection message now names the three supported and
  the two still-reserved providers.
- `SqlDriverBrowser.DefaultDialectSelector` widens identically, so a provider
  the runtime constructs is also browsable; MySql/Odbc keep resolving to null
  and the caller keeps reporting them as unavailable.

Tests: the existing rejected-provider controls flip from Postgres to MySql
(factory) and Odbc (browser) so the reserved half stays falsifiable, and the
new legs assert the bound dialect TYPE rather than mere construction —
"no throw" alone would pass against a SqlServer fallback.

Doc-comment sweep (contract addition, one line each): the "v1 constructs
SqlServer only" / "reserved for P2" claims in SqlProvider, SqlDriverConfigDto,
ISqlDialect.Provider and SqlServerDialect are now false and were corrected in
the same change.
2026-08-05 06:51:48 -04:00

420 lines
18 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);
}
/// <summary>
/// The browse-side gate, asserted as a partition of the whole enum: the three carried providers each
/// resolve to <b>their own</b> dialect, and every remaining member resolves to <see langword="null"/>.
/// Enumerating <see cref="Enum.GetValues{TEnum}"/> for the reserved half is what makes a future enum
/// member fail here rather than silently browse as SQL Server.
/// </summary>
[Fact]
public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle()
{
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType<PostgresDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType<OracleDialect>();
SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle];
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => !carried.Contains(p)))
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 ----
/// <summary>
/// The falsifiability control for the widened browse gate: <see cref="SqlProvider.MySql"/> is still
/// reserved and must be reported as unavailable, naming itself.
/// </summary>
[Fact]
public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("MySql", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("MySql");
ex.Message.ShouldContain("not available in this build");
}
/// <summary>
/// Provider availability is answered before the environment is touched: "this build cannot browse
/// MySql" 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("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("not available in this build");
reads.ShouldBeEmpty();
}
/// <summary>
/// A carried provider must get <b>past</b> the provider gate and on to the environment, so that a
/// Postgres or Oracle browse fails (if at all) on the connection string rather than on availability.
/// Asserted through the reads list: reaching the environment reader is the observable proof the gate
/// let the provider through.
/// </summary>
[Theory]
[InlineData("Postgres")]
[InlineData("Oracle")]
public async Task Open_aCarriedProvider_passesTheGateAndResolvesTheRef(string provider)
{
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(provider, connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldNotContain("not available in this build");
reads.ShouldContain(SqlDriverBrowser.EnvironmentVariableFor("MesStaging"));
}
// ---- 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));
}
}