feat(sql): opt-in writes — allowWrites + per-tag writable, single-row parameterised UPDATE (lmxopcua#753)
v2-ci / unit-tests (push) Waiting to run

allowWrites is no longer inert: SqlDriver implements IWritable, default off.

- Gates (outermost first): node Writable + WriteOperate (server), driver
  allowWrites, the tag's own TagConfig "writable": true (strict JSON boolean;
  anything else refuses the tag, and the deploy gate says why), and a model that
  names exactly one row — KeyValue, or WideRow by where-pair. topByTimestamp and
  Query are BadNotWritable.
- SqlWritePlanner: the only statement a write can become is
  UPDATE <table> SET <value col> = @v WHERE <key col> = @k, built from the
  catalog-gate-canonical identifiers (dialect-quoted) with value and key bound;
  value-before-key marker order so positional ODBC binds correctly. No authored
  write SQL, INSERT, UPSERT or procedure.
- Executed in a transaction that commits only when exactly one row was touched:
  0 rows -> BadNoData (never inserts), >1 -> rolled back, BadConfigurationError,
  unknown count -> rolled back, BadNotSupported. Bounded by operationTimeout;
  provider errors -> BadCommunicationError; health untouched.
- Discovery advertises Operate only where a write could succeed.
- AdminUI: driver form's Allow writes switch is live; tag editor gains a
  per-tag write switch; model validates writable strictly (parity corpus).
- Factory no longer warns "allowWrites ignored"; it logs whether writes are on.
- Tests: SqlWriteTests (planner SQL/bind order/injection/coercion, every gate,
  readback, absent key, two-row rollback, gate-rejected tag, discovery);
  MySqlReadTests write legs against InnoDB (keyed row only, two-row rollback,
  server-side grant refusal, default-off). Seed adds a setpoints table and the
  one UPDATE grant.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 17:21:31 -05:00
parent 3c9ab4b668
commit 2331f7ed14
22 changed files with 916 additions and 75 deletions
@@ -6,8 +6,8 @@
-- backends. Two things are load-bearing:
--
-- 1. The suite connects as `otopcua`, which holds SELECT on `sqlpoll` and nothing else (plus EXECUTE on the
-- side-effect function, below). A MySQL "schema" is a database, and information_schema shows a principal
-- only the objects it holds a privilege on — so MySqlDialect.ListSchemasSql returning `sqlpoll` for this
-- side-effect function and UPDATE on the one write-target table, below). A MySQL "schema" is a database,
-- and information_schema shows a principal only the objects it holds a privilege on — so MySqlDialect.ListSchemasSql returning `sqlpoll` for this
-- login is the real provisioning check, not a superuser's view.
-- 2. Every identifier is lower case. The suite authors the table name in UPPER case and expects the catalog
-- gate to resolve it through a unique case-insensitive match: on Linux, MySQL table names are case-
@@ -65,9 +65,23 @@ BEGIN
END//
DELIMITER ;
-- Write target (lmxopcua#753): the ONLY table the suite login may UPDATE. `dup` deliberately holds two rows
-- for one key, so a write keyed on it matches two rows and must be rolled back. tag_values stays SELECT-only,
-- so a write tag pointed there proves the server's own grant refuses it.
CREATE TABLE setpoints (
tag_name varchar(128) NOT NULL,
sp_value double NULL
) ENGINE = InnoDB;
INSERT INTO setpoints (tag_name, sp_value) VALUES
('Line1.SpeedSp', 10.0),
('Line1.TempSp', 20.0),
('dup', 1.0),
('dup', 2.0);
-- ── the read-only login ──────────────────────────────────────────────────────────────────────
DROP USER IF EXISTS 'otopcua'@'%';
CREATE USER 'otopcua'@'%' IDENTIFIED BY '__APP_PASSWORD__';
GRANT SELECT ON sqlpoll.* TO 'otopcua'@'%';
GRANT EXECUTE ON FUNCTION sqlpoll.bump_counter TO 'otopcua'@'%';
GRANT UPDATE ON sqlpoll.setpoints TO 'otopcua'@'%';
FLUSH PRIVILEGES;
@@ -265,6 +265,88 @@ public sealed class MySqlReadTests
(await _my.ScalarAsync("SELECT COUNT(*) FROM sqlpoll.side_effects")).ShouldBe(0L);
}
// ---- writes (design §3.4) against a real server ----
/// <summary>All gates open: the UPDATE lands on exactly the keyed row, and the driver reads it back.</summary>
[Fact]
public async Task Write_realMySql_updatesTheKeyedRowOnly()
{
SkipUnlessLive();
await using var driver = await StartAsync(null, allowWrites: true,
Setpoint("Sql/Sp/Speed", "Line1.SpeedSp", writable: true));
var target = 10.0 + Random.Shared.Next(1, 1000);
(await WriteAsync(driver, "Sql/Sp/Speed", target)).ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(await _my.ScalarRawAsync("SELECT sp_value FROM sqlpoll.setpoints WHERE tag_name = 'Line1.SpeedSp'"),
CultureInfo.InvariantCulture).ShouldBe(target);
Convert.ToDouble(await _my.ScalarRawAsync("SELECT sp_value FROM sqlpoll.setpoints WHERE tag_name = 'Line1.TempSp'"),
CultureInfo.InvariantCulture).ShouldBe(20.0);
Convert.ToDouble((await driver.ReadAsync(["Sql/Sp/Speed"], TestContext.Current.CancellationToken))[0].Value,
CultureInfo.InvariantCulture).ShouldBe(target);
}
/// <summary>A key matching two rows is rolled back on InnoDB — neither row changes.</summary>
[Fact]
public async Task Write_realMySql_aKeyMatchingTwoRowsIsRolledBack()
{
SkipUnlessLive();
await using var driver = await StartAsync(null, allowWrites: true, Setpoint("Sql/Sp/Dup", "dup", writable: true));
(await WriteAsync(driver, "Sql/Sp/Dup", 99.0)).ShouldBe(SqlStatusCodes.BadConfigurationError);
(await _my.ScalarAsync("SELECT COUNT(*) FROM sqlpoll.setpoints WHERE tag_name = 'dup' AND sp_value = 99")).ShouldBe(0L);
}
/// <summary>
/// Defence in depth: with every driver gate open, a table the login holds no UPDATE on is refused by the
/// server itself (the provider error surfaces as BadCommunicationError) and the row is unchanged.
/// </summary>
[Fact]
public async Task Write_realMySql_aTableWithoutAnUpdateGrantIsRefusedByTheServer()
{
SkipUnlessLive();
await using var driver = await StartAsync(null, allowWrites: true, Tag("Sql/Kv/Speed", new JsonObject
{
["model"] = "KeyValue", ["table"] = MySqlFixture.Qualified("tag_values"), ["keyColumn"] = "tag_name",
["keyValue"] = MySqlFixture.PresentKey, ["valueColumn"] = "num_value", ["writable"] = true,
}));
(await WriteAsync(driver, "Sql/Kv/Speed", 1.0)).ShouldBe(SqlStatusCodes.BadCommunicationError);
Convert.ToDouble(await _my.ScalarRawAsync("SELECT num_value FROM sqlpoll.tag_values WHERE tag_name = 'Line1.Speed'"),
CultureInfo.InvariantCulture).ShouldBe(MySqlFixture.PresentValue);
}
/// <summary>The default: no allowWrites ⇒ refused before any connection, whatever the tag says.</summary>
[Fact]
public async Task Write_realMySql_withoutAllowWritesIsRefused()
{
SkipUnlessLive();
await using var driver = await StartAsync(null, allowWrites: false, Setpoint("Sql/Sp/Temp", "Line1.TempSp", writable: true));
(await WriteAsync(driver, "Sql/Sp/Temp", 77.0)).ShouldBe(SqlStatusCodes.BadNotWritable);
Convert.ToDouble(await _my.ScalarRawAsync("SELECT sp_value FROM sqlpoll.setpoints WHERE tag_name = 'Line1.TempSp'"),
CultureInfo.InvariantCulture).ShouldBe(20.0);
}
private static RawTagEntry Setpoint(string rawPath, string key, bool writable) => Tag(rawPath, new JsonObject
{
["model"] = "KeyValue",
["table"] = MySqlFixture.Qualified("setpoints"),
["keyColumn"] = "tag_name",
["keyValue"] = key,
["valueColumn"] = "sp_value",
["type"] = "Float64",
["writable"] = writable,
});
private static async Task<uint> WriteAsync(SqlDriver driver, string reference, object? value)
=> (await ((IWritable)driver).WriteAsync([new WriteRequest(reference, value)], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem().StatusCode;
// ---- helpers ----
private void SkipUnlessLive()
@@ -274,13 +356,16 @@ public sealed class MySqlReadTests
private Task<SqlDriver> StartAsync(params RawTagEntry[] tags) => StartAsync(queriesJson: null, tags);
private Task<SqlDriver> StartAsync(string? queriesJson, params RawTagEntry[] tags)
=> StartAsync(queriesJson, allowWrites: false, tags);
/// <summary>
/// Builds and initializes a driver through the PRODUCTION factory (<c>provider: MySql</c>, the connection
/// string resolved from the fixture's <c>connectionStringRef</c> environment variable), so the suite
/// exercises <c>MySqlConnectorFactory.Instance</c> and the factory's own named-query validation exactly as a
/// deployed config would.
/// </summary>
private async Task<SqlDriver> StartAsync(string? queriesJson, params RawTagEntry[] tags)
private async Task<SqlDriver> StartAsync(string? queriesJson, bool allowWrites, params RawTagEntry[] tags)
{
var rawTags = new JsonArray();
foreach (var tag in tags)
@@ -302,6 +387,7 @@ public sealed class MySqlReadTests
["rawTags"] = rawTags,
};
if (queriesJson is not null) config["queries"] = JsonNode.Parse(queriesJson);
if (allowWrites) config["allowWrites"] = true;
var json = config.ToJsonString();
var driver = SqlDriverFactoryExtensions.CreateInstance("sql-mysql-integration", json);
@@ -433,6 +519,15 @@ public sealed class MySqlFixture : IAsyncLifetime
/// <param name="sql">The statement.</param>
/// <returns>The scalar, as a 64-bit integer when numeric.</returns>
public async Task<object?> ScalarAsync(string sql)
{
var value = await ScalarRawAsync(sql).ConfigureAwait(false);
return value is IConvertible and not string ? Convert.ToInt64(value, CultureInfo.InvariantCulture) : value;
}
/// <summary>Runs a scalar query as the fixture login and returns the provider's value unconverted.</summary>
/// <param name="sql">The statement.</param>
/// <returns>The scalar.</returns>
public async Task<object?> ScalarRawAsync(string sql)
{
var connection = new MySqlDialect().Factory.CreateConnection()
?? throw new InvalidOperationException("MySqlConnector returned no connection object.");
@@ -444,8 +539,7 @@ public sealed class MySqlFixture : IAsyncLifetime
await using (command.ConfigureAwait(false))
{
command.CommandText = sql;
var value = await command.ExecuteScalarAsync().ConfigureAwait(false);
return value is IConvertible and not string ? Convert.ToInt64(value, CultureInfo.InvariantCulture) : value;
return await command.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}
@@ -337,34 +337,43 @@ public sealed class SqlDriverFactoryTests
options.RawTags.ShouldBeEmpty();
}
// ---- v1 is read-only, structurally ----
// ---- allowWrites (lmxopcua#753): honoured now, default off ----
[Theory]
[InlineData(",\"allowWrites\":true", true)]
[InlineData(",\"allowWrites\":false", false)]
[InlineData("", false)] // absent ⇒ read-only
public void ParseBinding_carriesAllowWritesOntoTheOptions_defaultOff(string fragment, bool expected)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
SqlDriverFactoryExtensions.ParseBinding("d1", $$"""{"connectionStringRef":"{{name}}"{{fragment}}}""")
.Options.AllowWrites.ShouldBe(expected);
}
[Fact]
public void CreateInstance_anAuthoredAllowWrites_warnsLoudly_andTheDriverStaysReadOnly()
public void CreateInstance_withAllowWrites_logsNoWarning_andSaysWritesAreOn()
{
// allowWrites is inert by construction (IWritable is not implemented), so honouring it is impossible
// and rejecting it would brick a deployment over a flag that cannot do harm. The remaining failure
// mode is an operator who believes writes are enabled — which only a loud warning closes.
// allowWrites used to be inert and drew a "this build ignores it" warning; it is honoured now, so the
// flag is an ordinary setting — reported in the configured line, never as a warning.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var loggerFactory = new RecordingLoggerFactory();
var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory);
SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory)
.ShouldBeAssignableTo<IWritable>();
driver.ShouldNotBeAssignableTo<IWritable>();
var warning = loggerFactory.Entries
.Where(e => e.Level >= LogLevel.Warning)
.ShouldHaveSingleItem();
warning.Message.ShouldContain("allowWrites");
warning.Message.ShouldContain("read-only");
loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty();
loggerFactory.Entries.ShouldContain(e => e.Message.Contains("allowed for opted-in tags"));
}
[Fact]
public void CreateInstance_withoutAllowWrites_logsNoWarning()
{
// The falsifiability control for the warning above — a warning that always fires proves nothing.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
@@ -46,8 +46,9 @@ public sealed class SqlDriverTests
capture.Variables.ShouldContain(v =>
v.Info.FullName == "Speed" && v.Info.SecurityClass == SecurityClassification.ViewOnly);
// v1 is read-only by construction, not by configuration.
driver.ShouldNotBeAssignableTo<IWritable>();
// Writes are off by default (no allowWrites): the driver is IWritable, but refuses.
(await ((IWritable)driver).WriteAsync([new WriteRequest("Speed", 1.0)], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNotWritable);
}
[Fact]
@@ -0,0 +1,307 @@
using System.Globalization;
using System.Text.Json.Nodes;
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>
/// The opt-in write path (design §3.4, lmxopcua#753): <c>allowWrites</c> + the tag's <c>"writable": true</c>
/// + a single-row model, executed as a parameterised <c>UPDATE</c> over catalog-validated identifiers that
/// commits only when exactly one row matched. Over SQLite, end to end through <see cref="SqlDriver"/>.
/// </summary>
public sealed class SqlWriteTests
{
private const string DriverInstanceId = "sql-write";
// ---- tag opt-in parse ----
[Fact]
public void TryParse_readsWritable_andDefaultsItOff()
{
SqlEquipmentTagParser.TryParse(Kv(writable: true), "a", out var on).ShouldBeTrue();
on.Writable.ShouldBeTrue();
SqlEquipmentTagParser.TryParse(Kv(writable: null), "b", out var absent).ShouldBeTrue();
absent.Writable.ShouldBeFalse();
}
[Theory]
[InlineData("\"true\"")]
[InlineData("1")]
[InlineData("null")]
public void TryParse_aNonBooleanWritable_refusesTheTag_andTheGateSaysWhy(string writable)
{
var json = $$"""{"model":"KeyValue","table":"T","keyColumn":"k","keyValue":"x","valueColumn":"v","writable":{{writable}}}""";
SqlEquipmentTagParser.TryParse(json, "a", out _).ShouldBeFalse();
SqlTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("writable", StringComparison.Ordinal));
}
// ---- planner: the only statement a write can become ----
[Fact]
public void Plan_keyValue_isASingleRowUpdate_withTheValueAndKeyBound()
{
var tag = new SqlTagDefinition("A", SqlTagModel.KeyValue, "dbo.TagValues",
KeyColumn: "tag_name", KeyValue: "Line1.Speed", ValueColumn: "num_value", Writable: true);
SqlWritePlanner.TryPlan(tag, 12.5, new SqlServerDialect(), out var plan).ShouldBe(SqlStatusCodes.Good);
plan!.SqlText.ShouldBe("UPDATE [dbo].[TagValues] SET [num_value] = @v WHERE [tag_name] = @k");
plan.ParameterBindNames.ShouldBe(["@v", "@k"]);
plan.Parameters.ShouldBe([12.5, "Line1.Speed"]);
}
[Fact]
public void Plan_wideRowWherePair_updatesTheSelectedRowsColumn_valueBeforeKeyForPositionalProviders()
{
var tag = new SqlTagDefinition("A", SqlTagModel.WideRow, "plant.status", ColumnName: "setpoint",
RowSelectorColumn: "station_id", RowSelectorValue: "7", Writable: true);
SqlWritePlanner.TryPlan(tag, 3, new OdbcDialect(), out var plan).ShouldBe(SqlStatusCodes.Good);
plan!.SqlText.ShouldBe("UPDATE \"plant\".\"status\" SET \"setpoint\" = ? WHERE \"station_id\" = ?");
plan.Parameters.ShouldBe([3, "7"]); // marker order: SET value, then WHERE key
}
[Fact]
public void Plan_aHostileValueIsBound_neverConcatenated()
{
const string hostile = "x'; DROP TABLE TagValues; --";
var tag = new SqlTagDefinition("A", SqlTagModel.KeyValue, "T", KeyColumn: "k", KeyValue: "x", ValueColumn: "v");
SqlWritePlanner.TryPlan(tag, hostile, new SqlServerDialect(), out var plan).ShouldBe(SqlStatusCodes.Good);
plan!.SqlText.ShouldNotContain("DROP");
plan.Parameters[0].ShouldBe(hostile);
}
[Fact]
public void Plan_refusesModelsWithNoSingleRowTarget()
{
var newest = new SqlTagDefinition("A", SqlTagModel.WideRow, "T", ColumnName: "c", RowSelectorTopByTimestamp: "ts");
var query = new SqlTagDefinition("B", SqlTagModel.Query, string.Empty, ColumnName: "c", QueryName: "q");
SqlWritePlanner.TryPlan(newest, 1, new SqlServerDialect(), out _).ShouldBe(SqlStatusCodes.BadNotWritable);
SqlWritePlanner.TryPlan(query, 1, new SqlServerDialect(), out _).ShouldBe(SqlStatusCodes.BadNotWritable);
}
[Fact]
public void Plan_coercesToTheDeclaredType_andRefusesWhatWillNotCoerce()
{
var tag = new SqlTagDefinition("A", SqlTagModel.KeyValue, "T", KeyColumn: "k", KeyValue: "x", ValueColumn: "v",
DeclaredType: DriverDataType.Int32);
SqlWritePlanner.TryPlan(tag, "41", new SqlServerDialect(), out var plan).ShouldBe(SqlStatusCodes.Good);
plan!.Parameters[0].ShouldBe(41);
SqlWritePlanner.TryPlan(tag, "not a number", new SqlServerDialect(), out _).ShouldBe(SqlStatusCodes.BadTypeMismatch);
SqlWritePlanner.TryPlan(tag with { DeclaredType = null }, new object(), new SqlServerDialect(), out _)
.ShouldBe(SqlStatusCodes.BadTypeMismatch);
}
// ---- the gates, end to end ----
[Fact]
public async Task Write_withAllowWritesOff_isRefused_andTheTableIsUntouched()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: false, Entry("Speed", Kv(writable: true)));
(await WriteAsync(driver, "Speed", 99.0)).ShouldBe(SqlStatusCodes.BadNotWritable);
ValueOf(fixture, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue);
}
[Fact]
public async Task Write_toATagThatDidNotOptIn_isRefused_evenWithAllowWritesOn()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Speed", Kv(writable: null)));
(await WriteAsync(driver, "Speed", 99.0)).ShouldBe(SqlStatusCodes.BadNotWritable);
ValueOf(fixture, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue);
}
[Fact]
public async Task Write_allGatesOpen_updatesTheRow_andAReadSeesIt()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true,
Entry("Speed", Kv(writable: true)), Entry("Pressure", Kv(writable: true, key: SqlitePollFixture.SecondPresentKey)));
(await WriteAsync(driver, "Speed", 99.5)).ShouldBe(SqlStatusCodes.Good);
ValueOf(fixture, SqlitePollFixture.PresentKey).ShouldBe(99.5);
ValueOf(fixture, SqlitePollFixture.SecondPresentKey).ShouldBe(SqlitePollFixture.SecondPresentValue); // only one row
var read = (await driver.ReadAsync(["Speed"], CancellationToken.None))[0];
Convert.ToDouble(read.Value, CultureInfo.InvariantCulture).ShouldBe(99.5);
}
[Fact]
public async Task Write_aWideRowWherePair_updatesThatStationsColumnOnly()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Oven7", $$"""
{"model":"WideRow","table":"{{SqlitePollFixture.WideRowTable}}","columnName":"oven_temp",
"rowSelector":{"whereColumn":"station_id","whereValue":7},"writable":true}
"""));
(await WriteAsync(driver, "Oven7", 190.0)).ShouldBe(SqlStatusCodes.Good);
Scalar(fixture, "SELECT oven_temp FROM LatestStatus WHERE station_id = 7").ShouldBe(190.0);
Scalar(fixture, "SELECT oven_temp FROM LatestStatus WHERE station_id = 8").ShouldBe(SqlitePollFixture.NewestStationOvenTemp);
}
[Fact]
public async Task Write_toAnAbsentKey_isBadNoData_andInsertsNothing()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true,
Entry("Missing", Kv(writable: true, key: SqlitePollFixture.AbsentKey)));
(await WriteAsync(driver, "Missing", 1.0)).ShouldBe(SqlStatusCodes.BadNoData);
Scalar(fixture, "SELECT COUNT(*) FROM TagValues").ShouldBe(3.0);
}
/// <summary>
/// A key that matches two rows would update both. The write is rolled back and refused, so neither row
/// changes — the write-side twin of the read path's duplicate-key refusal.
/// </summary>
[Fact]
public async Task Write_matchingMoreThanOneRow_isRolledBack_andRefused()
{
using var fixture = new SqlitePollFixture();
fixture.Execute($"INSERT INTO TagValues (tag_name, num_value, sample_ts) VALUES ('{SqlitePollFixture.PresentKey}', 7.0, '2026-07-24T11:00:00Z')");
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Speed", Kv(writable: true)));
(await WriteAsync(driver, "Speed", 55.0)).ShouldBe(SqlStatusCodes.BadConfigurationError);
Scalar(fixture, $"SELECT COUNT(*) FROM TagValues WHERE num_value = 55.0").ShouldBe(0.0);
}
[Fact]
public async Task Write_toAnUnknownOrGateRejectedTag_isBadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Ghost", """
{"model":"KeyValue","table":"NoSuchTable","keyColumn":"k","keyValue":"x","valueColumn":"v","writable":true}
"""));
(await WriteAsync(driver, "Ghost", 1.0)).ShouldBe(SqlStatusCodes.BadNodeIdUnknown); // the catalog gate rejected it
(await WriteAsync(driver, "Nobody", 1.0)).ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
[Fact]
public async Task Write_aHostileString_isStoredVerbatim()
{
using var fixture = new SqlitePollFixture();
fixture.Execute("CREATE TABLE Notes (k TEXT NOT NULL, note TEXT NULL)");
fixture.Execute("INSERT INTO Notes VALUES ('a', 'old')");
const string hostile = "x'); DROP TABLE Notes; --";
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Note", """
{"model":"KeyValue","table":"Notes","keyColumn":"k","keyValue":"a","valueColumn":"note","writable":true,"type":"String"}
"""));
(await WriteAsync(driver, "Note", hostile)).ShouldBe(SqlStatusCodes.Good);
using var command = fixture.Connection.CreateCommand();
command.CommandText = "SELECT note FROM Notes WHERE k = 'a'";
command.ExecuteScalar().ShouldBe(hostile);
}
[Fact]
public async Task Discover_advertisesOperate_onlyWhereAWriteCouldSucceed()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true,
Entry("Writable", Kv(writable: true)),
Entry("ReadOnly", Kv(writable: null)),
Entry("Newest", $$"""
{"model":"WideRow","table":"{{SqlitePollFixture.WideRowTable}}","columnName":"oven_temp",
"rowSelector":{"topByTimestamp":"sample_ts"},"writable":true}
"""));
var builder = new ClassCapturingBuilder();
await driver.DiscoverAsync(builder, CancellationToken.None);
builder.Classes["Writable"].ShouldBe(SecurityClassification.Operate);
builder.Classes["ReadOnly"].ShouldBe(SecurityClassification.ViewOnly);
builder.Classes["Newest"].ShouldBe(SecurityClassification.ViewOnly); // no single-row target
}
// ---- helpers ----
private static string Kv(bool? writable, string key = SqlitePollFixture.PresentKey)
{
var o = new JsonObject
{
["model"] = "KeyValue",
["table"] = SqlitePollFixture.KeyValueTable,
["keyColumn"] = SqlitePollFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = SqlitePollFixture.ValueColumn,
["type"] = "Float64",
};
if (writable is { } w) o["writable"] = w;
return o.ToJsonString();
}
private static RawTagEntry Entry(string rawPath, string json) => new(rawPath, json, WriteIdempotent: false);
private static async Task<uint> WriteAsync(SqlDriver driver, string reference, object? value)
=> (await ((IWritable)driver).WriteAsync([new WriteRequest(reference, value)], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode;
private static double ValueOf(SqlitePollFixture fixture, string key)
=> Scalar(fixture, $"SELECT num_value FROM TagValues WHERE tag_name = '{key}'");
private static double Scalar(SqlitePollFixture fixture, string sql)
{
using var command = fixture.Connection.CreateCommand();
command.CommandText = sql;
return Convert.ToDouble(command.ExecuteScalar(), CultureInfo.InvariantCulture);
}
private static async Task<SqlDriver> StartAsync(SqlitePollFixture fixture, bool allowWrites, params RawTagEntry[] tags)
{
var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = tags,
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
AllowWrites = allowWrites,
},
DriverInstanceId, new SqliteDialect(), fixture.ConnectionString, fixture.Factory);
await driver.InitializeAsync("{}", CancellationToken.None);
return driver;
}
/// <summary>Records each discovered variable's security classification.</summary>
private sealed class ClassCapturingBuilder : IAddressSpaceBuilder
{
public Dictionary<string, SecurityClassification> Classes { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Classes[attributeInfo.FullName] = attributeInfo.SecurityClass;
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullReference) : IVariableHandle
{
public string FullReference => fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
}