Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs
T
Joseph Doherty 886ce1b87c fix(sql): chunk the key-value IN list at the dialect's bind ceiling (Driver.Sql-002, Driver.Sql.Dialect-002)
ISqlDialect gains MaxBindParameters — the per-command ceiling the seam was
missing while modelling everything else that differs per backend: 2098 usable
of SQL Server's 2100 (sp_executesql spends two), 1000 for Oracle's IN-list
limit (ORA-01795, tighter than its 64k bind count), 65535 for the Postgres
Bind message.

SqlGroupPlanner.AddKeyValuePlans now emits several plans for one group when
its distinct keys exceed that ceiling, instead of one statement the provider
refuses after the connection opened — which left every tag in an EAV group
publishing BadCommunicationError on every poll, permanently, and reported the
database endpoint as down. Chunks share the group key; members follow their
own key so a shared keyValue never splits. SqlPollReader is unchanged: it runs
one command per plan and slices each plan's own Members back, proven by a
fixture read at a ceiling of 1.
2026-08-10 08:02:44 -04:00

169 lines
7.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
/// Golden tests for the driver's SQL-injection boundary. Values are always bound as
/// <c>DbParameter</c>s; identifiers cannot be, so <see cref="SqlServerDialect.QuoteIdentifier"/> is the
/// only thing standing between a catalog-sourced name and a command text. These tests pin the escape
/// rule, the rejection rules, and the exact catalog SQL.
/// </summary>
public class SqlServerDialectTests
{
[Fact]
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
{
var d = new SqlServerDialect();
d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules
}
[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category.
[Theory]
[InlineData("x\u0001y")] // C0 SOH
[InlineData("x\u001By")] // C0 ESC
[InlineData("x\u007Fy")] // DEL
[InlineData("x\u0085y")] // C1 NEL
[InlineData("x\ty")]
[InlineData("x\ny")]
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(string ident)
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident));
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all
// of these. They cannot escape the brackets, but they spoof rendered log/AdminUI text while comparing
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
[Theory]
[InlineData("Sp\u200Beed")] // zero-width space
[InlineData("\u202Ediop.selbat")] // right-to-left override
[InlineData("x\u2066y\u2069")] // bidi isolates
[InlineData("x\uFEFFy")] // BOM / zero-width no-break space
[InlineData("x\u00ADy")] // soft hyphen
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(string ident)
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident));
[Theory]
[InlineData("bit", DriverDataType.Boolean)]
[InlineData("int", DriverDataType.Int32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("real", DriverDataType.Float32)]
[InlineData("float", DriverDataType.Float64)]
[InlineData("decimal", DriverDataType.Float64)]
[InlineData("nvarchar", DriverDataType.String)]
[InlineData("datetime2", DriverDataType.DateTime)]
[InlineData("uniqueidentifier", DriverDataType.String)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
[Fact]
public void CatalogSql_isInformationSchemaAndParameterized()
{
var d = new SqlServerDialect();
d.LivenessSql.ShouldBe("SELECT 1");
d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
d.ListColumnsSql.ShouldContain("@table");
}
[Fact]
public void SingleRowLimit_isTSqlsTop1_inThePrefixPosition_withItsOwnTrailingSpace()
{
var d = new SqlServerDialect();
// The planner concatenates "SELECT " + prefix + columns with no separator of its own, so the
// trailing space belongs to the fragment. T-SQL has no end-of-statement limit clause.
d.SingleRowLimitPrefix.ShouldBe("TOP 1 ");
d.SingleRowLimitSuffix.ShouldBe("");
string.Concat("SELECT ", d.SingleRowLimitPrefix, "[a]", d.SingleRowLimitSuffix)
.ShouldBe("SELECT TOP 1 [a]");
}
/// <summary>
/// 2098, not 2100: <c>Microsoft.Data.SqlClient</c> routes a parameterized command through
/// <c>sp_executesql</c>, whose statement text and parameter-declaration arguments consume two of the
/// server's 2100. A ceiling of 2100 would be rejected by a caller that filled it exactly.
/// </summary>
[Fact]
public void MaxBindParameters_leavesRoomForSpExecutesqlsOwnTwoParameters()
{
new SqlServerDialect().MaxBindParameters.ShouldBe(2098);
new SqlServerDialect().MaxBindParameters.ShouldBeLessThan(2100);
}
// ---- extra guards on the injection boundary (beyond the plan's golden set) ----
[Fact]
public void Dialect_identifiesItselfAsSqlServer_andExposesTheAbstractFactory()
{
var d = new SqlServerDialect();
d.Provider.ShouldBe(SqlProvider.SqlServer);
d.Factory.ShouldNotBeNull();
// The seam must not leak Microsoft.Data.SqlClient into its signature.
typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))!
.PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident!));
[Fact]
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButABracket()
=> new SqlServerDialect().QuoteIdentifier("]").ShouldBe("[]]]"); // T-SQL for the 1-char name "]"
[Fact]
public void QuoteIdentifier_acceptsExactly128Chars_andRejects129()
{
var d = new SqlServerDialect();
d.QuoteIdentifier(new string('a', 128)).ShouldBe("[" + new string('a', 128) + "]");
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 129)));
}
/// <summary>
/// The falsifiability control for the Postgres/Oracle byte ceilings: T-SQL's <c>sysname</c> is
/// <c>nvarchar(128)</c>, a genuine <b>128-character</b> (UTF-16 code unit) limit, so a 128-character
/// Cyrillic name — 256 UTF-8 bytes — is legal here and must still be accepted. A byte rule applied
/// uniformly across the three dialects would reject it, and only this test would say so.
/// </summary>
[Fact]
public void QuoteIdentifier_countsCharacters_notBytes_becauseSysnameIsNvarchar128()
{
var ident = new string('б', 128); // 128 chars, 256 UTF-8 bytes
new SqlServerDialect().QuoteIdentifier(ident).ShouldBe("[" + ident + "]");
}
[Fact]
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
{
// A hostile "column name" cannot escape the brackets: the only metacharacter that could is ],
// and it is doubled. The result is a single (nonexistent) identifier, never executable SQL.
new SqlServerDialect().QuoteIdentifier("x] ; DROP TABLE Users --")
.ShouldBe("[x]] ; DROP TABLE Users --]");
}
[Theory]
[InlineData("NVARCHAR", DriverDataType.String)]
[InlineData("BiT", DriverDataType.Boolean)]
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
[Theory]
[InlineData("geography")]
[InlineData("sql_variant")]
[InlineData("timestamp")] // SQL Server rowversion — binary, deliberately NOT a DateTime
[InlineData("")]
[InlineData(null)]
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
=> new SqlServerDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
}