886ce1b87c
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.
169 lines
7.5 KiB
C#
169 lines
7.5 KiB
C#
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);
|
||
}
|