using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
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;
///
/// The design §8.1 catalog gate end-to-end through , against the real SQLite
/// catalog the poll fixture creates — real ListSchemas/ListTables/ListColumns
/// round-trips, not a hand-built .
/// The two facts that matter most here are the ones a pure unit test cannot show: that a
/// rejected tag still has a node and reads BadNodeIdUnknown (rather than silently
/// vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead
/// of rejecting every tag.
///
public sealed class SqlCatalogGateDriverTests
{
private const string DriverInstanceId = "sql-gate";
private const string ConfigJson = """{"provider":"SqlServer"}""";
[Fact]
public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
{
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);
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
}
///
/// §8.1's specified outcome, in full: the tag is refused by the allow-list, so it never reaches a
/// query — but its node still exists and reads BadNodeIdUnknown. Dropping the node instead would
/// turn a diagnosable Bad quality into a missing address-space entry.
///
[Fact]
public async Task A_tag_naming_an_unknown_column_keeps_its_node_and_reads_BadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// The driver is healthy: an authoring typo is not a database fault.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
// The node is still materialized...
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Select(v => v.Info.FullName).ShouldBe(["Speed", "Bogus"], ignoreOrder: true);
// ...and it is the rejected tag — and only it — that reads BadNodeIdUnknown.
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
[Fact]
public async Task A_tag_naming_an_unknown_table_reads_BadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, KvEntry("Bogus", SqlitePollFixture.PresentKey, table: "NoSuchTable"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Bogus"], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
///
/// The injection shape #496 exists to close. Before the gate, this name was bracket-quoted into a real
/// query that failed only once the connection was open; now it never reaches a query at all.
///
[Fact]
public async Task A_hostile_table_name_never_reaches_a_query()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger,
KvEntry("Evil", SqlitePollFixture.PresentKey, table: "TagValues\"; DROP TABLE TagValues--"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Evil"], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The fixture's table is untouched — proven by a second tag still reading through it.
await using var honest = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await honest.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshot = (await honest.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
logger.Entries.ShouldContain(e =>
e.Level == LogLevel.Warning && e.Message.Contains("rejected by the catalog gate", StringComparison.Ordinal));
}
///
/// A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the
/// field and the reason.
///
[Fact]
public async Task Every_rejection_is_logged_with_the_tag_the_field_and_the_reason()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger, KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "num_valeu"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var warning = logger.Entries
.Where(e => e.Level == LogLevel.Warning)
.Select(e => e.Message)
.ShouldHaveSingleItem();
warning.ShouldContain("Bogus");
warning.ShouldContain(nameof(SqlTagDefinition.ValueColumn));
warning.ShouldContain("num_valeu");
}
///
/// Case-insensitive authoring has always worked on SQL Server's default collation, so the gate must
/// accept it — and it substitutes the catalog's spelling, which is what makes the emitted SQL carry
/// catalog strings rather than operator input.
///
[Fact]
public async Task A_case_variant_identifier_is_accepted_and_polls()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry(
"Speed", SqlitePollFixture.PresentKey,
table: SqlitePollFixture.KeyValueTable.ToUpperInvariant(),
valueColumn: SqlitePollFixture.ValueColumn.ToUpperInvariant()));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
}
///
/// A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would
/// be a round-trip that can only fail.
///
[Fact]
public async Task A_driver_with_no_authored_tags_initializes_without_touching_the_catalog()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
///
/// The fail-closed rule. A catalog that cannot be read is the ABSENCE of evidence about the
/// tags, not evidence against them. Rejecting every tag would serve a confidently-empty address space
/// and send the operator hunting typos that do not exist; faulting Initialize instead lands
/// DriverInstanceActor in Reconnecting with its retry timer running.
///
[Fact]
public async Task A_catalog_that_cannot_be_read_faults_Initialize_rather_than_rejecting_every_tag()
{
using var fixture = new SqlitePollFixture();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new CatalogSqlOverride("SELECT this is not valid sql"),
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger.Instance);
var thrown = await Should.ThrowAsync(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
// The operator surface names the stage that actually failed — "reached it but could not read the
// catalog" and "could not reach it" send an operator to different systems.
thrown.Message.ShouldContain("catalog");
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
///
/// Zero visible schemas is a grant problem, not an empty database, so it must fault rather than reject
/// every tag for an authoring fault the operator does not have.
///
[Fact]
public async Task A_catalog_reporting_no_schemas_at_all_faults_Initialize()
{
using var fixture = new SqlitePollFixture();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new CatalogSqlOverride("SELECT 'x' AS TABLE_SCHEMA WHERE 1 = 0"),
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger.Instance);
await Should.ThrowAsync(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
// ---- helpers ----
///
/// Delegates every member to except , so the
/// catalog-load failure modes can be driven against an otherwise-real dialect.
///
///
/// A decorator rather than a subclass: is sealed, and even if it were not,
/// new-hiding a property would leave interface dispatch calling the base — the substitution
/// would silently not happen and both tests below would pass for the wrong reason.
///
private sealed class CatalogSqlOverride(string listSchemasSql) : ISqlDialect
{
private static readonly SqliteDialect Inner = new();
public SqlProvider Provider => Inner.Provider;
public System.Data.Common.DbProviderFactory Factory => Inner.Factory;
public string LivenessSql => Inner.LivenessSql;
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
public string ListSchemasSql { get; } = listSchemasSql;
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
public string ListTablesSql => Inner.ListTablesSql;
public string ListColumnsSql => Inner.ListColumnsSql;
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
}
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
=> NewDriver(fixture, new CapturingLogger(), rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
=> new(
new SqlDriverOptions
{
RawTags = rawTags,
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new SqliteDialect(),
fixture.ConnectionString,
factory: fixture.Factory,
logger: logger);
/// One authored raw tag, with the table and value column overridable so the gate can be exercised.
private static RawTagEntry KvEntry(
string rawPath,
string keyValue,
string? table = null,
string? valueColumn = null)
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
{
"driver": "Sql",
"model": "KeyValue",
"table": "{{(table ?? SqlitePollFixture.KeyValueTable).Replace("\"", "\\\"", StringComparison.Ordinal)}}",
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
"keyValue": "{{keyValue}}",
"valueColumn": "{{valueColumn ?? SqlitePollFixture.ValueColumn}}",
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
}
"""), WriteIdempotent: false);
/// Records everything the driver streams into the address space.
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
/// The variables registered, in order.
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => 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) { }
}
}
}
/// Records every log record, level + rendered message.
private sealed class CapturingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
}