Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs
T

976 lines
48 KiB
C#

using System.Data;
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
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 the <see cref="SqlDriver"/> shell — the part <see cref="SqlPollReader"/> deliberately does not
/// own: lifecycle (initialize / reinitialize / dispose), the authored RawPath table, read-only discovery,
/// the poll-engine subscription overlay, and the health + host-connectivity surface.
/// <para><b>The health assertions are the point of this class.</b> The reader returns Bad-coded snapshots
/// rather than throwing for everything short of an unreachable server, so nothing below the shell degrades
/// health on its own: a frozen database yields all-<c>BadTimeout</c> snapshots and a perfectly successful
/// <see cref="PollGroupEngine"/> tick. If the shell does not classify what came back, the driver reports
/// <see cref="DriverState.Healthy"/> while every value is Bad — the failure mode these tests exist to
/// prevent, together with its inverse (a tag typo must NOT report the database down).</para>
/// </summary>
public sealed class SqlDriverTests
{
private const string DriverInstanceId = "sql-1";
// ---- discovery + initialize ----
[Fact]
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.DriverType.ShouldBe("Sql");
driver.DriverInstanceId.ShouldBe(DriverInstanceId);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.ShouldContain(v =>
v.Info.FullName == "Speed" && v.Info.SecurityClass == SecurityClassification.ViewOnly);
// v1 is read-only by construction, not by configuration.
driver.ShouldNotBeAssignableTo<IWritable>();
}
[Fact]
public async Task Initialize_anUnparseableTagConfig_isSkippedAndLogged_andTheRestStillServe()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture,
logger,
KvEntry("Speed", SqlitePollFixture.PresentKey),
new RawTagEntry("Broken", "not json at all", WriteIdempotent: false));
// A single malformed blob must never fail the whole driver.
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Count.ShouldBe(1);
capture.Variables[0].Info.FullName.ShouldBe("Speed");
logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("Broken"));
// …and the skipped tag resolves to nothing rather than to someone else's row.
var snapshots = await driver.ReadAsync(["Broken"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
[Fact]
public async Task Initialize_whenTheDatabaseIsUnreachable_faultsWithAnActionableErrorAndNoCredentials()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
// Mirrors ModbusDriver: the driver records Faulted AND rethrows, so DriverInstanceActor lands in
// Reconnecting and its retry-connect timer keeps trying — a database that is merely down must not
// seal as a silently-connected 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();
// Actionable: it names the endpoint the operator has to go look at…
health.LastError!.ShouldContain(NoSuchDatabaseName);
thrown.Message.ShouldContain(NoSuchDatabaseName);
// …and never the credential-bearing connection string.
health.LastError.ShouldNotContain(SecretToken);
thrown.Message.ShouldNotContain(SecretToken);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken);
}
[Fact]
public async Task ReinitializeAsync_recoversFromFaulted_onceTheDatabaseIsReachableAgain()
{
using var fixture = new SqlitePollFixture();
// SQLite creates a missing FILE on open but cannot create a missing DIRECTORY — so this connection
// string is unreachable until the directory exists, and reachable the moment it does.
var directory = Path.Combine(Path.GetTempPath(), $"otopcua-sql-driver-{Guid.NewGuid():N}");
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(directory, "late.db"),
}.ToString();
await using var driver = NewDriver(
fixture, connectionString, CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
await Should.ThrowAsync<Exception>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
try
{
Directory.CreateDirectory(directory);
await driver.ReinitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
// The authored table survived the round trip — a recovered driver still serves its tags.
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Count.ShouldBe(1);
}
finally
{
SqliteConnection.ClearAllPools();
try { Directory.Delete(directory, recursive: true); } catch (IOException) { }
}
}
// ---- IReadable delegation ----
[Fact]
public async Task ReadAsync_delegatesToTheReader_valueForValue()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Temp", SqlitePollFixture.NullValueKey),
KvEntry("Missing", SqlitePollFixture.AbsentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var reference = new SqlPollReader(
fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
maxConcurrentGroups: 4, nullIsBad: false,
resolve: rawPath => rawPath switch
{
"Speed" => KvDefinition("Speed", SqlitePollFixture.PresentKey),
"Temp" => KvDefinition("Temp", SqlitePollFixture.NullValueKey),
"Missing" => KvDefinition("Missing", SqlitePollFixture.AbsentKey),
_ => null,
});
string[] refs = ["Speed", "Temp", "Missing", "NotAuthored"];
var throughDriver = await driver.ReadAsync(refs, CancellationToken.None);
var throughReader = await reference.ReadAsync(refs, CancellationToken.None);
throughDriver.Count.ShouldBe(throughReader.Count);
for (var i = 0; i < throughDriver.Count; i++)
{
throughDriver[i].Value.ShouldBe(throughReader[i].Value);
throughDriver[i].StatusCode.ShouldBe(throughReader[i].StatusCode);
throughDriver[i].SourceTimestampUtc.ShouldBe(throughReader[i].SourceTimestampUtc);
}
}
// ---- the sustained-timeout decision ----
[Fact]
public async Task ReadAsync_whenEveryTagTimesOut_degradesHealthAndReportsTheHostStopped()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, fixture.ConnectionString, CapturingLogger.Null,
// Deliberately inverted (operationTimeout < commandTimeout) so the CLIENT-side bound fires while
// the server-side backstop would still be waiting — the reader's frozen-peer shape.
operationTimeout: TimeSpan.FromMilliseconds(500),
commandTimeout: TimeSpan.FromSeconds(30),
rawTags: [KvEntry("Speed", SqlitePollFixture.PresentKey)]);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
// A held EXCLUSIVE transaction is this rig's frozen database (see SqlPollReaderTests).
using var locker = fixture.OpenNewConnection();
using (var begin = locker.CreateCommand())
{
begin.CommandText = "BEGIN EXCLUSIVE";
begin.ExecuteNonQuery();
}
try
{
var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None);
// The reader does exactly what it promises — Bad-coded snapshots, no exception…
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
// …so the poll engine sees a clean tick, and ONLY the shell's own classification degrades health.
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastError.ShouldNotBeNullOrWhiteSpace();
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
}
finally
{
using var rollback = locker.CreateCommand();
rollback.CommandText = "ROLLBACK";
rollback.ExecuteNonQuery();
}
}
[Fact]
public async Task ReadAsync_whenTheQueryFailsAfterTheConnectionOpened_degradesButLeavesTheEndpointUp()
{
// Driver.Sql-005. BadCommunicationError is minted by GroupResult.Failed, which the reader raises only
// AFTER the connection opened ("the connection opened, so the server is up — this query is what
// failed"). Classifying it as connection-class reported the whole database endpoint Stopped over one
// group failing on a dropped table, a revoked SELECT or an over-long IN list, sending the operator at
// the network instead of at the schema. The driver must still degrade — every tag in that group is
// Bad — but the endpoint is demonstrably up.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
// The schema drifts out from under a driver whose catalog gate already passed.
fixture.Execute($"DROP TABLE {SqlitePollFixture.KeyValueTable}");
var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastError.ShouldNotBeNull();
health.LastError.ShouldContain("quer"); // names the query, not the network
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running); // RED pre-fix: Stopped
}
[Fact]
public async Task ReadAsync_anUnresolvableRef_isNotAConnectivityFact_soHealthAndHostStand()
{
// The falsifiability control for the test above: a Bad-coded batch must degrade the driver only when
// the Bad codes are connection-class. An authoring typo reporting "the database is down" would send
// an operator to the wrong system entirely.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
var snapshots = await driver.ReadAsync(["TypoedTagName"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
}
// ---- the comms-loss watchdog's evidence clock (Driver.Sql-006) ----
[Fact]
public async Task ReadAsync_whenTheDatabaseAnswersWithNoRow_advancesTheEvidenceClock_withoutChangingHealth()
{
// Driver.Sql-006. LastSuccessfulRead is what DriverInstanceActor's comms-loss watchdog reads: a
// non-Healthy driver whose clock has been frozen for 90 s is force-reconnected (Gitea #527), on the
// premise that the clock is the honest evidence the transport works. BadNoData IS that evidence —
// the query ran and the row is absent — so a driver whose key set was deleted must not be
// reconnected every 90 s against a database answering every poll on time. The health STATE stays
// exactly as it was: an absent row is a data fact, not a connectivity one.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Missing", SqlitePollFixture.AbsentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// Something has degraded the driver — the watchdog's first conjunct. Only then does the frozen clock
// matter, which is why the scenario needs both.
driver.HandlePollError(new InvalidOperationException("Reader contract violation: expected 1 snapshots"));
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
await Task.Delay(20, TestContext.Current.CancellationToken);
var snapshots = await driver.ReadAsync(["Missing"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
var health = driver.GetHealth();
health.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before); // RED pre-fix: frozen
health.State.ShouldBe(DriverState.Degraded); // classification untouched
}
[Fact]
public async Task ReadAsync_anUnresolvableRef_isNoEvidenceTheDatabaseAnswered_soTheEvidenceClockStands()
{
// The falsifiability control: "advance on anything that is not Good" would pass the test above and
// break the watchdog outright. BadNodeIdUnknown is minted by the resolver without any database
// contact at all, so it proves nothing about the transport and must leave the clock alone —
// otherwise a driver polling only typoed refs would look forever alive to the watchdog.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
await Task.Delay(20, TestContext.Current.CancellationToken);
var snapshots = await driver.ReadAsync(["TypoedTagName"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
}
[Theory]
[InlineData(SqlStatusCodes.Good)] // the row is there and readable
[InlineData(SqlStatusCodes.BadNoData)] // the query ran; the row is absent
[InlineData(SqlStatusCodes.Uncertain)] // a present row with a NULL cell (nullIsBad=false)
[InlineData(SqlStatusCodes.Bad)] // …the same row under nullIsBad=true
[InlineData(SqlStatusCodes.BadTypeMismatch)] // a cell came back and would not coerce
[InlineData(SqlStatusCodes.BadCommunicationError)] // the connection opened; the query was refused
public async Task ObservePollOutcome_everyEvidenceOfAnswerCode_advancesTheEvidenceClock(uint statusCode)
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
await Task.Delay(20, TestContext.Current.CancellationToken);
driver.ObservePollOutcome([new DataValueSnapshot(null, statusCode, DateTime.UtcNow, DateTime.UtcNow)]);
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
}
[Theory]
[InlineData(SqlStatusCodes.BadTimeout)] // the database did not answer at all
[InlineData(SqlStatusCodes.BadNodeIdUnknown)] // the resolver never reached the database
[InlineData(SqlStatusCodes.BadConfigurationError)] // the planner rejected the tag; nothing was executed
[InlineData(SqlStatusCodes.BadInternalError)] // a reader defect; no statement about the database
public async Task ObservePollOutcome_aCodeThatIsNoEvidenceOfAnAnswer_leavesTheEvidenceClockAlone(
uint statusCode)
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
await Task.Delay(20, TestContext.Current.CancellationToken);
driver.ObservePollOutcome([new DataValueSnapshot(null, statusCode, DateTime.UtcNow, DateTime.UtcNow)]);
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
}
// ---- Driver.Sql-010: the health field is read-modify-written from several poll loops ----
[Fact]
public async Task HealthTransitions_fromConcurrentPolls_neverRewindTheEvidenceClock()
{
// Driver.Sql-010. Every health transition is a read-modify-write of one field: Degrade reads the
// current snapshot to PRESERVE LastSuccessfulRead, then writes a new record. With a plain
// Volatile read + write, a Degrade that read the clock at t0 can be published after a concurrent
// advance to t1 — so the clock goes BACKWARDS, and the advance is lost. That is not cosmetic: the
// comms-loss watchdog measures how long the clock has been frozen, so a lost advance is a spurious
// 90 s reconnect. Monotonicity is the observable the lost update violates, and it is exactly what a
// compare-and-swap guarantees.
//
// A driver with several subscriptions runs one poll loop each, plus any ad-hoc ReadAsync — so two
// classifications landing at once is the ordinary case, not a contrived one.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
const int iterations = 30_000;
var advancing = new DataValueSnapshot(1.0, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow);
var degrading = new DataValueSnapshot(null, SqlStatusCodes.BadTimeout, DateTime.UtcNow, DateTime.UtcNow);
using var done = new CancellationTokenSource();
var rewinds = 0;
var token = TestContext.Current.CancellationToken;
var observer = Task.Run(
() =>
{
var highWater = driver.GetHealth().LastSuccessfulRead ?? DateTime.MinValue;
while (!done.IsCancellationRequested)
{
var seen = driver.GetHealth().LastSuccessfulRead ?? DateTime.MinValue;
if (seen < highWater) Interlocked.Increment(ref rewinds);
else highWater = seen;
}
},
token);
var writers = new[]
{
Task.Run(
() => { for (var i = 0; i < iterations; i++) driver.ObservePollOutcome([advancing]); }, token),
Task.Run(
() => { for (var i = 0; i < iterations; i++) driver.ObservePollOutcome([degrading]); }, token),
};
await Task.WhenAll(writers);
await done.CancelAsync();
await observer;
Volatile.Read(ref rewinds).ShouldBe(0);
}
// ---- ISubscribable (poll-engine overlay) ----
[Fact]
public async Task SubscribeAsync_deliversAnInitialChange_andUnsubscribeStopsThePolling()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var first = new TaskCompletionSource<DataChangeEventArgs>(
TaskCreationOptions.RunContinuationsAsynchronously);
var changes = 0;
driver.OnDataChange += (_, args) =>
{
Interlocked.Increment(ref changes);
first.TrySetResult(args);
};
var handle = await driver.SubscribeAsync(
["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
var initial = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
initial.FullReference.ShouldBe("Speed");
initial.Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
initial.SubscriptionHandle.ShouldBe(handle);
await driver.UnsubscribeAsync(handle, CancellationToken.None);
driver.ActiveSubscriptionCount.ShouldBe(0);
// Unsubscribe awaits the loop task, so nothing may arrive after it returns.
var afterUnsubscribe = Volatile.Read(ref changes);
await Task.Delay(400, TestContext.Current.CancellationToken);
Volatile.Read(ref changes).ShouldBe(afterUnsubscribe);
}
// ---- disposal ----
[Fact]
public async Task DisposeAsync_stopsThePollEngine_andIsIdempotent()
{
using var fixture = new SqlitePollFixture();
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var changes = 0;
driver.OnDataChange += (_, _) => Interlocked.Increment(ref changes);
_ = await driver.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await Task.Delay(300, TestContext.Current.CancellationToken);
await driver.DisposeAsync();
driver.ActiveSubscriptionCount.ShouldBe(0);
var afterDispose = Volatile.Read(ref changes);
await Task.Delay(400, TestContext.Current.CancellationToken);
Volatile.Read(ref changes).ShouldBe(afterDispose);
// Idempotent: an `await using` after an explicit ShutdownAsync/DisposeAsync must not throw.
await driver.ShutdownAsync(CancellationToken.None);
await Should.NotThrowAsync(async () => await driver.DisposeAsync());
}
// ---- host identity ----
[Fact]
public async Task GetHostStatuses_namesTheServerAndDatabase_butNeverTheCredentials()
{
using var fixture = new SqlitePollFixture();
const string connectionString =
"Server=sqlsrv01;Initial Catalog=MesStaging;User ID=svc_ot;Password=" + SecretToken + ";";
await using var driver = NewDriver(fixture, connectionString, CapturingLogger.Null);
var hosts = driver.GetHostStatuses();
hosts.Count.ShouldBe(1);
hosts[0].HostName.ShouldContain("sqlsrv01");
hosts[0].HostName.ShouldContain("MesStaging");
hosts[0].HostName.ShouldNotContain(SecretToken);
hosts[0].HostName.ShouldNotContain("svc_ot");
hosts[0].State.ShouldBe(HostState.Unknown); // nothing has been attempted yet
}
[Fact]
public async Task OnHostStatusChanged_firesOnceOnTheRunningTransition()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
var transitions = new List<HostStatusChangedEventArgs>();
driver.OnHostStatusChanged += (_, args) => { lock (transitions) { transitions.Add(args); } };
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// A second successful poll must not re-raise — the event is for transitions, not for ticks.
await driver.ReadAsync(["Speed"], CancellationToken.None);
lock (transitions)
{
transitions.Count.ShouldBe(1);
transitions[0].OldState.ShouldBe(HostState.Unknown);
transitions[0].NewState.ShouldBe(HostState.Running);
}
}
// ---- 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 leave the classification ReadAsync
// already made alone — 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.
// Driven through the REAL round trip rather than by handing HandlePollError a bare exception: the
// deferral is owned by ReadAsync marking the throw, not by the exception's type (Driver.Sql-004).
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
var factory = new BreakableFactory(fixture.Factory);
await using var driver = NewDriver(
fixture, fixture.ConnectionString, logger, factory, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
factory.BreakByThrowingOnOpen("connection failed; Password=" + SecretToken);
var thrown = await Should.ThrowAsync<DbException>(
async () => await driver.ReadAsync(["Speed"], CancellationToken.None));
var classified = driver.GetHealth();
classified.State.ShouldBe(DriverState.Degraded);
classified.LastError.ShouldNotBeNull();
classified.LastError.ShouldContain("could not reach");
var warningsAfterRead = logger.Entries.Count(e => e.Level == LogLevel.Warning);
driver.HandlePollError(thrown);
var health = driver.GetHealth();
// Untouched: HandlePollError left the exception to ReadAsync, so it neither re-degraded nor leaked…
health.LastError.ShouldNotBeNull().ShouldBe(classified.LastError);
health.LastError.ShouldNotContain(SecretToken);
// …and it logged nothing (ReadAsync owns the single outage warning).
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(warningsAfterRead);
}
[Fact]
public async Task HandlePollError_forANonDbExceptionReadAsyncClassified_stillDefers_soTheGoodLastErrorSurvives()
{
// Driver.Sql-004. Not every unreachable-database throw is a DbException: the reader raises a plain
// InvalidOperationException when the provider factory hands back no connection, and a provider can
// raise ArgumentException/FormatException off a malformed connection string on the same open path.
// ReadAsync classifies those exactly as it classifies a DbException — endpoint-bearing LastError,
// host Stopped — and rethrows, so HandlePollError must defer to it here too. RED against the
// `ex is DbException` type test, which re-degraded with the guidance-free "poll failed: TypeName"
// and logged the outage twice.
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
var factory = new BreakableFactory(fixture.Factory);
await using var driver = NewDriver(
fixture, fixture.ConnectionString, logger, factory, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
factory.BreakByReturningNoConnection();
var thrown = await Should.ThrowAsync<InvalidOperationException>(
async () => await driver.ReadAsync(["Speed"], CancellationToken.None));
var classified = driver.GetHealth();
classified.State.ShouldBe(DriverState.Degraded);
classified.LastError.ShouldNotBeNull();
classified.LastError.ShouldContain("could not reach");
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
var warningsAfterRead = logger.Entries.Count(e => e.Level == LogLevel.Warning);
driver.HandlePollError(thrown);
var health = driver.GetHealth();
health.LastError.ShouldNotBeNull()
.ShouldBe(classified.LastError); // the richer message survived…
health.LastError.ShouldNotContain("poll failed"); // …and was not overwritten by the poor one
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(warningsAfterRead);
}
[Fact]
public async Task HandlePollError_forAnExceptionReadAsyncNeverSaw_degrades_evenWhenItIsADbException()
{
// The falsifiability control for the two deferrals above: the marker is per-exception, not per-type,
// so a DbException that never went through ReadAsync's classification is NOT owned by it and must
// still reach the health surface. A type test would silently swallow this one.
using var fixture = new SqlitePollFixture();
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
driver.HandlePollError(new FakeDbException("connection failed; Password=" + SecretToken));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastError.ShouldNotBeNull();
health.LastError.ShouldNotContain(SecretToken); // type only, never the message text
health.LastError.ShouldContain(nameof(FakeDbException));
}
[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);
}
[Fact]
public void HandlePollError_forANonDbExceptionCarryingCredentials_degradesWithoutLeakingThem()
{
// The residual C1 leg HandlePollError owns: a NON-DbException raised from provider interaction —
// canonically the ArgumentException a malformed connection string's keyword parser throws, echoing
// credential fragments (the exact unquoted-';'-in-password vector this whole fix exists for). It is
// not the DbException class ReadAsync owns, so it reaches Degrade — and must arrive TYPE-only, never
// ex.Message. Pre-fix this degraded with the raw message and leaked the token.
using var fixture = new SqlitePollFixture();
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
driver.HandlePollError(new ArgumentException(
"Keyword not supported: 'secrettail;connect timeout'. Password=" + SecretToken));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastError.ShouldNotBeNull();
health.LastError.ShouldNotContain(SecretToken); // never the message text
health.LastError.ShouldContain(nameof(ArgumentException)); // but still actionable (the type)
}
// ---- 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>
private const string SecretToken = "hunter2-do-not-log";
/// <summary>The unreachable connection string's database file name, used as the actionable-error probe.</summary>
private const string NoSuchDatabaseName = "otopcua-sql-no-such-dir";
/// <summary>
/// The driver's <c>DriverConfig</c> JSON. The shell serves its typed options (the factory, Task 9,
/// owns parsing) exactly as <c>ModbusDriver</c> does, so this is deliberately inert.
/// </summary>
private const string ConfigJson = """{"provider":"SqlServer"}""";
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
=> NewDriver(fixture, CapturingLogger.Null, rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
=> NewDriver(fixture, fixture.ConnectionString, logger, rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, string connectionString, CapturingLogger logger, params RawTagEntry[] rawTags)
=> NewDriver(
fixture, connectionString, logger,
operationTimeout: TimeSpan.FromSeconds(15), commandTimeout: TimeSpan.FromSeconds(10),
rawTags: rawTags);
/// <summary>The same driver, over a caller-supplied provider factory — the outage-injection seam.</summary>
private static SqlDriver NewDriver(
SqlitePollFixture fixture,
string connectionString,
CapturingLogger logger,
DbProviderFactory factory,
params RawTagEntry[] rawTags)
=> new(
new SqlDriverOptions
{
RawTags = rawTags,
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new SqliteDialect(),
connectionString,
factory: factory,
logger: logger);
/// <summary>
/// Builds the driver through its production constructor. That constructor <b>is</b> the injection
/// seam — dialect, provider factory and already-resolved connection string are all parameters — so no
/// test-only factory is needed on the product type (Task 9 resolves the same three from config).
/// </summary>
private static SqlDriver NewDriver(
SqlitePollFixture fixture,
string connectionString,
CapturingLogger logger,
TimeSpan operationTimeout,
TimeSpan commandTimeout,
IReadOnlyList<RawTagEntry> rawTags)
=> new(
new SqlDriverOptions
{
RawTags = rawTags,
OperationTimeout = operationTimeout,
CommandTimeout = commandTimeout,
},
DriverInstanceId,
new SqliteDialect(),
connectionString,
factory: fixture.Factory,
logger: logger);
/// <summary>A connection string whose database cannot be opened — the directory does not exist.</summary>
private static string Unreachable() => new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(
Path.GetTempPath(), $"{NoSuchDatabaseName}-{Guid.NewGuid():N}", "db.sqlite"),
Password = SecretToken,
}.ToString();
/// <summary>One authored raw tag: a key-value <c>TagConfig</c> blob over the fixture's EAV table.</summary>
private static RawTagEntry KvEntry(string rawPath, string keyValue)
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
{
"driver": "Sql",
"model": "KeyValue",
"table": "{{SqlitePollFixture.KeyValueTable}}",
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
"keyValue": "{{keyValue}}",
"valueColumn": "{{SqlitePollFixture.ValueColumn}}",
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
}
"""), 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,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn);
/// <summary>Records everything the driver streams into the address space.</summary>
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
/// <summary>The folders created, in order.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
/// <summary>The variables registered, in order.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add((browseName, displayName));
return this;
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullReference) : IVariableHandle
{
public string FullReference => fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
/// <summary>Captures the driver's log so "skipped and logged" can be asserted rather than assumed.</summary>
private sealed class CapturingLogger : ILogger<SqlDriver>
{
/// <summary>A logger that records nothing — for the tests that do not assert on logging.</summary>
public static CapturingLogger Null { get; } = new();
/// <summary>Every record written, level + rendered message.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
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 provider factory that behaves exactly like the fixture's until it is broken, then reproduces one
/// of the two open-path outages the driver has to classify: a factory that returns <b>no</b>
/// connection (a plain <see cref="InvalidOperationException"/> from the reader — NOT a
/// <see cref="DbException"/>), or one whose connections throw on open.
/// </summary>
private sealed class BreakableFactory(DbProviderFactory inner) : DbProviderFactory
{
private Func<DbConnection?>? _broken;
/// <summary>Hands back no connection, the shape that makes the reader raise InvalidOperationException.</summary>
public void BreakByReturningNoConnection() => Volatile.Write(ref _broken, static () => null);
/// <summary>Hands back a connection that fails to open with <paramref name="message"/>.</summary>
/// <param name="message">The provider message, which may carry credentials.</param>
public void BreakByThrowingOnOpen(string message)
=> Volatile.Write(ref _broken, () => new ThrowingConnection(message));
public override DbConnection? CreateConnection()
=> Volatile.Read(ref _broken) is { } broken ? broken() : inner.CreateConnection();
}
/// <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();
}
}