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();
+ }
+}