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; /// /// Covers '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. /// The successful-open cases run through the linked against the real /// seeded , injected via the browser's dialectSelector /// 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 SqlDriver's factory /// parameter. /// public sealed class SqlDriverBrowserTests { /// /// 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. /// private const string Password = "Sup3rSecret-OtOpcUa-Probe"; /// /// A host that cannot resolve, so the connect fails in DNS rather than on a timer. .invalid is /// reserved by RFC 2606 and can never be registered. /// private const string DeadHost = "otopcua-no-such-host.invalid"; private static Func 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(); foreach (var provider in Enum.GetValues().Where(p => p != SqlProvider.SqlServer)) SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull(); } // ---- connection-string reference resolution ---- /// The plan's headline case: an unresolvable ref must name the exact variable to set. [Fact] public async Task Open_unresolvableRef_failsNamingExactEnvVar() { var browser = new SqlDriverBrowser(); var ex = await Should.ThrowAsync(() => browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default)); ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging"); } /// /// The default (production) path: no injected reader, a real process environment variable. Uses a /// run-unique name and restores it in a finally, so a failure cannot leave it set for the rest /// of the assembly — environment variables are process-global and every other test shares them. /// [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(() => 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(() => browser.OpenAsync(ConfigJson(connectionStringRef: " "), CancellationToken.None)); ex.Message.ShouldContain("connectionStringRef"); } // ---- pasted literal, and its precedence ---- /// /// A pasted literal opens a working session, and the session owns the connection — it is still /// live after OpenAsync returns (the browser did not close it) and dead after the session is /// disposed (nothing else will). /// [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(() => session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None)); } /// /// Precedence: the pasted literal wins, and the reference is not even read. 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. /// [Fact] public async Task Open_refAndLiteralTogether_literalWinsAndRefIsNeverRead() { await using var db = await SqliteBrowseFixture.CreateAsync(); var reads = new List(); var logger = new CapturingLogger(); 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(() => browser.OpenAsync( ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None)); ex.Message.ShouldContain("Postgres"); ex.Message.ShouldContain("not available in this build"); } /// /// 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. /// [Fact] public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider() { var reads = new List(); var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; }); var ex = await Should.ThrowAsync(() => 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(() => browser.OpenAsync(configJson, CancellationToken.None)); ex.Message.ShouldContain("configuration"); } /// /// 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. /// [Fact] public async Task Open_malformedJson_failsWithoutEchoingTheConfig() { var browser = new SqlDriverBrowser(SqliteSelector); var configJson = $$"""{"provider":"SqlServer","connectionString":"Password={{Password}}" """; var ex = await Should.ThrowAsync(() => 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(() => browser.OpenAsync("[1,2,3]", CancellationToken.None)); ex.Message.ShouldContain("JSON object"); } /// An unknown provider name is a bind failure, and must not echo the config either. [Fact] public async Task Open_unknownProviderName_failsWithoutEchoingTheConfig() { var browser = new SqlDriverBrowser(SqliteSelector); var ex = await Should.ThrowAsync(() => 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 ---- /// /// 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. /// [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(() => 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"); } /// /// The leak that is real, and is the reason the parser's message is suppressed rather than trusted. /// ADO.NET expects a value containing ; to be quoted. An unquoted one splits, and the /// tail of the password is then parsed as a keyword — which /// Microsoft.Data.SqlClient echoes verbatim (lower-cased) in /// Keyword not supported: '…'. 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. /// [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(() => { 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(() => browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None)); ex.ToString().ShouldNotContain(tail, Case.Insensitive); ex.Message.ShouldContain("malformed"); } /// /// 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. /// [Fact] public async Task Open_neverLogsTheConnectionString() { await using var db = await SqliteBrowseFixture.CreateAsync(); var logger = new CapturingLogger(); 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(() => 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 ---- /// /// Builds a driver-config blob. Serialized rather than interpolated so a connection string containing /// JSON metacharacters (backslashes in a Data Source path, quotes in a password) escapes /// correctly instead of producing malformed JSON the test would then misattribute. /// private static string ConfigJson( string provider = "SqlServer", string? connectionStringRef = null, string? connectionString = null) { var map = new Dictionary { ["provider"] = provider }; if (connectionStringRef is not null) map["connectionStringRef"] = connectionStringRef; if (connectionString is not null) map["connectionString"] = connectionString; return JsonSerializer.Serialize(map); } /// Records every formatted log message so the hygiene assertions can read them back. private sealed class CapturingLogger : ILogger { public List Entries { get; } = []; IDisposable? ILogger.BeginScope(TState state) => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Add(formatter(state, exception)); } }