0a5aa9fc43
Key-value and where-pair plans had no row limit, so a selector pointed at a history or events table materialised every matching row on every poll. ISqlDialect gains RowLimitPrefix/RowLimitSuffix(n) (SQL Server TOP (n), Postgres LIMIT n, Oracle FETCH FIRST n ROWS ONLY); plans carry a RowCap (distinct keys, or 1) and ask for RowCap+1 rows; the reader also stops client-side at RowCap+1. Getting the extra row proves the selector ambiguous: the group publishes BadConfigurationError with a rate-limited warning. The reader reports those answered refusals so the driver counts them as answer evidence, and any refusal-only poll now restores Healthy after a comms fault. Refs #744 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
178 lines
7.8 KiB
C#
178 lines
7.8 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 RowLimit_isTopN_inThePrefixPosition()
|
||
{
|
||
var d = new SqlServerDialect();
|
||
d.RowLimitPrefix(3).ShouldBe("TOP (3) ");
|
||
d.RowLimitSuffix(3).ShouldBe("");
|
||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitPrefix(0));
|
||
}
|
||
|
||
[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);
|
||
}
|