a68593e714
SqlTagModel.Query (design §5.4) is now served end to end instead of refused. The driver config declares `queries` (name -> sql, params, maxRows); a tag names the query, the result `column` it reads and optionally a `rowKey` picking its row. - Parser/deploy gate: query + column required, rowKey all-or-nothing; Inspect explains each refusal. Table is not read for this model. - SqlReadOnlyStatement: single SELECT/WITH only, no data/schema-changing keyword outside literals/comments (refuses SELECT INTO, FOR UPDATE, DML CTEs). Shared by the factory and the AdminUI driver form (SqlNamedQueryRules.Describe), so the form refuses what the driver refuses. One bad entry fails the driver. - Parameters are always bound; the factory refuses a declared parameter whose dialect marker the statement never uses. - Reader: one execution per query per poll, inside a transaction that is always rolled back; result columns matched exact-then-unique-CI; no row -> BadNoData, ambiguous row / missing column / over maxRows -> BadConfigurationError. - Catalog gate passes Query tags through (nothing is quoted into SQL); a Query-only driver skips the catalog load. A tag naming an undeclared query is skipped with the reason on /hosts. - AdminUI: typed editor offers Query (query/column/rowKey); driver form edits `queries` as JSON and keeps the last valid set on a typo. (lmxopcua#753) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
1188 lines
59 KiB
C#
1188 lines
59 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_andRestoresHealthy()
|
|
{
|
|
// 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. And since the database
|
|
// answered, the driver is Healthy again (lane rule: a refusal means the device answered — it used to
|
|
// leave the Degraded latched behind every refusal-only poll).
|
|
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.Healthy); // the database answered
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>R06:S-1.</b> An over-cap result set publishes BadConfigurationError — the code the planner also
|
|
/// mints before any I/O, which on its own is NOT answer evidence (theory above). The reader's
|
|
/// answered-refusal count is what makes it count: the database answered, so the clock advances and a
|
|
/// comms-Degraded driver is Healthy again. Control: the same snapshot with no count leaves both alone.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ObservePollOutcome_aRowCapRefusal_isAnswerEvidence_andRestoresHealthy()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
var refused = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, DateTime.UtcNow);
|
|
|
|
driver.ObservePollOutcome([new DataValueSnapshot(null, SqlStatusCodes.BadTimeout, null, DateTime.UtcNow)]);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
|
await Task.Delay(20, TestContext.Current.CancellationToken);
|
|
|
|
driver.ObservePollOutcome([refused]); // no count: planner-minted, no evidence
|
|
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
|
|
|
driver.ObservePollOutcome([refused], answeredRefusals: 1); // the database answered, over-cap
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>R06:S-9 / lifecycle generations.</b> A poll that started before a Shutdown → Initialize cycle
|
|
/// (it can outlive the engine's 5 s teardown wait) must not write its verdict onto the NEW
|
|
/// configuration's health. Control: the same BadTimeout verdict under the current generation degrades.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ObservePollOutcome_fromASupersededGeneration_isIgnored()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
var stale = driver.LifecycleGenerationForTests;
|
|
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
var timedOut = new DataValueSnapshot(null, SqlStatusCodes.BadTimeout, null, DateTime.UtcNow);
|
|
|
|
driver.ObservePollOutcome([timedOut], generation: stale);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy, "a superseded poll must not degrade the new init");
|
|
|
|
driver.ObservePollOutcome([timedOut], generation: driver.LifecycleGenerationForTests);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Only the initialize's own final write ends <c>Initializing</c>: a Good poll landing mid-init used to
|
|
/// flip it to Healthy before liveness had even answered. Control: once the init completes it is Healthy.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task APollLandingMidInitialize_cannotEndInitializing()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var factory = new GatedFactory();
|
|
await using var driver = NewDriver(fixture, fixture.ConnectionString, CapturingLogger.Null, factory,
|
|
KvEntry("Speed", SqlitePollFixture.PresentKey));
|
|
|
|
factory.Arm();
|
|
var init = driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
(await factory.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBeTrue();
|
|
driver.GetHealth().State.ShouldBe(DriverState.Initializing);
|
|
|
|
driver.ObservePollOutcome([new DataValueSnapshot(42.0, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)]);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Initializing);
|
|
|
|
factory.Release();
|
|
await init.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>A SQLite factory whose next connection creation blocks until released — parks an initialize.</summary>
|
|
private sealed class GatedFactory : DbProviderFactory
|
|
{
|
|
private readonly ManualResetEventSlim _release = new(initialState: true);
|
|
private volatile bool _armed;
|
|
|
|
public TaskCompletionSource<bool> Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public void Arm()
|
|
{
|
|
_release.Reset();
|
|
_armed = true;
|
|
}
|
|
|
|
public void Release() => _release.Set();
|
|
|
|
public override DbConnection CreateConnection()
|
|
{
|
|
if (_armed)
|
|
{
|
|
_armed = false;
|
|
Entered.TrySetResult(true);
|
|
_release.Wait(TimeSpan.FromSeconds(30));
|
|
}
|
|
|
|
return Microsoft.Data.Sqlite.SqliteFactory.Instance.CreateConnection()!;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>R06:S-4.</b> A skipped tag's ledger reason (what /hosts shows) is the deploy gate's own explanation
|
|
/// — for an incomplete <c>Query</c> tag, the missing field — instead of a generic "did not map". Control:
|
|
/// a well-formed tag is not skipped at all.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AnIncompleteQueryTag_isSkipped_withTheGatesReason()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(fixture,
|
|
KvEntry("Speed", SqlitePollFixture.PresentKey),
|
|
new RawTagEntry("Q", """{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}""", WriteIdempotent: false));
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
|
|
var report = ((ISkippedTagReporter)driver).GetSkippedTags().ShouldNotBeNull();
|
|
var skipped = report.Sample.ShouldHaveSingleItem();
|
|
skipped.RawPath.ShouldBe("Q");
|
|
skipped.Reason.ShouldContain("Query");
|
|
skipped.Reason.ShouldContain("'column'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>Review m-1.</b> A redeploy re-initialises without teardown, so a poll can capture the reader just
|
|
/// before a rebind disposes it. That poll used to throw an OperationCanceledException the engine then
|
|
/// classified as a poll failure (Degrade). It now retries once on the current reader — and reads Good,
|
|
/// the positive control — with health untouched.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task APollOnAReplacedReader_retriesOnTheCurrentOne()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
var captured = driver.ReaderForTests;
|
|
|
|
driver.RebindForTests();
|
|
captured.GateDisposedForTests.ShouldBeTrue();
|
|
|
|
var snapshots = await driver.ReadThroughAsync(
|
|
captured, ["Speed"], driver.LifecycleGenerationForTests, CancellationToken.None);
|
|
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// With no newer reader (the driver's own reader was disposed) the disposed-reader exception still
|
|
/// propagates, but the engine's error callback must not degrade for it. Control: any other poll error
|
|
/// still degrades.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ADisposedReaderError_isNotAPollFailure()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
driver.ReaderForTests.Dispose();
|
|
|
|
// Awaited directly (as the poll engine does): an OperationCanceledException-derived throw ends the task
|
|
// Canceled, and only a direct await rethrows the original instance — which HandlePollError matches on.
|
|
Exception? caught = null;
|
|
try
|
|
{
|
|
await driver.ReadThroughAsync(
|
|
driver.ReaderForTests, ["Speed"], driver.LifecycleGenerationForTests, CancellationToken.None);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
caught = e;
|
|
}
|
|
|
|
caught.ShouldBeOfType<SqlPollReaderDisposedException>();
|
|
driver.HandlePollError(caught!);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
|
|
driver.HandlePollError(new InvalidOperationException("contract violation"));
|
|
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>R02:P1.</b> The driver runs <c>PollGroupEngine</c>, so it raises one batch per poll burst:
|
|
/// <c>DriverInstanceActor</c> then forwards one message per cycle instead of one per tag. Both tags of
|
|
/// the first (force-raised) poll arrive in the SAME batch, on the subscription's handle.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Subscribe_raisesOneBatchPerPollBurst()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await using var driver = NewDriver(fixture,
|
|
KvEntry("Speed", SqlitePollFixture.PresentKey), KvEntry("Pressure", SqlitePollFixture.SecondPresentKey));
|
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
|
|
|
var batchable = driver.ShouldBeAssignableTo<IBatchSubscribable>();
|
|
var first = new TaskCompletionSource<DataChangeBatchEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
batchable!.OnDataChangeBatch += (_, args) => first.TrySetResult(args);
|
|
|
|
var handle = await driver.SubscribeAsync(["Speed", "Pressure"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
|
|
|
var batch = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
|
batch.SubscriptionHandle.ShouldBe(handle);
|
|
batch.Items.Select(i => i.FullReference).OrderBy(r => r, StringComparer.Ordinal).ShouldBe(["Pressure", "Speed"]);
|
|
batch.Items.Single(i => i.FullReference == "Speed").Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|
|
}
|