2331f7ed14
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
308 lines
14 KiB
C#
308 lines
14 KiB
C#
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) { }
|
|
}
|
|
}
|
|
}
|
|
}
|