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
212 lines
11 KiB
C#
212 lines
11 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
|
|
|
/// <summary>
|
|
/// Guards the AdminUI <c>SqlDriverForm</c>'s driver-config output against the runtime factory that consumes
|
|
/// it. The form is razor (no bUnit here), so these tests pin two things the live verify then confirms:
|
|
/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative
|
|
/// reader, <see cref="SqlDriverFactoryExtensions.CreateInstance(string,string)"/>; and (2) the
|
|
/// serialization contract — <c>provider</c> as a NAME string (never an ordinal), camelCase keys, and no
|
|
/// composer-owned <c>rawTags</c> or an unrequested <c>allowWrites</c> leaking into the authored blob. A numeric enum
|
|
/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards.
|
|
/// </summary>
|
|
public sealed class SqlDriverFormContractTests
|
|
{
|
|
// The JsonSerializerOptions SqlDriverForm serializes with (camelCase + string enums + omit-nulls).
|
|
private static readonly JsonSerializerOptions FormOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
WriteIndented = false,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
// ---- LOAD-BEARING: the exact bytes SqlDriverForm emits must construct a driver through the factory ----
|
|
|
|
[Theory]
|
|
// Minimal valid config: provider + required connectionStringRef only (all timeouts left to the factory).
|
|
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest"}""")]
|
|
// Fuller config with every optional the form authors (timeouts satisfy operationTimeout > commandTimeout).
|
|
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","defaultPollInterval":"00:00:05","operationTimeout":"00:00:20","commandTimeout":"00:00:10","maxConcurrentGroups":8,"nullIsBad":true}""")]
|
|
public void FormEmittedJson_constructs_a_driver_through_the_factory(string formEmittedJson)
|
|
{
|
|
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
|
|
var previous = Environment.GetEnvironmentVariable(envVar);
|
|
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
|
|
try
|
|
{
|
|
var driver = SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", formEmittedJson);
|
|
driver.ShouldNotBeNull();
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envVar, previous);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Factory_rejects_a_blob_missing_connectionStringRef()
|
|
{
|
|
// The form marks connectionStringRef required; the factory is the authoritative enforcement.
|
|
var ex = Should.Throw<InvalidOperationException>(
|
|
() => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", """{"provider":"SqlServer"}"""));
|
|
ex.Message.ShouldContain("connectionStringRef");
|
|
}
|
|
|
|
// ---- serialization contract: enum-by-name, camelCase, no rawTags / allowWrites / null optionals --------
|
|
|
|
[Fact]
|
|
public void Minimal_config_serializes_provider_as_a_name_and_omits_owned_and_null_fields()
|
|
{
|
|
// Mirrors SqlDriverForm.FormModel.ToDto() for a minimal config (only the required ref set).
|
|
var dto = new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "MesStaging" };
|
|
|
|
var json = JsonSerializer.Serialize(dto, FormOptions);
|
|
|
|
json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""");
|
|
json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read
|
|
json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form
|
|
json.ShouldNotContain("allowWrites"); // off ⇒ omitted ⇒ read-only
|
|
json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies
|
|
}
|
|
|
|
[Fact]
|
|
public void Provider_round_trips_as_the_SqlServer_name()
|
|
{
|
|
var json = JsonSerializer.Serialize(
|
|
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "R" }, FormOptions);
|
|
|
|
json.ShouldContain("\"SqlServer\"");
|
|
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
|
|
}
|
|
|
|
// ---- the multi-provider dropdown: every offered option survives the form model's own round-trip ----
|
|
|
|
/// <summary>
|
|
/// The dropdown offers SqlServer + Postgres + Oracle, so each must survive
|
|
/// <c>FormModel.ToDto()</c> → JSON → <c>FormModel.FromDto()</c> as a NAME string. The razor markup
|
|
/// itself is not reachable (no bUnit), so this pins the model half of the change: a provider the
|
|
/// dropdown can select but the form model cannot carry back would silently reset to SqlServer on
|
|
/// the next modal open.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(SqlProvider.SqlServer, "SqlServer")]
|
|
[InlineData(SqlProvider.Postgres, "Postgres")]
|
|
[InlineData(SqlProvider.Oracle, "Oracle")]
|
|
[InlineData(SqlProvider.MySql, "MySql")]
|
|
[InlineData(SqlProvider.Odbc, "Odbc")]
|
|
public void Every_dropdown_provider_round_trips_through_the_form_model(SqlProvider provider, string name)
|
|
{
|
|
var form = new SqlDriverForm.FormModel { Provider = provider, ConnectionStringRef = "MesStaging" };
|
|
|
|
var json = JsonSerializer.Serialize(form.ToDto(), FormOptions);
|
|
|
|
json.ShouldContain($"\"provider\":\"{name}\"");
|
|
json.ShouldNotContain($"\"provider\":{(int)provider}"); // never an ordinal — that faults the factory read
|
|
var reloaded = SqlDriverForm.FormModel.FromDto(
|
|
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!);
|
|
reloaded.Provider.ShouldBe(provider);
|
|
reloaded.ConnectionStringRef.ShouldBe("MesStaging");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The dropdown offers every provider the build constructs (lmxopcua#753 made all five enum members real),
|
|
/// so the form-model theory above must cover exactly <see cref="SqlDialects.Supported"/> — a provider added
|
|
/// to the map without a dropdown option (or the reverse) has to argue with this test.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Every_constructed_provider_is_covered_by_the_dropdown_theory()
|
|
=> SqlDialects.Supported.ShouldBe(
|
|
[SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.MySql, SqlProvider.Odbc, SqlProvider.Oracle],
|
|
ignoreOrder: true);
|
|
|
|
// ---- named queries (lmxopcua#753): the form refuses exactly what the factory refuses ----
|
|
|
|
/// <summary>
|
|
/// The driver form's queries editor consults the runtime's own rules
|
|
/// (<see cref="SqlNamedQueryRules.Describe"/>), so what the form keeps is what the factory constructs and
|
|
/// what it refuses the factory refuses too. Driven through the real factory entry point, so a rule added
|
|
/// on one side only breaks this test.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("""{"q":{"sql":"SELECT a FROM t WHERE x = @x","params":{"x":1}}}""", true)]
|
|
[InlineData("""{"q":{"sql":"WITH l AS (SELECT a FROM t) SELECT a FROM l;","maxRows":10}}""", true)]
|
|
[InlineData("""{"q":{"sql":"UPDATE t SET a = 1"}}""", false)]
|
|
[InlineData("""{"q":{"sql":"SELECT a INTO t2 FROM t"}}""", false)]
|
|
[InlineData("""{"q":{"sql":"SELECT 1; SELECT 2"}}""", false)]
|
|
[InlineData("""{"q":{"sql":"SELECT a FROM t","maxRows":0}}""", false)]
|
|
[InlineData("""{"q":{"sql":"SELECT a FROM t WHERE x = @x","params":{"x":{"nested":1}}}}""", false)]
|
|
[InlineData("""{"q":{"sql":""}}""", false)]
|
|
public void Form_queries_editor_and_factory_agree(string queriesJson, bool accepted)
|
|
{
|
|
var formError = SqlDriverForm.FormModel.TryParseQueries(queriesJson, out _);
|
|
(formError is null).ShouldBe(accepted, formError);
|
|
|
|
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
|
|
var previous = Environment.GetEnvironmentVariable(envVar);
|
|
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
|
|
try
|
|
{
|
|
var blob = $$"""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","queries":{{queriesJson}}}""";
|
|
if (accepted) { SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", blob).ShouldNotBeNull(); }
|
|
else
|
|
{
|
|
Should.Throw<InvalidOperationException>(
|
|
() => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", blob));
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envVar, previous);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AllowWrites_is_emitted_only_when_switched_on_and_the_factory_honours_it()
|
|
{
|
|
var off = JsonSerializer.Serialize(new SqlDriverForm.FormModel { ConnectionStringRef = "R" }.ToDto(), FormOptions);
|
|
off.ShouldNotContain("allowWrites");
|
|
|
|
var form = new SqlDriverForm.FormModel { ConnectionStringRef = "AdminUiFormTest", AllowWrites = true };
|
|
var on = JsonSerializer.Serialize(form.ToDto(), FormOptions);
|
|
on.ShouldContain("\"allowWrites\":true");
|
|
SqlDriverForm.FormModel.FromDto(JsonSerializer.Deserialize<SqlDriverConfigDto>(on, FormOptions)!)
|
|
.AllowWrites.ShouldBeTrue();
|
|
|
|
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
|
|
var previous = Environment.GetEnvironmentVariable(envVar);
|
|
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
|
|
try
|
|
{
|
|
SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", on)
|
|
.ShouldBeAssignableTo<ZB.MOM.WW.OtOpcUa.Core.Abstractions.IWritable>();
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envVar, previous);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Queries_survive_the_form_model_round_trip_and_a_typo_keeps_the_last_valid_set()
|
|
{
|
|
var dto = JsonSerializer.Deserialize<SqlDriverConfigDto>(
|
|
"""{"provider":"SqlServer","connectionStringRef":"R","queries":{"q":{"sql":"SELECT a FROM t"}}}""", FormOptions)!;
|
|
var form = SqlDriverForm.FormModel.FromDto(dto);
|
|
|
|
form.SetQueriesJson("{ not json");
|
|
form.QueriesError.ShouldNotBeNull();
|
|
|
|
var json = JsonSerializer.Serialize(form.ToDto(), FormOptions);
|
|
json.ShouldContain("\"queries\":{\"q\":{\"sql\":\"SELECT a FROM t\"}}");
|
|
}
|
|
}
|