6bb0108d30
The dialect owns the two things ADO.NET's System.Data.Common base types cannot abstract: identifier quoting and the metadata-catalog SQL. It is the driver's SQL-injection boundary — values always bind as DbParameters, identifiers cannot, so the few that reach a command text are catalog- sourced and pass through QuoteIdentifier. QuoteIdentifier brackets one identifier part and doubles embedded ] (the only metacharacter that can terminate a bracketed identifier early); rejects null/empty/whitespace, >128 chars (T-SQL sysname ceiling), and any control character (incl. NUL) as ArgumentException, without echoing the untrusted value into the message. MapColumnType folds the design §3.7 families case-insensitively and never throws — an unrecognised family falls back to String so a browse over an exotic column still renders; timestamp/rowversion and time are deliberately NOT mapped to DateTime. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
108 lines
4.4 KiB
C#
108 lines
4.4 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"));
|
|
|
|
[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");
|
|
}
|
|
|
|
// ---- 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)));
|
|
}
|
|
|
|
[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);
|
|
}
|