diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs new file mode 100644 index 00000000..79c5b986 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs @@ -0,0 +1,171 @@ +using System.Data.Common; +using System.Diagnostics; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// The AdminUI "Test Connect" liveness check for the Sql driver: open a connection, run the +/// dialect's SELECT 1 () under a bounded deadline, and report +/// green with latency or red with a reason. Mirrors ModbusDriverProbe. +/// Parses the SAME factory DTO with the SAME converter as +/// (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a +/// config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised +/// provider parses here exactly as it does at the factory, and the same +/// connectionStringRef resolves the same way. +/// Never throws (the contract). Every failure — malformed JSON, a +/// missing connectionStringRef, an unprovisioned connection string, a provider open failure, a +/// timeout, a cancellation — becomes a red , so the AdminUI has nothing to +/// catch. +/// The resolved connection string never reaches the result message. A red result names the +/// failure class, never the string — the same credential discipline the factory and the driver keep. +/// +public sealed class SqlDriverProbe : IDriverProbe +{ + /// + /// Selects the provider seam by . In production this constructs the real + /// ; injects a test dialect (and factory) so the + /// probe can run against the SQLite fixture with no SQL Server and no network. + /// + private readonly Func _dialectFor; + + /// + /// Overrides the dialect's own when non-null — the injection point + /// the SQLite tests use to hand in SqliteFactory while keeping the test dialect's + /// catalog SQL. Null in production: the dialect's own factory is used. + /// + private readonly DbProviderFactory? _factoryOverride; + + /// Initializes a new that constructs the real SQL Server dialect. + public SqlDriverProbe() + : this(DefaultDialectFor, factoryOverride: null) + { + } + + private SqlDriverProbe(Func dialectFor, DbProviderFactory? factoryOverride) + { + _dialectFor = dialectFor; + _factoryOverride = factoryOverride; + } + + /// + /// Test seam: a probe that always uses and opens connections from + /// , so the SQLite fixture's create → open → SELECT 1 → close cycle + /// runs unmodified. + /// + /// The provider factory the probe opens connections from. + /// The dialect whose the probe runs. + /// A probe wired to the supplied factory + dialect. + /// A required argument is null. + internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect) + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(dialect); + return new SqlDriverProbe(_ => dialect, factory); + } + + /// + public string DriverType => SqlDriver.DriverTypeName; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + // Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt. + SqlDriverConfigDto? dto; + try + { + dto = System.Text.Json.JsonSerializer.Deserialize( + configJson, SqlDriverFactoryExtensions.JsonOptions); + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException) + { + return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null); + } + + if (dto is null) + return new DriverProbeResult(false, "Config JSON deserialized to null.", null); + if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) + return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null); + + ISqlDialect dialect; + string connectionString; + try + { + dialect = _dialectFor(dto.Provider); + connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); + } + catch (InvalidOperationException ex) + { + // Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is + // safe and actionable (it names the environment variable the operator must set). + return new DriverProbeResult( + false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null); + } + + var factory = _factoryOverride ?? dialect.Factory; + return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false); + } + + /// + /// Opens one connection under a linked CTS bounded by , runs the dialect's + /// liveness statement, and returns green with latency. Any failure becomes a red result whose message + /// names the failure class — never the connection string (a provider exception can embed the + /// data source, so its .Message is deliberately not surfaced). + /// + private static async Task RunLivenessAsync( + DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout, + CancellationToken ct) + { + var stopwatch = Stopwatch.StartNew(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout); + + try + { + var connection = factory.CreateConnection() + ?? throw new InvalidOperationException( + $"the {dialect.Provider} provider factory returned no connection."); + await using (connection.ConfigureAwait(false)) + { + connection.ConnectionString = connectionString; + await connection.OpenAsync(cts.Token).ConfigureAwait(false); + + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = dialect.LivenessSql; + await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false); + } + } + + stopwatch.Stop(); + return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return new DriverProbeResult(false, "Probe cancelled.", null); + } + catch (OperationCanceledException) + { + return new DriverProbeResult( + false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); + } + catch (Exception ex) + { + // The provider's own message can carry the data source (host/database), which is not a secret but + // is more than the operator needs — and keeping the message to a fixed class is what guarantees no + // credential-bearing connection string can ever slip through. Name the exception TYPE only. + return new DriverProbeResult( + false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null); + } + } + + /// Constructs the production dialect for a provider; v1 supports SQL Server only. + private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch + { + SqlProvider.SqlServer => new SqlServerDialect(), + _ => throw new InvalidOperationException( + $"provider '{provider}' is not available in this build (only SqlServer is supported)."), + }; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs new file mode 100644 index 00000000..7092695d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs @@ -0,0 +1,198 @@ +using System.Data; +using System.Data.Common; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +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(); + } + + [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(); + } + + // ---- 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(); + } +}