fix(sql): stop leaking provider exception text; de-dup health classification; guard Faulted

C1: SqlDriver's Initialize/Read failure paths no longer interpolate the ADO.NET
provider's ex.Message into LastError, the thrown message, or host status — only
ex.GetType().Name reaches the operator surface; the full exception goes to the log
sink via the structured logger's exception parameter. The driver is dialect-agnostic
over an arbitrary DbProviderFactory, so it cannot rely on any provider's message
discipline (Microsoft.Data.SqlClient's parser echoes an unrecognised keyword
lower-cased, which a value-based redactor misses).

C1a: replace the vacuous credential test (SQLite's "unable to open" message never
contains the connection string, so it passed regardless) with one that fabricates a
DbException whose own .Message carries a credential token and drives it through the
Initialize liveness-failure path via the injectable factory seam — red against the
pre-fix ex.Message interpolation.

I1: HandlePollError now ignores the DbException class ReadAsync already classified,
so a subscribed-read outage is classified (and logged) once, not twice with the
worse message.

I2: the poll's Healthy verdict routes through a shared SetHealthUnlessFaulted guard
(the one Degrade already used), so a late in-flight poll cannot un-fault a driver a
concurrent ReinitializeAsync just faulted.

I3: DiscoverAsync warns when materializing an omitted-type tag as String, the only
operator signal for the declared-vs-published type mismatch on a numeric column.

M1: BuildTagTable wraps the per-entry parse so a stray non-JsonException throw skips
the tag instead of stranding health at Initializing (it runs before Initialize's
try/catch). M2 left as a documented TODO to avoid touching SqlPollReader.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 15:28:17 -04:00
parent 443b718158
commit 37f444b99c
2 changed files with 261 additions and 13 deletions
@@ -1,3 +1,4 @@
using System.Data;
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;
@@ -342,6 +343,132 @@ public sealed class SqlDriverTests
}
}
// ---- C1a: the credential guarantee, proven load-bearing ----
[Fact]
public async Task Initialize_whenTheProviderExceptionCarriesCredentials_leaksThemIntoNoOperatorSurface()
{
// The vacuous sibling test drives SQLite's "unable to open database file" path, whose message
// structurally never contains the connection string — so it passes with or without any defensive
// code. This one fabricates a DbException whose OWN .Message carries a credential-like token and
// drives it through Initialize's liveness-failure path via the injectable factory seam. It is RED
// against the pre-fix code that interpolated ex.Message into LastError and the thrown message.
const string leakyMessage = "login failed for user; Password=" + SecretToken;
var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new SqliteDialect(),
// A credential-free connection string: the ONLY place the token can come from is the exception.
"Server=sqlsrv01;Initial Catalog=Mes",
factory: new ThrowingConnectionFactory(leakyMessage),
logger: CapturingLogger.Null);
await using var _ = driver;
var thrown = await Should.ThrowAsync<Exception>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Faulted);
health.LastError.ShouldNotBeNullOrWhiteSpace();
// The token appears in NEITHER the operator-facing LastError NOR the thrown message NOR host status.
health.LastError!.ShouldNotContain(SecretToken);
thrown.Message.ShouldNotContain(SecretToken);
driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
// …and it is still actionable: it names the endpoint the operator has to go look at.
health.LastError.ShouldContain("sqlsrv01");
}
// ---- I1: no double health-classification on a subscribed-read outage ----
[Fact]
public async Task HandlePollError_forADbException_defersToReadAsync_soHealthIsClassifiedOnce()
{
// On a subscribed-read outage the reader throws a DbException; ReadAsync classifies health with a
// good, endpoint-bearing message + host Stopped and rethrows the SAME exception, which the poll
// engine then routes to HandlePollError. HandlePollError must ignore the DbException class ReadAsync
// owns — pre-fix it re-degraded with the bare ex.Message (the WORSE of the two LastErrors, and a
// credential-leak path) and logged the outage a second time.
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(fixture, logger, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.HandlePollError(new FakeDbException("connection failed; Password=" + SecretToken));
var health = driver.GetHealth();
// Untouched: HandlePollError left the DbException to ReadAsync, so it neither degraded nor leaked…
health.State.ShouldBe(DriverState.Healthy);
health.LastError.ShouldBeNull();
// …and it logged nothing (ReadAsync owns the single outage warning).
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(0);
}
[Fact]
public void HandlePollError_forAnEngineContractViolation_stillDegrades()
{
// The falsifiability control for the guard above: a non-DbException (the poll engine's own
// reader-contract-violation throw) is NOT owned by ReadAsync, so it must still reach the health
// surface. The guard skips only the DbException connection class.
using var fixture = new SqlitePollFixture();
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
driver.HandlePollError(new InvalidOperationException("Reader contract violation: expected 1 snapshots"));
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
}
// ---- I2: a late poll cannot un-fault a Faulted driver ----
[Fact]
public async Task ObservePollOutcome_whenALateGoodPollLands_cannotUnFaultTheDriver()
{
// The engine's teardown waits ~5 s for loops to wind down, but a poll may still be in flight up to
// the 15 s default operationTimeout — so a poll can complete AFTER a concurrent ReinitializeAsync has
// recorded Faulted. If that stale poll reports all-Good it must NOT flip Faulted back to Healthy: only
// a successful (re)initialize clears Faulted. RED against the pre-fix unconditional Healthy write.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
await Should.ThrowAsync<Exception>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
// The stale in-flight poll completes with an all-Good batch.
driver.ObservePollOutcome(
[new DataValueSnapshot(42, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)]);
driver.GetHealth().State.ShouldBe(DriverState.Faulted); // stayed Faulted
}
// ---- I3: an omitted-type tag warns at discovery ----
[Fact]
public async Task Discover_warnsForAnOmittedTypeTag_butNotForATypedOne()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger,
KvEntry("Speed", SqlitePollFixture.PresentKey), // no "type" → discovered String
TypedKvEntry("Rpm", SqlitePollFixture.PresentKey, "Int32")); // explicit "type"
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
logger.Entries.ShouldContain(e =>
e.Level == LogLevel.Warning && e.Message.Contains("Speed") && e.Message.Contains("no authored"));
logger.Entries.ShouldNotContain(e =>
e.Level == LogLevel.Warning && e.Message.Contains("Rpm") && e.Message.Contains("no authored"));
}
// ---- helpers ----
/// <summary>A distinctive password token: any assertion that finds it has found a credential leak.</summary>
@@ -417,6 +544,21 @@ public sealed class SqlDriverTests
}
"""), WriteIdempotent: false);
/// <summary>A key-value entry carrying an explicit <c>"type"</c> — so <c>DeclaredType</c> is non-null.</summary>
private static RawTagEntry TypedKvEntry(string rawPath, string keyValue, string type)
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
{
"driver": "Sql",
"model": "KeyValue",
"table": "{{SqlitePollFixture.KeyValueTable}}",
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
"keyValue": "{{keyValue}}",
"valueColumn": "{{SqlitePollFixture.ValueColumn}}",
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}",
"type": "{{type}}"
}
"""), WriteIdempotent: false);
/// <summary>The same tag as <see cref="KvEntry"/>, already typed — for the reference reader.</summary>
private static SqlTagDefinition KvDefinition(string rawPath, string keyValue)
=> new(rawPath, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
@@ -487,4 +629,47 @@ public sealed class SqlDriverTests
public void Dispose() { }
}
}
/// <summary>
/// A <see cref="DbException"/> whose message is under the test's control — stands in for the
/// credential-echoing exceptions a real ADO.NET provider can raise, so the credential-hygiene
/// guarantee can be exercised without a live database.
/// </summary>
private sealed class FakeDbException(string message) : DbException(message);
/// <summary>
/// A <see cref="DbProviderFactory"/> whose connections always fail to open, with a
/// caller-supplied (credential-bearing) message — the seam that drives the driver's
/// unreachable-database path with a fabricated provider exception.
/// </summary>
private sealed class ThrowingConnectionFactory(string openFailureMessage) : DbProviderFactory
{
public override DbConnection CreateConnection() => new ThrowingConnection(openFailureMessage);
}
/// <summary>A connection that throws <see cref="FakeDbException"/> on open; every other member is inert.</summary>
private sealed class ThrowingConnection(string openFailureMessage) : 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;
public override void Open() => throw new FakeDbException(openFailureMessage);
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
public override void Close() { }
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => throw new NotSupportedException();
}
}