using System.Data; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; /// /// Walks over a real, seeded SQLite catalog through the /// — the only non-SQL-Server dialect in the tree, and therefore the control /// that proves the session runs the dialect's catalog SQL rather than INFORMATION_SCHEMA /// with a quoting helper bolted on. /// public sealed class SqlBrowseSessionTests { private static readonly string SchemaNodeId = SqliteBrowseFixture.SchemaNodeId; [Fact] public async Task RootAsync_yieldsSchemaFolders() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var roots = await session.RootAsync(CancellationToken.None); roots.Count.ShouldBe(1); roots[0].DisplayName.ShouldBe(SqliteDialect.MainSchema); roots[0].NodeId.ShouldBe(SchemaNodeId); roots[0].Kind.ShouldBe(BrowseNodeKind.Folder); roots[0].HasChildrenHint.ShouldBeTrue(); } [Fact] public async Task ExpandSchema_yieldsTablesAndViewsAsFolders() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var children = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); children.ShouldAllBe(n => n.Kind == BrowseNodeKind.Folder && n.HasChildrenHint); children.ShouldContain(n => n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable)); // The view is listed beside the tables, and is labelled so the operator can tell them apart. var view = children .Where(n => n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ViewName)) .ShouldHaveSingleItem(); view.DisplayName.ShouldContain(SqliteBrowseFixture.ViewName); view.DisplayName.ShouldNotBe(SqliteBrowseFixture.ViewName); } /// The plan's headline case: table expand yields columns as committable leaves. [Fact] public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); // Exact match, not Contains: the injection-probe table is named "'); DROP TABLE TagValues; --", // so a substring selector picks *it*. Worth keeping in mind when reading a browse tree by eye too. var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None)) .First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable); var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None); columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn && c.Kind == BrowseNodeKind.Leaf && !c.HasChildrenHint); var columnNode = columns.First(c => c.DisplayName == SqliteBrowseFixture.RealColumn); var attributes = await session.AttributesAsync(columnNode.NodeId, CancellationToken.None); var attribute = attributes.ShouldHaveSingleItem(); attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn); attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); attribute.IsArray.ShouldBeFalse(); attribute.SecurityClass.ShouldBe("ViewOnly"); attribute.IsAlarm.ShouldBeFalse(); } [Fact] public async Task AttributesAsync_mapsTextColumnToString() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn); var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); } /// /// An exotic declared type must fall back to via /// MapColumnType rather than crash the browse — a table with one geography column must /// still render. /// [Fact] public async Task ExoticColumnType_fallsBackToString_ratherThanThrowing() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable); var columns = await session.ExpandAsync(tableNodeId, CancellationToken.None); columns.Count.ShouldBe(2); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable, SqliteBrowseFixture.ExoticColumn); var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); } /// /// The end-to-end awkward-identifier walk: a table named a.b|c holding a column named /// x|y. Both characters are the ones a naive schema.table|column NodeId splits on, so /// this is the case that proves the encoding, not just the codec unit test. /// [Fact] public async Task AwkwardIdentifiers_surviveTheFullWalk() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var tables = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); var awkward = tables .Where(n => n.DisplayName == SqliteBrowseFixture.AwkwardTable) .ShouldHaveSingleItem(); var columns = await session.ExpandAsync(awkward.NodeId, CancellationToken.None); columns.Select(c => c.DisplayName) .ShouldBe(new[] { SqliteBrowseFixture.AwkwardColumn, SqliteBrowseFixture.AwkwardColumn2 }, ignoreOrder: true); var awkwardColumn = columns.First(c => c.DisplayName == SqliteBrowseFixture.AwkwardColumn); var parsed = SqlBrowseNodeId.Parse(awkwardColumn.NodeId); parsed.Table.ShouldBe(SqliteBrowseFixture.AwkwardTable); parsed.Column.ShouldBe(SqliteBrowseFixture.AwkwardColumn); var attributes = await session.AttributesAsync(awkwardColumn.NodeId, CancellationToken.None); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); } /// /// A table whose name is an injection payload. If any catalog query concatenated the name /// instead of binding @table, expanding it would drop TagValues. /// [Fact] public async Task HostileTableName_isBound_notConcatenated() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable); var columns = await session.ExpandAsync(nodeId, CancellationToken.None); // Survival first, and deliberately so: this is the assertion that catches a spliced name. Assert it // before anything about the columns, or a splice that returns no rows reds on the wrong line and the // test stops proving the payload was inert. db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); columns.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteBrowseFixture.HostileColumn); var columnNodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable, SqliteBrowseFixture.HostileColumn); var attributes = await session.AttributesAsync(columnNodeId, CancellationToken.None); db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Int32)); } /// A hostile schema name takes the same bound path at the table level. [Fact] public async Task HostileSchemaName_isBound_notConcatenated() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForSchema("'); DROP TABLE TagValues; --"); var tables = await session.ExpandAsync(nodeId, CancellationToken.None); // SqliteDialect answers only to 'main', so an unknown schema legitimately lists nothing. tables.ShouldBeEmpty(); db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); } /// /// A table node whose table is gone (dropped between browse and expand) yields an empty column list. /// This is also the "table with zero columns" case: SQLite cannot create a genuinely column-less /// table, and an empty catalog answer is what both shapes look like from the browser's side. /// [Fact] public async Task TableWithNoColumns_yieldsEmpty_ratherThanThrowing() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable); (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); var columnNodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable, "whatever"); (await session.AttributesAsync(columnNodeId, CancellationToken.None)).ShouldBeEmpty(); } /// A column NodeId is terminal — expanding it is a no-op, never an error. [Fact] public async Task ExpandColumn_yieldsEmpty() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); } /// Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser. [Fact] public async Task AttributesAsync_onNonLeafNode_yieldsEmpty() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); (await session.AttributesAsync(SchemaNodeId, CancellationToken.None)).ShouldBeEmpty(); var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); (await session.AttributesAsync(tableNodeId, CancellationToken.None)).ShouldBeEmpty(); } /// /// A garbage NodeId must surface as an carrying an operator-readable /// message — the same contract the Galaxy and OPC UA client sessions have, and what /// DriverBrowseTree.razor renders inline as the node's error. It must not be a /// or an from a blind split. /// [Theory] [InlineData("")] [InlineData("not-a-node-id")] [InlineData("main.TagValues|num_value")] // the plan's literal sketch — not this codec's format [InlineData("column:a|b|c|d")] public async Task MalformedNodeId_failsWithAnActionableArgumentException(string nodeId) { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var expand = await Should.ThrowAsync( () => session.ExpandAsync(nodeId, CancellationToken.None)); expand.Message.ShouldNotBeNullOrWhiteSpace(); await Should.ThrowAsync( () => session.AttributesAsync(nodeId, CancellationToken.None)); } /// /// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate. /// Asserted by measuring overlap directly through : a results-only /// assertion would pass with the gate deleted. /// [Fact] public async Task ConcurrentCalls_areSerializedByTheGate() { await using var db = await SqliteBrowseFixture.CreateAsync(); var probe = new ConcurrencyProbe(); var probing = new ConcurrencyProbingDbConnection(db.Connection, probe); await using var session = new SqlBrowseSession(probing, new SqliteDialect()); var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); var calls = new[] { session.RootAsync(CancellationToken.None), session.ExpandAsync(SchemaNodeId, CancellationToken.None), session.ExpandAsync(tableNodeId, CancellationToken.None), session.ExpandAsync(tableNodeId, CancellationToken.None), }; var results = await Task.WhenAll(calls); probe.Peak.ShouldBe(1); // TagValues has three columns — proves the serialized calls still returned real catalog rows. results[2].Count.ShouldBe(3); } /// /// Ownership: the session takes over the connection it is handed and closes it on dispose. The /// AdminUI's BrowseSessionRegistry is the only thing holding a session, so if the session did /// not close the connection, nothing would — every reaped picker would leak one. /// [Fact] public async Task DisposeAsync_closesTheConnectionItWasGiven() { await using var db = await SqliteBrowseFixture.CreateAsync(); var connection = db.OpenNewConnection(); var session = new SqlBrowseSession(connection, new SqliteDialect()); connection.State.ShouldBe(ConnectionState.Open); await session.DisposeAsync(); connection.State.ShouldBe(ConnectionState.Closed); } [Fact] public async Task DisposeAsync_isIdempotent() { await using var db = await SqliteBrowseFixture.CreateAsync(); var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); await session.DisposeAsync(); await Should.NotThrowAsync(async () => await session.DisposeAsync()); } [Fact] public async Task CallsAfterDispose_throwObjectDisposedException() { await using var db = await SqliteBrowseFixture.CreateAsync(); var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); await session.DisposeAsync(); await Should.ThrowAsync( () => session.RootAsync(CancellationToken.None)); await Should.ThrowAsync( () => session.ExpandAsync(SchemaNodeId, CancellationToken.None)); } /// /// The AdminUI bounds every call with a 20 s linked CTS /// (BrowserSessionService.PerCallTimeout) — the session invents no timeout of its own, it just /// has to honour the token it is handed, all the way into the ADO.NET call. /// [Fact] public async Task CancelledToken_isHonoured() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync(() => session.RootAsync(cts.Token)); await Should.ThrowAsync(() => session.ExpandAsync(SchemaNodeId, cts.Token)); } /// /// feeds the registry's idle reaper — a session that never /// refreshed it would be evicted out from under an actively browsing operator. /// [Fact] public async Task LastUsedUtc_advancesOnEverySuccessfulCall() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var atStart = session.LastUsedUtc; await Task.Delay(15, TestContext.Current.CancellationToken); await session.RootAsync(CancellationToken.None); var afterRoot = session.LastUsedUtc; afterRoot.ShouldBeGreaterThan(atStart); await Task.Delay(15, TestContext.Current.CancellationToken); await session.ExpandAsync(SchemaNodeId, CancellationToken.None); var afterExpand = session.LastUsedUtc; afterExpand.ShouldBeGreaterThan(afterRoot); await Task.Delay(15, TestContext.Current.CancellationToken); var columnNodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); await session.AttributesAsync(columnNodeId, CancellationToken.None); session.LastUsedUtc.ShouldBeGreaterThan(afterExpand); } }