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,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