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() { } } } }