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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user