feat(sql): SqlDriverProbe SELECT-1 liveness check
The AdminUI "Test Connect" probe for the Sql driver: open a connection, run the dialect's LivenessSql (SELECT 1) under a linked-CTS deadline, return green with latency or red with a reason. Implements IDriverProbe; never throws (malformed JSON, missing connectionStringRef, unprovisioned connection string, a provider open failure, timeout, cancellation all become red results). Parses the SAME factory DTO with the SAME JsonStringEnumConverter as SqlDriverFactoryExtensions (R2-11 factory parity, mirroring ModbusDriverProbe) and resolves connectionStringRef the same way, so a config that Test-Connects is the config that Deploys. Credential hygiene: the resolved connection string never reaches the result message. A provider exception can embed the data source in its OWN message (the real SqlException shape), so the catch-all names the exception TYPE only, never ex.Message. The regression test's fake connection embeds the connection string in its thrown message specifically so surfacing ex.Message would leak it — verified load-bearing by breaking the guard and watching it go red. ForTest injects factory+dialect for the offline SQLite fixture path. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The AdminUI "Test Connect" liveness check for the <c>Sql</c> driver: open a connection, run the
|
||||||
|
/// dialect's <c>SELECT 1</c> (<see cref="ISqlDialect.LivenessSql"/>) under a bounded deadline, and report
|
||||||
|
/// green with latency or red with a reason. Mirrors <c>ModbusDriverProbe</c>.
|
||||||
|
/// <para><b>Parses the SAME factory DTO with the SAME converter as
|
||||||
|
/// <see cref="SqlDriverFactoryExtensions"/></b> (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
|
||||||
|
/// <c>provider</c> parses here exactly as it does at the factory, and the same
|
||||||
|
/// <c>connectionStringRef</c> resolves the same way.</para>
|
||||||
|
/// <para><b>Never throws</b> (the <see cref="IDriverProbe"/> contract). Every failure — malformed JSON, a
|
||||||
|
/// missing <c>connectionStringRef</c>, an unprovisioned connection string, a provider open failure, a
|
||||||
|
/// timeout, a cancellation — becomes a red <see cref="DriverProbeResult"/>, so the AdminUI has nothing to
|
||||||
|
/// catch.</para>
|
||||||
|
/// <para><b>The resolved connection string never reaches the result message.</b> A red result names the
|
||||||
|
/// failure class, never the string — the same credential discipline the factory and the driver keep.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlDriverProbe : IDriverProbe
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Selects the provider seam by <see cref="SqlProvider"/>. In production this constructs the real
|
||||||
|
/// <see cref="SqlServerDialect"/>; <see cref="ForTest"/> injects a test dialect (and factory) so the
|
||||||
|
/// probe can run against the SQLite fixture with no SQL Server and no network.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Func<SqlProvider, ISqlDialect> _dialectFor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overrides the dialect's own <see cref="ISqlDialect.Factory"/> when non-null — the injection point
|
||||||
|
/// the SQLite tests use to hand in <c>SqliteFactory</c> while keeping the test dialect's
|
||||||
|
/// catalog SQL. Null in production: the dialect's own factory is used.
|
||||||
|
/// </summary>
|
||||||
|
private readonly DbProviderFactory? _factoryOverride;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new <see cref="SqlDriverProbe"/> that constructs the real SQL Server dialect.</summary>
|
||||||
|
public SqlDriverProbe()
|
||||||
|
: this(DefaultDialectFor, factoryOverride: null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqlDriverProbe(Func<SqlProvider, ISqlDialect> dialectFor, DbProviderFactory? factoryOverride)
|
||||||
|
{
|
||||||
|
_dialectFor = dialectFor;
|
||||||
|
_factoryOverride = factoryOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test seam: a probe that always uses <paramref name="dialect"/> and opens connections from
|
||||||
|
/// <paramref name="factory"/>, so the SQLite fixture's create → open → <c>SELECT 1</c> → close cycle
|
||||||
|
/// runs unmodified.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="factory">The provider factory the probe opens connections from.</param>
|
||||||
|
/// <param name="dialect">The dialect whose <see cref="ISqlDialect.LivenessSql"/> the probe runs.</param>
|
||||||
|
/// <returns>A probe wired to the supplied factory + dialect.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
|
||||||
|
internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(factory);
|
||||||
|
ArgumentNullException.ThrowIfNull(dialect);
|
||||||
|
return new SqlDriverProbe(_ => dialect, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverType => SqlDriver.DriverTypeName;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<DriverProbeResult> 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<SqlDriverConfigDto>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens one connection under a linked CTS bounded by <paramref name="timeout"/>, runs the dialect's
|
||||||
|
/// liveness statement, and returns green with latency. Any failure becomes a red result whose message
|
||||||
|
/// names the failure class — <b>never the connection string</b> (a provider exception can embed the
|
||||||
|
/// data source, so its <c>.Message</c> is deliberately not surfaced).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<DriverProbeResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
|
||||||
|
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)."),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proves <see cref="SqlDriverProbe"/> — the AdminUI "Test Connect" liveness check — against the real
|
||||||
|
/// SQLite fixture. A probe opens a connection, runs the dialect's <c>SELECT 1</c>, and reports green with
|
||||||
|
/// latency or red with a reason; it <b>never throws</b> (the <see cref="IDriverProbe"/> contract).
|
||||||
|
/// <para>The probe parses the SAME factory DTO shape as <see cref="SqlDriverFactoryExtensions"/> (R2-11
|
||||||
|
/// factory parity, mirroring <c>ModbusDriverProbe</c>), so a config that Tests-green and a config that
|
||||||
|
/// Deploys are the same config. It resolves the <c>connectionStringRef</c> the same way too — and the
|
||||||
|
/// tests below assert the resolved connection string never reaches the red-result message.</para>
|
||||||
|
/// </summary>
|
||||||
|
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 ----
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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}}"}""";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a config JSON whose <c>connectionStringRef</c> is provisioned to
|
||||||
|
/// <paramref name="connectionString"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A <see cref="DbProviderFactory"/> whose connections throw on open — a database that cannot be
|
||||||
|
/// reached, without needing a real unreachable endpoint.</summary>
|
||||||
|
private sealed class FailingFactory(string message) : DbProviderFactory
|
||||||
|
{
|
||||||
|
public override DbConnection CreateConnection() => new FailingConnection(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Like <see cref="FailingFactory"/>, but its open failure embeds the connection string in the
|
||||||
|
/// thrown exception's message — the realistic provider shape that a naive <c>ex.Message</c> would leak.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user