using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// The design §8.1 catalog gate (Gitea #496), tested as a pure function over a hand-built /// . The end-to-end behaviour against a real catalog — and the proof that a /// rejected tag keeps its node and publishes BadNodeIdUnknown — lives in /// . /// public sealed class SqlCatalogGateTests { private static readonly SqliteDialect Dialect = new(); /// A catalog with one schema, two relations, and deliberately mixed-case column spellings. private static SqlCatalog Catalog(string defaultSchema = "dbo") => new( defaultSchema, ["dbo", "mes"], new Dictionary>(StringComparer.Ordinal) { ["dbo"] = ["TagValues", "LatestStatus"], ["mes"] = ["Orders"], }, new Dictionary>(StringComparer.Ordinal) { ["dbo.TagValues"] = ["tag_name", "num_value", "sample_ts"], ["dbo.LatestStatus"] = ["station_id", "oven_temp", "pressure", "sample_ts"], ["mes.Orders"] = ["order_id", "qty"], }); private static SqlTagDefinition KeyValueTag( string table = "dbo.TagValues", string keyColumn = "tag_name", string valueColumn = "num_value", string? timestampColumn = "sample_ts") => new("plant/sql/Speed", SqlTagModel.KeyValue, table, KeyColumn: keyColumn, KeyValue: "Line1.Speed", ValueColumn: valueColumn, TimestampColumn: timestampColumn); private static SqlCatalogGateResult Apply(params SqlTagDefinition[] tags) => SqlCatalogGate.Apply(tags, Catalog(), Dialect); [Fact] public void A_fully_resolvable_tag_is_accepted() { var result = Apply(KeyValueTag()); result.Rejected.ShouldBeEmpty(); result.Accepted.Count.ShouldBe(1); } /// /// The heart of §8.1: what reaches the planner must be a string the catalog gave us, not the string an /// operator typed. Authoring every identifier in the wrong case proves the substitution actually /// happens rather than the input merely being waved through. /// [Fact] public void Accepted_identifiers_are_rewritten_to_the_catalogs_own_spelling() { var authored = KeyValueTag( table: "DBO.TAGVALUES", keyColumn: "TAG_NAME", valueColumn: "Num_Value", timestampColumn: "SAMPLE_TS"); var accepted = Apply(authored).Accepted.ShouldHaveSingleItem(); accepted.Table.ShouldBe("dbo.TagValues"); accepted.KeyColumn.ShouldBe("tag_name"); accepted.ValueColumn.ShouldBe("num_value"); accepted.TimestampColumn.ShouldBe("sample_ts"); // Identity and bound VALUES are untouched — the gate rewrites identifiers only. accepted.Name.ShouldBe(authored.Name); accepted.KeyValue.ShouldBe(authored.KeyValue); } [Fact] public void An_unqualified_table_resolves_in_the_default_schema() { var accepted = Apply(KeyValueTag(table: "TagValues")).Accepted.ShouldHaveSingleItem(); accepted.Table.ShouldBe("dbo.TagValues"); } /// /// Guessing dbo would be a silent lie on an estate that maps service accounts to their own /// default schema, so the gate must resolve an unqualified name in whatever schema the server reports. /// [Fact] public void An_unqualified_table_follows_a_non_dbo_default_schema() { var catalog = Catalog(defaultSchema: "mes"); var result = SqlCatalogGate.Apply([KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null)], catalog, Dialect); result.Accepted.ShouldHaveSingleItem().Table.ShouldBe("mes.Orders"); } [Fact] public void An_unknown_table_rejects_the_tag() { var rejection = Apply(KeyValueTag(table: "dbo.NoSuchTable")).Rejected.ShouldHaveSingleItem(); rejection.RawPath.ShouldBe("plant/sql/Speed"); rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table)); rejection.Reason.ShouldContain("NoSuchTable"); } [Fact] public void An_unknown_schema_rejects_the_tag() { var rejection = Apply(KeyValueTag(table: "nope.TagValues")).Rejected.ShouldHaveSingleItem(); rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table)); rejection.Reason.ShouldContain("nope"); } [Fact] public void An_unknown_column_rejects_the_tag_and_names_the_field() { var rejection = Apply(KeyValueTag(valueColumn: "no_such_column")).Rejected.ShouldHaveSingleItem(); rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn)); rejection.Reason.ShouldContain("no_such_column"); rejection.Reason.ShouldContain("dbo.TagValues"); } /// /// A table in the right catalog but the wrong schema must not resolve — otherwise the gate would /// allow-list a column set from a relation the query will never read. /// [Fact] public void A_table_from_another_schema_does_not_resolve_unqualified() { var rejection = Apply(KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null)) .Rejected.ShouldHaveSingleItem(); rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table)); } /// /// A cross-database or linked-server name addresses a catalog this connection cannot enumerate, so it /// cannot be allow-listed. Rejecting is the fail-closed answer, and the message says what to do instead. /// [Fact] public void A_three_part_name_is_rejected_with_an_actionable_message() { var rejection = Apply(KeyValueTag(table: "otherdb.dbo.TagValues")).Rejected.ShouldHaveSingleItem(); rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table)); rejection.Reason.ShouldContain("3-part"); rejection.Reason.ShouldContain("view"); } /// /// The injection shape the whole gate exists for: a hostile identifier must be refused by the /// allow-list, not merely quoted into a query against a nonexistent object. /// [Theory] [InlineData("TagValues]; DROP TABLE TagValues--")] [InlineData("'; DROP TABLE TagValues--")] [InlineData("TagValues WHERE 1=1 OR 1=1")] public void A_hostile_table_name_is_rejected_by_the_allow_list(string table) { Apply(KeyValueTag(table: table)).Rejected.ShouldHaveSingleItem() .Field.ShouldBe(nameof(SqlTagDefinition.Table)); } [Theory] [InlineData("num_value]; DROP TABLE TagValues--")] [InlineData("(SELECT password FROM users)")] public void A_hostile_column_name_is_rejected_by_the_allow_list(string column) { Apply(KeyValueTag(valueColumn: column)).Rejected.ShouldHaveSingleItem() .Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn)); } /// /// A name carrying a control or Unicode format character cannot be safely rendered into a log line /// (the Trojan-Source class, CVE-2021-42574), so the gate rejects it without echoing it — the /// charset check runs before the catalog lookup precisely so no such string can reach a message. /// /// /// Driven against , not the test-only : the /// charset rules belong to the dialect (the gate delegates to /// rather than duplicating them), and SQLite's rules /// deliberately stop at control characters. Asserting SQL Server's rules means using SQL Server's /// dialect — the first draft of this test asserted them against SQLite's and failed for that reason. /// [Theory] [InlineData("num\u0000value")] // Cc — embedded NUL [InlineData("num\u0009value")] // Cc — tab; truncates or corrupts a logged statement [InlineData("num\u202Evalue")] // Cf — right-to-left override [InlineData("num\u200Bvalue")] // Cf — zero-width space public void An_unrenderable_identifier_is_rejected_without_being_echoed(string column) { var result = SqlCatalogGate.Apply( [KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect()); var rejection = result.Rejected.ShouldHaveSingleItem(); rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn)); rejection.Reason.ShouldContain("withheld"); rejection.Reason.ShouldNotContain(column); } /// /// An over-long name cannot name a real object and is likewise withheld, rather than pasting an /// unbounded slab of operator input into a log line. /// [Fact] public void An_over_long_identifier_is_rejected_without_being_echoed() { var column = new string('x', SqlServerDialect.MaxIdentifierLength + 1); var result = SqlCatalogGate.Apply( [KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect()); result.Rejected.ShouldHaveSingleItem().Reason.ShouldContain("withheld"); } /// /// The complement, and the reason the charset check is not simply "withhold everything": a name that /// IS safely renderable gets echoed, because an operator hunting a typo has to see what they wrote. /// [Fact] public void A_renderable_but_unknown_identifier_IS_echoed_so_the_typo_is_findable() { var rejection = Apply(KeyValueTag(valueColumn: "num_valeu")).Rejected.ShouldHaveSingleItem(); rejection.Reason.ShouldContain("num_valeu"); rejection.Reason.ShouldNotContain("withheld"); } /// /// On a case-sensitive collation a relation may legitimately carry both Value and value. /// Picking one would publish a different column's data under the operator's node, so the only safe /// answer is to refuse — but an EXACT match must still win, or a valid config would break. /// [Fact] public void An_ambiguous_case_insensitive_column_match_is_rejected_but_an_exact_match_still_wins() { var catalog = new SqlCatalog( "dbo", ["dbo"], new Dictionary>(StringComparer.Ordinal) { ["dbo"] = ["T"] }, new Dictionary>(StringComparer.Ordinal) { ["dbo.T"] = ["k", "Value", "value"], }); var ambiguous = new SqlTagDefinition( "p/Ambiguous", SqlTagModel.KeyValue, "dbo.T", KeyColumn: "k", KeyValue: "x", ValueColumn: "VALUE"); SqlCatalogGate.Apply([ambiguous], catalog, Dialect).Rejected.ShouldHaveSingleItem() .Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn)); var exact = ambiguous with { Name = "p/Exact", ValueColumn = "value" }; SqlCatalogGate.Apply([exact], catalog, Dialect).Accepted.ShouldHaveSingleItem() .ValueColumn.ShouldBe("value"); } /// /// One operator typo must not stop the other tags on the same database — the same rule the tag-table /// build already follows for a malformed blob. /// [Fact] public void A_rejected_tag_does_not_take_its_healthy_neighbours_with_it() { var good = KeyValueTag() with { Name = "plant/sql/Good" }; var bad = KeyValueTag(valueColumn: "typo") with { Name = "plant/sql/Bad" }; var result = Apply(good, bad); result.Accepted.ShouldHaveSingleItem().Name.ShouldBe("plant/sql/Good"); result.Rejected.ShouldHaveSingleItem().RawPath.ShouldBe("plant/sql/Bad"); } /// Every identifier-bearing field is checked, not just the two the key-value model happens to use. [Fact] public void The_wide_row_models_identifier_fields_are_validated_too() { var selectorTypo = new SqlTagDefinition( "p/Oven", SqlTagModel.WideRow, "dbo.LatestStatus", ColumnName: "oven_temp", RowSelectorColumn: "no_such_selector", RowSelectorValue: "7"); Apply(selectorTypo).Rejected.ShouldHaveSingleItem() .Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorColumn)); var orderTypo = new SqlTagDefinition( "p/Newest", SqlTagModel.WideRow, "dbo.LatestStatus", ColumnName: "oven_temp", RowSelectorTopByTimestamp: "no_such_ts"); Apply(orderTypo).Rejected.ShouldHaveSingleItem() .Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorTopByTimestamp)); var columnTypo = new SqlTagDefinition( "p/Bad", SqlTagModel.WideRow, "dbo.LatestStatus", ColumnName: "no_such_column", RowSelectorColumn: "station_id", RowSelectorValue: "7"); Apply(columnTypo).Rejected.ShouldHaveSingleItem() .Field.ShouldBe(nameof(SqlTagDefinition.ColumnName)); var ok = new SqlTagDefinition( "p/Ok", SqlTagModel.WideRow, "dbo.LatestStatus", ColumnName: "OVEN_TEMP", RowSelectorColumn: "STATION_ID", RowSelectorValue: "7"); var accepted = Apply(ok).Accepted.ShouldHaveSingleItem(); accepted.ColumnName.ShouldBe("oven_temp"); accepted.RowSelectorColumn.ShouldBe("station_id"); accepted.RowSelectorValue.ShouldBe("7"); // a bound value, never canonicalized } /// An absent optional identifier is not a rejection — only a present, unresolvable one is. [Fact] public void An_absent_optional_timestamp_column_is_left_alone() { var accepted = Apply(KeyValueTag(timestampColumn: null)).Accepted.ShouldHaveSingleItem(); accepted.TimestampColumn.ShouldBeNull(); } }