073930e023
SqlDriverProbe surfaced 'Config JSON is invalid: ' + ex.Message, where SqlDriverBrowser.ParseDocument deliberately withholds the identical message because the malformed text may itself carry a connection string. The two read the same blob shape - the AdminUI Sql picker composes a document with a top-level connectionString literal - so a malformed paste reaches both. The probe now reports the failure class only, matching the browser.
280 lines
13 KiB
C#
280 lines
13 KiB
C#
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;
|
|
|
|
/// <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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A malformed config is reported as a failure CLASS, never as the parser's own message — the same
|
|
/// discipline <c>SqlDriverBrowser.ParseDocument</c> keeps, and for the same reason: the blob the two
|
|
/// read is the one the AdminUI Sql picker composes, which carries a top-level <c>connectionString</c>
|
|
/// literal, so a malformed paste is exactly the text that reaches here.
|
|
/// <para><see cref="System.Text.Json"/>'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.</para>
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <b>production</b> dialect gate — the one seam every other test in this file bypasses.
|
|
/// <para>Each case above builds its probe with <see cref="SqlDriverProbe.ForTest"/>, which injects
|
|
/// <c>_ => dialect</c> and therefore never reaches <c>DefaultDialectFor</c>. 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 <c>InvalidOperationException</c>. Caught by the live chrome/AdminUI gate,
|
|
/// not by any test — so this asserts the gate directly.</para>
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(SqlProvider.SqlServer)]
|
|
[InlineData(SqlProvider.Postgres)]
|
|
[InlineData(SqlProvider.Oracle)]
|
|
public void ProductionDialectGate_constructsEveryShippedProvider(SqlProvider provider)
|
|
=> SqlDriverProbe.DefaultDialectForTest(provider).Provider.ShouldBe(provider);
|
|
|
|
/// <summary>
|
|
/// The falsification control for the Theory above: the two <b>reserved</b> members must still be
|
|
/// rejected. Without this, widening the gate to "return a dialect for anything" would pass.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(SqlProvider.MySql)]
|
|
[InlineData(SqlProvider.Odbc)]
|
|
public void ProductionDialectGate_stillRejectsReservedProviders(SqlProvider provider)
|
|
=> Should.Throw<InvalidOperationException>(() => SqlDriverProbe.DefaultDialectForTest(provider))
|
|
.Message.ShouldContain(provider.ToString());
|
|
|
|
/// <summary>
|
|
/// End-to-end through the <b>production constructor</b>: 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 <c>InvalidOperationException</c> thrown before any socket is opened.
|
|
/// </summary>
|
|
[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 ----
|
|
|
|
/// <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();
|
|
}
|
|
}
|