using System.Data; using System.Data.Common; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// Proves — the AdminUI "Test Connect" liveness check — against the real /// SQLite fixture. A probe opens a connection, runs the dialect's SELECT 1, and reports green with /// latency or red with a reason; it never throws (the contract). /// The probe parses the SAME factory DTO shape as (R2-11 /// factory parity, mirroring ModbusDriverProbe), so a config that Tests-green and a config that /// Deploys are the same config. It resolves the connectionStringRef the same way too — and the /// tests below assert the resolved connection string never reaches the red-result message. /// public sealed class SqlDriverProbeTests { [Fact] public async Task Probe_liveConnection_returnsGreen() { using var fixture = new SqlitePollFixture(); var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); var result = await probe.ProbeAsync( ConfigJson(fixture), TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeTrue(); result.Latency.ShouldNotBeNull(); result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); } [Fact] public async Task Probe_declaresTheSqlDriverType() => SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()) .DriverType.ShouldBe(SqlDriver.DriverTypeName); [Fact] public async Task Probe_aBadConnectionString_returnsRed_withoutThrowing() { // A connection string that resolves fine but points at nothing openable. The probe must catch the // provider's failure and hand back a red result, never propagate it. var probe = SqlDriverProbe.ForTest( new FailingFactory("simulated open failure"), new SqliteDialect()); var result = await probe.ProbeAsync( ConfigJson(connectionString: "Data Source=/no/such/place.db"), TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNullOrWhiteSpace(); result.Latency.ShouldBeNull(); } [Fact] public async Task Probe_missingConnectionStringRef_returnsRed_notAThrow() { var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()); var result = await probe.ProbeAsync( """{"provider":"SqlServer"}""", TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("connectionStringRef"); } [Fact] public async Task Probe_configThatIsNotJson_returnsRed_notAThrow() { var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()); var result = await probe.ProbeAsync( "definitely not json", TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNullOrWhiteSpace(); } /// /// A malformed config is reported as a failure CLASS, never as the parser's own message — the same /// discipline SqlDriverBrowser.ParseDocument keeps, and for the same reason: the blob the two /// read is the one the AdminUI Sql picker composes, which carries a top-level connectionString /// literal, so a malformed paste is exactly the text that reaches here. /// 's message quotes the offending token and states the JSON path /// and the byte position. None of it may leave the probe: at this point nothing has been extracted, /// so there is no known secret to redact against. /// [Fact] public async Task Probe_malformedConfig_withholdsTheParserMessage() { using var fixture = new SqlitePollFixture(); var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); // A pasted literal whose password carries an unquoted ';' — mis-quoted exactly the way an operator's // paste is, so the parse fails partway THROUGH the credential. const string malformed = """{"provider":"SqlServer","connectionString":Password=Sup3r;SecretTail}"""; var result = await probe.ProbeAsync(malformed, TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldNotContain("LineNumber"); result.Message.ShouldNotContain("BytePositionInLine"); result.Message.ShouldNotContain("Sup3r"); result.Message.ShouldNotContain("SecretTail"); // Still actionable: it names what is wrong and why the detail is missing. result.Message.ShouldContain("not valid JSON"); } [Fact] public async Task Probe_providerNumberSpelling_parses_soTestConnectMatchesDeploy() { // R2-11: the probe and the factory read the SAME DTO with the SAME converter, so a numerically // serialised provider Test-Connects exactly as it Deploys. using var fixture = new SqlitePollFixture(); var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); var result = await probe.ProbeAsync( ConfigWithProviderAsNumber(fixture), TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeTrue(); } [Fact] public async Task Probe_neverPutsTheResolvedConnectionStringIntoTheMessage() { const string secret = "Password=DoNotLeakMeIntoAProbeMessage"; var connectionString = $"Data Source=/no/such.db;{secret}"; // The realistic leak shape: a real provider (e.g. SqlException) embeds the connection target in its // OWN exception message. This fake reproduces that — it throws with the connection string in the // message — so a probe that surfaced ex.Message would leak, and this test would then go red. var probe = SqlDriverProbe.ForTest(new LeakyFailingFactory(), new SqliteDialect()); var result = await probe.ProbeAsync( ConfigJson(connectionString: connectionString), TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldNotContain("DoNotLeakMeIntoAProbeMessage"); } [Fact] public async Task Probe_honoursCancellation_asRed_notAThrow() { // A cancelled probe is still a probe result, not an exception the AdminUI has to catch. using var fixture = new SqlitePollFixture(); var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); using var cancelled = new CancellationTokenSource(); await cancelled.CancelAsync(); var result = await probe.ProbeAsync(ConfigJson(fixture), TimeSpan.FromSeconds(5), cancelled.Token); result.Ok.ShouldBeFalse(); } /// /// The production dialect gate — the one seam every other test in this file bypasses. /// Each case above builds its probe with , which injects /// _ => dialect and therefore never reaches DefaultDialectFor. That is exactly how the /// probe kept a SqlServer-only gate while the factory and the browser were widened to Postgres + /// Oracle: the whole suite stayed green, and AdminUI "Test Connect" failed for two of the three /// shipped providers with InvalidOperationException. Caught by the live chrome/AdminUI gate, /// not by any test — so this asserts the gate directly. /// [Theory] [InlineData(SqlProvider.SqlServer)] [InlineData(SqlProvider.Postgres)] [InlineData(SqlProvider.Oracle)] public void ProductionDialectGate_constructsEveryShippedProvider(SqlProvider provider) => SqlDriverProbe.DefaultDialectForTest(provider).Provider.ShouldBe(provider); /// /// The falsification control for the Theory above: the two reserved members must still be /// rejected. Without this, widening the gate to "return a dialect for anything" would pass. /// [Theory] [InlineData(SqlProvider.MySql)] [InlineData(SqlProvider.Odbc)] public void ProductionDialectGate_stillRejectsReservedProviders(SqlProvider provider) => Should.Throw(() => SqlDriverProbe.DefaultDialectForTest(provider)) .Message.ShouldContain(provider.ToString()); /// /// End-to-end through the production constructor: a Postgres config must get PAST dialect /// selection and fail at the connection attempt instead. Both outcomes are a red result whose message /// names only an exception type, so the type is the discriminator — with the SqlServer-only gate this /// is InvalidOperationException thrown before any socket is opened. /// [Fact] public async Task ProductionProbe_postgresConfig_reachesTheConnectionAttempt_notTheProviderGate() { // Port 1 on the loopback: nothing listens, so the connect fails fast and locally — no fixture, no // network egress, and a failure that is the PROVIDER's, which is the point of the assertion. var name = SetRef("Host=127.0.0.1;Port=1;Database=x;Username=u;Password=p;Timeout=2"); var result = await new SqlDriverProbe().ProbeAsync( $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", TimeSpan.FromSeconds(10), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldNotContain(nameof(InvalidOperationException)); } // ---- helpers ---- /// A config blob whose connectionStringRef resolves (for the life of the returned scope) to the /// fixture's real database, so the probe's SELECT 1 actually runs. private static string ConfigJson(SqlitePollFixture fixture) => ConfigJson(connectionString: fixture.ConnectionString); private static string ConfigWithProviderAsNumber(SqlitePollFixture fixture) { var name = SetRef(fixture.ConnectionString); return $$"""{"provider":0,"connectionStringRef":"{{name}}"}"""; } /// /// Builds a config JSON whose connectionStringRef is provisioned to /// via the process environment — the exact resolution path the /// factory uses. The env var is set for the test process; each call mints a unique ref so parallel /// tests cannot collide, and the value is never unset (a harmless per-run leak of a random name). /// private static string ConfigJson(string connectionString) { var name = SetRef(connectionString); return $$"""{"provider":"SqlServer","connectionStringRef":"{{name}}"}"""; } private static string SetRef(string connectionString) { var name = $"OtOpcUaSqlProbe{Guid.NewGuid():N}"; Environment.SetEnvironmentVariable( SqlConnectionStringResolver.EnvironmentVariableFor(name), connectionString); return name; } /// A whose connections throw on open — a database that cannot be /// reached, without needing a real unreachable endpoint. private sealed class FailingFactory(string message) : DbProviderFactory { public override DbConnection CreateConnection() => new FailingConnection(message); } /// Like , but its open failure embeds the connection string in the /// thrown exception's message — the realistic provider shape that a naive ex.Message would leak. private sealed class LeakyFailingFactory : DbProviderFactory { public override DbConnection CreateConnection() => new FailingConnection(message: null); } private sealed class FailingConnection(string? message) : DbConnection { [System.Diagnostics.CodeAnalysis.AllowNull] public override string ConnectionString { get; set; } = string.Empty; public override string Database => string.Empty; public override string DataSource => string.Empty; public override string ServerVersion => string.Empty; public override ConnectionState State => ConnectionState.Closed; // A null ctor message means "embed the connection string" — the leaky provider shape. private string FailureMessage => message ?? $"connect failed for {ConnectionString}"; public override void Open() => throw new InvalidOperationException(FailureMessage); public override Task OpenAsync(CancellationToken cancellationToken) => throw new InvalidOperationException(FailureMessage); public override void Close() { } public override void ChangeDatabase(string databaseName) { } protected override DbTransaction BeginDbTransaction(System.Data.IsolationLevel isolationLevel) => throw new NotSupportedException(); protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); } }