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:
@@ -0,0 +1,401 @@
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a <b>transient</b> database connection from form-supplied JSON for the AdminUI schema picker,
|
||||
/// and hands it to a <see cref="SqlBrowseSession"/>. The AdminUI's <c>BrowseSessionRegistry</c> (its idle
|
||||
/// TTL reaper included) owns the returned session unchanged — nothing about the SQL browse needs a
|
||||
/// registry of its own.
|
||||
/// <para><b>Two ways to name the database, and only one of them is persisted.</b>
|
||||
/// <c>connectionStringRef</c> is the deployed form: a name resolved <em>in the AdminUI process</em> from
|
||||
/// the environment as <c>Sql__ConnectionStrings__<ref></c> (design §4.4), so a deployed artifact
|
||||
/// never carries credentials. <c>connectionString</c> is an ad-hoc, <b>session-only</b> literal an
|
||||
/// operator may paste to browse a database that has no ref yet; it is used for this one connection and
|
||||
/// then forgotten — there is deliberately no cached-config field on this type, and nothing writes it
|
||||
/// anywhere.</para>
|
||||
/// <para><b>Precedence: a pasted literal wins over a ref</b>, and the ref is then not even read. The
|
||||
/// literal cannot arrive from a persisted blob — <see cref="SqlDriverConfigDto"/> has no such property, so
|
||||
/// the driver factory would drop it — which makes its presence proof that an operator typed it just now.
|
||||
/// A config carrying both logs a warning (naming the shadowed <em>ref</em>, never any connection text) so
|
||||
/// the override is visible rather than silent.</para>
|
||||
/// <para><b>Credential hygiene is the point of this type.</b> A connection string is a secret. It is
|
||||
/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field,
|
||||
/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their
|
||||
/// own errors, every failure on the open path goes through <see cref="Sanitize"/> before it leaves this
|
||||
/// class.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
{
|
||||
/// <summary>
|
||||
/// Prefix of the environment variable a <c>connectionStringRef</c> resolves through — the
|
||||
/// double-underscore form .NET configuration uses for the <c>Sql:ConnectionStrings:<ref></c>
|
||||
/// key path.
|
||||
/// </summary>
|
||||
public const string ConnectionStringEnvironmentPrefix = "Sql__ConnectionStrings__";
|
||||
|
||||
/// <summary>
|
||||
/// Hard cap on the connect phase, layered on the caller's token. The AdminUI already bounds each
|
||||
/// browse call at 20 s; this only stops a pathological provider-side connect from outliving the
|
||||
/// picker entirely.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Connection-string keys whose <em>values</em> are redacted out of any provider error text. The
|
||||
/// whole connection string is redacted unconditionally; these are the parts that could survive as a
|
||||
/// fragment. Server / database names are deliberately <b>not</b> redacted — "server X was not found"
|
||||
/// is the diagnostic the operator needs, and a hostname is not a credential.
|
||||
/// </summary>
|
||||
private static readonly string[] CredentialKeys =
|
||||
[
|
||||
"password", "pwd", "user id", "uid", "user", "username", "accesstoken", "access token",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Kept deliberately identical in shape to the driver factory's options (design §5.1): enum-valued
|
||||
/// knobs are authored as their <em>names</em>, so the browser parses a given <c>DriverConfig</c> blob
|
||||
/// exactly as the runtime factory does. <see cref="JsonStringEnumConverter"/> also accepts ordinals,
|
||||
/// so a numeric config still binds.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private readonly Func<SqlProvider, ISqlDialect?> _dialectSelector;
|
||||
private readonly Func<string, string?> _environmentReader;
|
||||
private readonly ILogger<SqlDriverBrowser> _logger;
|
||||
|
||||
/// <summary>Creates a browser. Every dependency has a production default, so DI may construct it bare.</summary>
|
||||
/// <param name="dialectSelector">
|
||||
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
|
||||
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
|
||||
/// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
|
||||
/// provider gets added without touching this class.
|
||||
/// </param>
|
||||
/// <param name="environmentReader">
|
||||
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
|
||||
/// Injectable because environment variables are process-global, and a test that sets one races every
|
||||
/// other test in the assembly.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional; defaults to <see cref="NullLogger{T}"/>.</param>
|
||||
public SqlDriverBrowser(
|
||||
Func<SqlProvider, ISqlDialect?>? dialectSelector = null,
|
||||
Func<string, string?>? environmentReader = null,
|
||||
ILogger<SqlDriverBrowser>? logger = null)
|
||||
{
|
||||
_dialectSelector = dialectSelector ?? DefaultDialectSelector;
|
||||
_environmentReader = environmentReader ?? Environment.GetEnvironmentVariable;
|
||||
_logger = logger ?? NullLogger<SqlDriverBrowser>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Sourced from <see cref="SqlDriver.DriverTypeName"/>, the driver's interim local constant, rather
|
||||
/// than from <c>DriverTypeNames</c> — the shared constant set is added by the driver-factory task,
|
||||
/// because adding a member there reddens <c>DriverTypeNamesGuardTests</c> until a factory is
|
||||
/// registered against it. Repointing that one constant repoints this too.
|
||||
/// </remarks>
|
||||
public string DriverType => SqlDriver.DriverTypeName;
|
||||
|
||||
/// <summary>The environment variable a <c>connectionStringRef</c> resolves through.</summary>
|
||||
/// <param name="connectionStringRef">The authored reference name.</param>
|
||||
/// <returns>The full environment-variable name, e.g. <c>Sql__ConnectionStrings__MesStaging</c>.</returns>
|
||||
public static string EnvironmentVariableFor(string connectionStringRef) =>
|
||||
ConnectionStringEnvironmentPrefix + connectionStringRef;
|
||||
|
||||
/// <summary>
|
||||
/// The providers this build can browse. v1 constructs SQL Server only; every other
|
||||
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> and
|
||||
/// the caller reports it as unavailable rather than crashing on a null dialect.
|
||||
/// </summary>
|
||||
/// <param name="provider">The authored provider.</param>
|
||||
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
|
||||
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
|
||||
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The configuration is absent / malformed, names a provider this build does not carry, names neither
|
||||
/// a <c>connectionStringRef</c> nor a literal, resolves a ref that is not set in the environment, or
|
||||
/// the connection could not be opened.
|
||||
/// </exception>
|
||||
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configJson))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser requires a driver configuration; none was supplied.");
|
||||
}
|
||||
|
||||
var literal = ReadPastedConnectionString(configJson);
|
||||
var config = Deserialize(configJson, literal);
|
||||
|
||||
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
|
||||
// named, and answering it before touching the environment keeps the two failures from masking.
|
||||
var dialect = _dialectSelector(config.Provider)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Sql provider '{config.Provider}' is not available in this build; v1 browses "
|
||||
+ $"'{SqlProvider.SqlServer}' only.");
|
||||
|
||||
var reference = config.ConnectionStringRef;
|
||||
var connectionString = ResolveConnectionString(literal, reference);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI Sql browse session opening (provider {Provider}, connection from {Source})",
|
||||
config.Provider,
|
||||
DescribeSource(literal, reference));
|
||||
|
||||
return await OpenSessionAsync(dialect, connectionString, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens one transient connection and hands ownership to the session. <b>The session closes the
|
||||
/// connection on its own disposal</b>, so the only disposal this method owns is the failure path —
|
||||
/// disposing on success would double-close and leave the picker with a dead session.
|
||||
/// </summary>
|
||||
private static async Task<IBrowseSession> OpenSessionAsync(
|
||||
ISqlDialect dialect, string connectionString, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = dialect.Factory.CreateConnection()
|
||||
?? throw new InvalidOperationException(
|
||||
$"The ADO.NET provider for '{dialect.Provider}' returned no connection object.");
|
||||
|
||||
try
|
||||
{
|
||||
connection.ConnectionString = connectionString;
|
||||
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
connectCts.CancelAfter(ConnectBudget);
|
||||
await connection.OpenAsync(connectCts.Token).ConfigureAwait(false);
|
||||
|
||||
return new SqlBrowseSession(connection, dialect);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { await connection.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort: the original failure is the useful one. */ }
|
||||
|
||||
if (ex is OperationCanceledException) throw;
|
||||
throw Sanitize(ex, connectionString);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the connection string, literal first. A blank literal is treated as absent (an untouched
|
||||
/// form field), never as an empty connection string.
|
||||
/// </summary>
|
||||
private string ResolveConnectionString(string? literal, string? reference)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(literal))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql browse: a pasted connection string was supplied alongside connectionStringRef "
|
||||
+ "'{Ref}'. The pasted value wins for this session only and is not persisted.",
|
||||
reference);
|
||||
}
|
||||
|
||||
return literal;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser needs a database to browse: set 'connectionStringRef' to a name resolved "
|
||||
+ $"from the environment as '{ConnectionStringEnvironmentPrefix}<ref>', or paste a "
|
||||
+ "connection string into 'connectionString' for an ad-hoc browse.");
|
||||
}
|
||||
|
||||
var variable = EnvironmentVariableFor(reference);
|
||||
var resolved = _environmentReader(variable);
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
// Name the exact variable: "the ref did not resolve" is unactionable, and the operator's next
|
||||
// move is to set this one name on the AdminUI host.
|
||||
throw new InvalidOperationException(
|
||||
$"connectionStringRef '{reference}' does not resolve: environment variable "
|
||||
+ $"'{variable}' is not set on the AdminUI host. Set it there (the AdminUI resolves browse "
|
||||
+ "connection strings in its own process), or paste a connection string for an ad-hoc "
|
||||
+ "browse.");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>How the connection string was obtained, for the log. Carries no connection text.</summary>
|
||||
private static string DescribeSource(string? literal, string? reference) =>
|
||||
!string.IsNullOrWhiteSpace(literal)
|
||||
? "a pasted literal (session-only, not persisted)"
|
||||
: $"connectionStringRef '{reference}'";
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the ad-hoc <c>connectionString</c> literal out of the raw JSON. It is read here rather than
|
||||
/// off the DTO because <see cref="SqlDriverConfigDto"/> deliberately has no such property — the
|
||||
/// persisted contract must not be able to carry a credential — and the DTO's
|
||||
/// <see cref="JsonUnmappedMemberHandling.Skip"/> would silently drop it.
|
||||
/// </summary>
|
||||
/// <returns>The literal, or <see langword="null"/> when absent, blank, or not a JSON string.</returns>
|
||||
private static string? ReadPastedConnectionString(string configJson)
|
||||
{
|
||||
using var document = ParseDocument(configJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser's driver configuration must be a JSON object.");
|
||||
}
|
||||
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!string.Equals(property.Name, "connectionString", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (property.Value.ValueKind != JsonValueKind.String) return null;
|
||||
var value = property.Value.GetString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the raw configuration. The <see cref="JsonException"/> is <b>not</b> attached or echoed:
|
||||
/// at this point nothing has been extracted, so there is no known secret to redact against, and the
|
||||
/// malformed text may itself be a pasted connection string.
|
||||
/// </summary>
|
||||
private static JsonDocument ParseDocument(string configJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(configJson);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser's driver configuration is not valid JSON. (The parser's message is "
|
||||
+ "withheld because the malformed text may carry a connection string.)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the typed configuration. A bind failure (e.g. an unknown <c>provider</c> name) is reported
|
||||
/// with the parser's own message redacted against the literal, which is known by now.
|
||||
/// </summary>
|
||||
private static SqlDriverConfigDto Deserialize(string configJson, string? literal)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SqlDriverConfigDto>(configJson, JsonOpts)
|
||||
?? throw new InvalidOperationException(
|
||||
"The Sql browser's driver configuration deserialized to null.");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser's driver configuration could not be bound: "
|
||||
+ Redact(ex.Message, CollectSecrets(literal)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a provider-side open failure into an exception that cannot carry the connection string.
|
||||
/// <para><b>Live-probed, not assumed.</b> <c>Microsoft.Data.SqlClient</c> and
|
||||
/// <c>Microsoft.Data.Sqlite</c> keep connection-string <em>values</em> out of their
|
||||
/// <c>SqlException</c>/<c>SqliteException</c> text (a failed connect says "the server was not found",
|
||||
/// never what it was handed). Their connection-string <b>parser</b> is the exception: it reports an
|
||||
/// unrecognised keyword by quoting it, and a value carrying an unquoted <c>;</c> — legal inside a
|
||||
/// password, which ADO.NET expects you to quote — splits, so the tail of the password is parsed as a
|
||||
/// keyword and echoed verbatim. <c>Password=Sup3r;SecretTail</c> yields
|
||||
/// <c>Keyword not supported: 'secrettail;connect timeout'.</c> — the credential, in the message,
|
||||
/// lower-cased and therefore invisible to a naive ordinal search for the value.</para>
|
||||
/// <para>So the parser's message (an <see cref="ArgumentException"/>) is <b>never</b> surfaced. Every
|
||||
/// other failure is additionally passed through the substring redactor as a backstop, and when that
|
||||
/// fires anywhere in the exception <em>chain</em> the inner exception is dropped too — keeping it
|
||||
/// would re-expose through <c>ToString()</c> exactly what the message just removed.</para>
|
||||
/// </summary>
|
||||
private static InvalidOperationException Sanitize(Exception ex, string connectionString)
|
||||
{
|
||||
if (ex is ArgumentException)
|
||||
{
|
||||
return new InvalidOperationException(
|
||||
"The Sql browser could not open a connection: the connection string is malformed, or "
|
||||
+ "carries a keyword this provider does not support. (The provider's own message is "
|
||||
+ "withheld because it quotes connection-string text verbatim; check for an unquoted ';' "
|
||||
+ "or '=' inside a value such as the password.)");
|
||||
}
|
||||
|
||||
var secrets = CollectSecrets(connectionString);
|
||||
var leaked = ContainsAny(ex.ToString(), secrets);
|
||||
var reason = Redact(ex.Message, secrets);
|
||||
|
||||
var message = leaked
|
||||
? "The Sql browser could not open a connection: " + reason
|
||||
+ " (the provider's error echoed the connection string, so it was redacted and the inner "
|
||||
+ "exception withheld)"
|
||||
: "The Sql browser could not open a connection: " + reason;
|
||||
|
||||
return new InvalidOperationException(message, leaked ? null : ex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The substrings that must never leave this class: the whole connection string, and the values of
|
||||
/// its credential-bearing keys (which could surface on their own).
|
||||
/// </summary>
|
||||
private static List<string> CollectSecrets(string? connectionString)
|
||||
{
|
||||
var secrets = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(connectionString)) return secrets;
|
||||
|
||||
secrets.Add(connectionString);
|
||||
|
||||
try
|
||||
{
|
||||
var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
|
||||
foreach (var key in CredentialKeys)
|
||||
{
|
||||
if (!builder.TryGetValue(key, out var value)) continue;
|
||||
var text = value?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(text)) secrets.Add(text);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// A malformed connection string has no parseable parts; the whole-string entry still stands.
|
||||
}
|
||||
|
||||
// Longest first, so redacting a short credential value cannot chop a longer secret into
|
||||
// unredactable halves.
|
||||
secrets.Sort(static (left, right) => right.Length.CompareTo(left.Length));
|
||||
return secrets;
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string text, List<string> secrets)
|
||||
{
|
||||
foreach (var secret in secrets)
|
||||
{
|
||||
if (text.Contains(secret, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Redact(string text, List<string> secrets)
|
||||
{
|
||||
foreach (var secret in secrets)
|
||||
{
|
||||
text = text.Replace(secret, "[redacted]", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user