a68593e714
SqlTagModel.Query (design §5.4) is now served end to end instead of refused. The driver config declares `queries` (name -> sql, params, maxRows); a tag names the query, the result `column` it reads and optionally a `rowKey` picking its row. - Parser/deploy gate: query + column required, rowKey all-or-nothing; Inspect explains each refusal. Table is not read for this model. - SqlReadOnlyStatement: single SELECT/WITH only, no data/schema-changing keyword outside literals/comments (refuses SELECT INTO, FOR UPDATE, DML CTEs). Shared by the factory and the AdminUI driver form (SqlNamedQueryRules.Describe), so the form refuses what the driver refuses. One bad entry fails the driver. - Parameters are always bound; the factory refuses a declared parameter whose dialect marker the statement never uses. - Reader: one execution per query per poll, inside a transaction that is always rolled back; result columns matched exact-then-unique-CI; no row -> BadNoData, ambiguous row / missing column / over maxRows -> BadConfigurationError. - Catalog gate passes Query tags through (nothing is quoted into SQL); a Query-only driver skips the catalog load. A tag naming an undeclared query is skipped with the reason on /hosts. - AdminUI: typed editor offers Query (query/column/rowKey); driver form edits `queries` as JSON and keeps the last valid set on a typo. (lmxopcua#753) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
188 lines
9.6 KiB
C#
188 lines
9.6 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> / inert <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"); // inert in v1 (read-only); never authored
|
|
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")]
|
|
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>
|
|
/// MySql and Odbc stay reserved-and-rejected (design §2), so the dropdown deliberately does not offer
|
|
/// them. Nothing in the form model blocks the value — this records the intent so a later "the enum has
|
|
/// five members, the dropdown should too" edit has to argue with a test.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(SqlProvider.MySql)]
|
|
[InlineData(SqlProvider.Odbc)]
|
|
public void Reserved_providers_are_not_offered_but_still_serialize_by_name(SqlProvider reserved)
|
|
{
|
|
var json = JsonSerializer.Serialize(
|
|
new SqlDriverForm.FormModel { Provider = reserved, ConnectionStringRef = "R" }.ToDto(), FormOptions);
|
|
|
|
json.ShouldContain($"\"provider\":\"{reserved}\"");
|
|
}
|
|
// ---- 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 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\"}}");
|
|
}
|
|
}
|