using Microsoft.Data.Sqlite;
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;
///
/// Locks the driver's SQL-injection guarantee against a real database ():
/// an authored value is bound as a parameter and can never execute, and an authored
/// identifier — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes
/// an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag
/// cannot alter the database; the seed table is intact after every hostile poll.
/// Scope note — this suite deliberately tests the layer BELOW the catalog gate. Design
/// §8.1's catalog gate now exists (Gitea #496): validates every authored
/// table/column against the live catalog at Initialize, so in the assembled driver a hostile identifier
/// is rejected up front and its tag reads — proven by
/// SqlCatalogGateDriverTests.
/// The tests here construct a directly, with hand-built
/// definitions that never pass through the gate, and that is the point: defence in depth is only worth
/// the name if each layer holds on its own. These assertions pin the quoting backstop — that
/// even with the allow-list bypassed entirely, a hostile identifier becomes an inert reference to a
/// nonexistent object, the query fails after the connection opened, the tag Bad-codes as a query failure
/// (), and the seed table survives. Rewriting them to
/// expect BadNodeIdUnknown would delete the only coverage the backstop has and leave the gate as a
/// single point of failure.
///
public sealed class SqlInjectionRegressionTests
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15);
/// The classic payload: were the key concatenated into text, this would drop the table.
private const string DropTablePayload = "'; DROP TABLE TagValues; --";
[Fact]
public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
{
using var fixture = new SqlitePollFixture();
var reader = NewReader(fixture, KvTag("Speed", DropTablePayload));
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
// Bound, not executed: it is simply a key that matches no row.
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
// The table is still there, with its seeded rows — the DROP never ran.
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0);
}
[Fact]
public async Task MaliciousKeyValue_doesNotEvenDisturbAHealthyNeighbourOnTheSamePoll()
{
// A hostile key in one slot must not corrupt a legitimate tag sharing the poll — it binds as its own
// parameter and matches nothing; the real key still reads.
using var fixture = new SqlitePollFixture();
var reader = NewReader(fixture,
KvTag("Evil", DropTablePayload),
KvTag("Speed", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["Evil", "Speed"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0);
}
[Fact]
public async Task MaliciousTableIdentifier_isInert_neverExecutesAndTheSeedSurvives()
{
using var fixture = new SqlitePollFixture();
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
// A table name carrying a statement terminator + DROP. The reader is driven directly, so the
// catalog gate never sees it and the quoting backstop is on its own: the name is quoted into one
// nonexistent identifier, the query fails (no such table) with the connection already open, so the
// tag Bad-codes as a query failure. The DROP never runs. In the assembled driver the gate rejects
// this first and the tag reads BadNodeIdUnknown instead — see SqlCatalogGateDriverTests.
var reader = NewReader(fixture,
new SqlTagDefinition(
Name: "Evil",
Model: SqlTagModel.KeyValue,
Table: "TagValues\"; DROP TABLE TagValues; --",
KeyColumn: SqlitePollFixture.KeyColumn,
KeyValue: SqlitePollFixture.PresentKey,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn));
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);
// Bad-coded as a QUERY failure, not BadNodeIdUnknown — this reader was built without the gate.
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
// The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched.
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows);
}
[Fact]
public async Task MaliciousColumnIdentifier_isInert_neverExecutesAndTheSeedSurvives()
{
using var fixture = new SqlitePollFixture();
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
// The same attack via the value column: quoted, so it becomes a nonexistent column reference; the
// SELECT fails and the tag Bad-codes. Nothing executes.
var reader = NewReader(fixture,
new SqlTagDefinition(
Name: "Evil",
Model: SqlTagModel.KeyValue,
Table: SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn,
KeyValue: SqlitePollFixture.PresentKey,
ValueColumn: "num_value\"; DROP TABLE TagValues; --",
TimestampColumn: SqlitePollFixture.TimestampColumn));
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows);
}
[Fact]
public async Task AControlCharacterIdentifier_isRejectedByQuoting_beforeItReachesTheDatabase()
{
// QuoteIdentifier refuses a NUL/control-character identifier outright (it cannot name a real object).
// The planner throws ArgumentException, which the reader classes as an authoring fault —
// BadConfigurationError — rather than a database failure. Still inert: the table survives.
using var fixture = new SqlitePollFixture();
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
var reader = NewReader(fixture,
new SqlTagDefinition(
Name: "Evil",
Model: SqlTagModel.KeyValue,
Table: "Tag\0Values",
KeyColumn: SqlitePollFixture.KeyColumn,
KeyValue: SqlitePollFixture.PresentKey,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn));
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows);
}
// ---- helpers ----
private static SqlPollReader NewReader(SqlitePollFixture fixture, params SqlTagDefinition[] tags)
{
var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal);
return new SqlPollReader(
fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: rawPath => table.GetValueOrDefault(rawPath));
}
private static SqlTagDefinition KvTag(string name, string keyValue)
=> new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn);
/// Counts rows in a table over the fixture's own connection — the direct evidence a DROP did not
/// run. The table name is a test constant, never authored input, so interpolating it here is safe.
private static async Task RowCountAsync(SqlitePollFixture fixture, string table)
{
await using var command = fixture.Connection.CreateCommand();
command.CommandText = $"SELECT COUNT(*) FROM \"{table}\"";
return Convert.ToInt64(await command.ExecuteScalarAsync());
}
}