feat(sql): factory + connectionStringRef env resolution + string-enum guard
SqlDriverFactoryExtensions turns a deployed DriverConfig blob into a live SqlDriver, and SqlConnectionStringResolver resolves the authored connectionStringRef NAME from Sql__ConnectionStrings__<ref> so credentials never ride in a config blob (design §8.2). Three behaviours worth naming: - String-enum guard. JsonOptions carries JsonStringEnumConverter + camelCase, so `provider` parses whether authored as a name or an ordinal and always round-trips as the NAME — the systemic AdminUI defect where a page serialised an enum numerically against a string-typed DTO. - Config validation lands here because nothing below does it. operationTimeout must be STRICTLY greater than commandTimeout (design §8.3); the reader and the driver stay deliberately usable with the pair inverted so the frozen-database tests can prove the client-side bound fires. Non-positive timeouts / poll interval and maxConcurrentGroups < 1 are rejected too. - Credential hygiene. The resolved connection string reaches the provider and nothing else: messages name the ref, the environment variable, or SqlDriver.Endpoint's credential-free server/database rendering. Both a log-leak and an exception-leak test cover it. DTO changes: - Adds RawTags (List<RawTagEntry>), following the Modbus precedent — the deploy artifact delivers a driver's tags this way and the DTO had no way to receive them, so an authored Sql tag could never reach the driver. - Deletes SqlProbeDto + the `probe` key. It had no consumer: SqlDriver was specified without a background probe loop, and deliberately so — its IHostConnectivityProbe state is a by-product of the Initialize liveness check and of every poll, i.e. a statement about traffic that actually happened rather than a synthetic ping. On-demand connectivity is Task 10's SqlDriverProbe. Shipping a config key that silently does nothing is worse than not shipping it; UnmappedMemberHandling.Skip means a blob that still carries `probe` keeps parsing. allowWrites stays inert (SqlDriver implements no write capability) but an authored `true` now WARNS rather than passing silently — the flag cannot do harm, an operator who believes writes are enabled can. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>
|
||||
@@ -41,19 +43,22 @@ public sealed class SqlDriverConfigDto
|
||||
/// <summary>When <see langword="true"/>, a NULL cell publishes Bad rather than the default Uncertain.</summary>
|
||||
public bool? NullIsBad { get; init; }
|
||||
|
||||
/// <summary>Master write kill-switch. <b>v1 is read-only</b> — writes stay disabled regardless.</summary>
|
||||
/// <summary>
|
||||
/// Master write kill-switch. <b>v1 is read-only and this field is inert</b> — the driver does not
|
||||
/// implement <c>IWritable</c>, so no value here can produce a write. The factory <em>warns</em> when it
|
||||
/// is authored <see langword="true"/> rather than failing the driver, since the flag cannot do harm but
|
||||
/// an operator who believes writes are enabled can.
|
||||
/// </summary>
|
||||
public bool? AllowWrites { get; init; }
|
||||
|
||||
/// <summary>Background connectivity-probe settings. Absent ⇒ the factory default.</summary>
|
||||
public SqlProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Background connectivity-probe settings inside a <see cref="SqlDriverConfigDto"/> (design §5.1).</summary>
|
||||
public sealed class SqlProbeDto
|
||||
{
|
||||
/// <summary>Whether the driver runs its background connectivity probe.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Interval between probe attempts.</summary>
|
||||
public TimeSpan? Interval { get; init; }
|
||||
/// <summary>
|
||||
/// The authored raw tags the deploy artifact delivers for this driver instance — RawPath identity plus
|
||||
/// the driver <c>TagConfig</c> blob, the same shape every other driver receives. The factory copies
|
||||
/// these onto the driver's options and the driver maps each through
|
||||
/// <see cref="SqlEquipmentTagParser.TryParse"/> at Initialize.
|
||||
/// <para>A SQL source has no tag-discovery protocol, so this list is the complete set of tags the
|
||||
/// instance serves — an absent or empty list is a driver with nothing to poll, not a driver that will
|
||||
/// find its tags later.</para>
|
||||
/// </summary>
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a <c>Sql</c> driver config's <c>connectionStringRef</c> — a NAME — into the connection string
|
||||
/// it stands for (design §8.2). The deployed artifact carries only the name, so database credentials never
|
||||
/// ride in a config blob, never land in the central config DB, and never appear in a deployment diff.
|
||||
/// <para><b>The environment is read directly, on purpose.</b> A driver factory is invoked through
|
||||
/// <c>DriverFactoryRegistry</c>'s <c>(driverInstanceId, driverConfigJson)</c> closure — there is no
|
||||
/// <c>IConfiguration</c>, no <c>IServiceProvider</c> and no ambient scope at that seam, and widening the
|
||||
/// registry's signature for one driver would change every driver in the tree. The
|
||||
/// <c>Sql__ConnectionStrings__<ref></c> spelling is exactly the key .NET's environment-variable
|
||||
/// configuration provider would bind to <c>Sql:ConnectionStrings:<ref></c>, so an operator can set it
|
||||
/// the same way they set every other secret and a later move onto <c>IConfiguration</c> needs no
|
||||
/// re-provisioning.</para>
|
||||
/// <para><b>Nothing here ever emits the resolved value.</b> Messages name the environment
|
||||
/// <em>variable</em>, which is not a secret and is the operator's next action; the value itself is returned
|
||||
/// to exactly one caller and is otherwise unmentioned.</para>
|
||||
/// </summary>
|
||||
public static class SqlConnectionStringResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// The environment-variable prefix a <c>connectionStringRef</c> is appended to. The double underscore
|
||||
/// is .NET's configuration hierarchy separator, so this is the environment spelling of
|
||||
/// <c>Sql:ConnectionStrings:<ref></c>.
|
||||
/// </summary>
|
||||
public const string EnvironmentVariablePrefix = "Sql__ConnectionStrings__";
|
||||
|
||||
/// <summary>The environment variable a given <c>connectionStringRef</c> is read from.</summary>
|
||||
/// <param name="connectionStringRef">The authored reference name.</param>
|
||||
/// <returns>The full environment-variable name.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
|
||||
public static string EnvironmentVariableFor(string connectionStringRef)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionStringRef);
|
||||
return string.Concat(EnvironmentVariablePrefix, connectionStringRef);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <paramref name="connectionStringRef"/> to its connection string.
|
||||
/// <para>An absent <b>or blank</b> variable is a half-provisioned deployment and throws: handing an
|
||||
/// empty string to the provider would surface as an unintelligible ADO.NET error at the first poll,
|
||||
/// far from the missing secret that actually caused it.</para>
|
||||
/// </summary>
|
||||
/// <param name="connectionStringRef">The authored reference name.</param>
|
||||
/// <returns>The resolved connection string. <b>Treat as a secret</b> — never log it.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
|
||||
/// <exception cref="InvalidOperationException">The environment variable is absent or blank.</exception>
|
||||
public static string Resolve(string connectionStringRef)
|
||||
{
|
||||
var variable = EnvironmentVariableFor(connectionStringRef);
|
||||
var value = Environment.GetEnvironmentVariable(variable);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql connection string '{connectionStringRef}' is not provisioned: set the environment " +
|
||||
$"variable '{variable}' on every node that runs this driver. The connection string is " +
|
||||
$"deliberately not carried in the deployed configuration.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="SqlDriver"/> — the seam between a deployed
|
||||
/// <c>DriverConfig</c> blob and a live driver instance. The Server's composition root calls
|
||||
/// <see cref="Register"/> once at startup; the bootstrapper then materialises <c>Sql</c> DriverInstance
|
||||
/// rows into driver instances. Mirrors <c>ModbusDriverFactoryExtensions</c>.
|
||||
/// <para><b>This type owns the config validation the layers below deliberately skip.</b>
|
||||
/// <see cref="SqlPollReader"/> and <see cref="SqlDriver"/> stay usable with an inverted
|
||||
/// <c>operationTimeout</c>/<c>commandTimeout</c> pair on purpose (the frozen-database tests need that
|
||||
/// shape), so the factory is the only place a deployment can be stopped before it silently loses one of
|
||||
/// its two independent deadlines. Every knob is checked here, once, at construction.</para>
|
||||
/// <para><b>Enum fields are read leniently and written as names.</b> <see cref="JsonOptions"/> carries a
|
||||
/// <see cref="JsonStringEnumConverter"/>, so a <c>provider</c> authored as an ordinal still parses and a
|
||||
/// round-trip emits <c>"SqlServer"</c> — the systemic AdminUI defect where a page serialised an enum
|
||||
/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time.</para>
|
||||
/// <para><b>The resolved connection string is a secret and never leaves this method except into the
|
||||
/// driver.</b> Every log line and every exception message here names the <c>connectionStringRef</c>, the
|
||||
/// environment variable, or <see cref="SqlDriver.Endpoint"/>'s credential-free <c>server/database</c>
|
||||
/// rendering — never the string itself.</para>
|
||||
/// </summary>
|
||||
public static class SqlDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The driver-type string this factory registers under.
|
||||
/// <para>Chained off <see cref="SqlDriver.DriverTypeName"/> rather than <c>DriverTypeNames.Sql</c>:
|
||||
/// <c>DriverTypeNamesGuardTests</c> asserts bidirectional parity between the shared constants and the
|
||||
/// factories actually registered in the process, so the constant may only be added in the change that
|
||||
/// also wires this factory into the Host (the plan's Task 11, which repoints both at
|
||||
/// <c>DriverTypeNames.Sql</c>).</para>
|
||||
/// </summary>
|
||||
public const string DriverTypeName = SqlDriver.DriverTypeName;
|
||||
|
||||
/// <summary>
|
||||
/// How a <c>Sql</c> config blob is read.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="JsonStringEnumConverter"/> — accepts an enum written as a name OR as an
|
||||
/// ordinal, and always writes the name (the enum-serialization guard).</item>
|
||||
/// <item><see cref="JsonUnmappedMemberHandling.Skip"/> — a blob authored against a different
|
||||
/// schema version must not brick the driver.</item>
|
||||
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — the authored spelling
|
||||
/// (<c>connectionStringRef</c>, <c>rawTags</c>). Reads are case-insensitive anyway; the policy is
|
||||
/// what makes a <em>write</em> round-trip to the authored shape.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>The serializer options, exposed so tests can assert the enum guard on a real round-trip.</summary>
|
||||
internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the <c>Sql</c> factory with the driver registry. The optional
|
||||
/// <paramref name="loggerFactory"/> is captured at registration time and used to build a logger per
|
||||
/// driver instance; without it the driver runs with the null logger.
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="registry"/> is null.</exception>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
}
|
||||
|
||||
/// <summary>Builds one driver instance from its config blob. For the Server bootstrapper and tests.</summary>
|
||||
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
|
||||
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
|
||||
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
|
||||
/// <summary>
|
||||
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
|
||||
/// <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
|
||||
/// the reader's <c>resolve</c> delegate must read the driver's live authored-tag table, which does not
|
||||
/// exist until the driver does.</para>
|
||||
/// <para>No I/O happens here. The database is first contacted by
|
||||
/// <see cref="SqlDriver.InitializeAsync"/>, so a database that is merely down yields a driver in
|
||||
/// Reconnecting rather than a deployment that cannot be constructed.</para>
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
|
||||
/// <exception cref="ArgumentException">An argument is null, empty or whitespace.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The blob is not valid JSON, omits <c>connectionStringRef</c>, names a provider this build cannot
|
||||
/// construct, carries an invalid knob, or the referenced connection string is not provisioned.
|
||||
/// </exception>
|
||||
public static SqlDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = Deserialize(driverInstanceId, driverConfigJson);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
|
||||
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
|
||||
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
|
||||
}
|
||||
|
||||
var dialect = CreateDialect(dto.Provider, driverInstanceId);
|
||||
|
||||
// Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the
|
||||
// secret in hand — which is exactly where a careless interpolation would leak it.
|
||||
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
|
||||
var options = BuildOptions(dto, driverInstanceId);
|
||||
|
||||
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
|
||||
?? NullLogger.Instance;
|
||||
|
||||
if (dto.AllowWrites is true)
|
||||
{
|
||||
// Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it
|
||||
// would brick a deployment for a flag that cannot do harm. The operator who believes writes are
|
||||
// on, however, can — so this is loud.
|
||||
logger.LogWarning(
|
||||
"Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " +
|
||||
"driver is read-only (it implements no write capability), so no configuration can enable a " +
|
||||
"write. Remove the flag, or expect reads only.",
|
||||
driverInstanceId);
|
||||
}
|
||||
|
||||
var driver = new SqlDriver(
|
||||
options,
|
||||
driverInstanceId,
|
||||
dialect,
|
||||
connectionString,
|
||||
factory: null,
|
||||
logger: loggerFactory?.CreateLogger<SqlDriver>());
|
||||
|
||||
// Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
|
||||
// permitted outside the provider call.
|
||||
logger.LogInformation(
|
||||
"Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " +
|
||||
"authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.",
|
||||
driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count,
|
||||
options.DefaultPollInterval, options.OperationTimeout);
|
||||
|
||||
return driver;
|
||||
}
|
||||
|
||||
/// <summary>Deserialises the blob, turning a malformed one into an actionable, instance-named failure.</summary>
|
||||
private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SqlDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' deserialised to null");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the provider seam.
|
||||
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> only. The other members exist on the shipped
|
||||
/// enum as reserved names; authoring one is a config mistake that must fail at construction with a
|
||||
/// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.</para>
|
||||
/// </summary>
|
||||
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
|
||||
=> provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
|
||||
$"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
|
||||
/// <para>Internal so the defaults and the rejections can be asserted directly, without a database or a
|
||||
/// provisioned connection string standing between the test and the rule.</para>
|
||||
/// </summary>
|
||||
/// <param name="dto">The deserialised config blob.</param>
|
||||
/// <param name="driverInstanceId">Named in every rejection message, so a fleet-wide log identifies the row.</param>
|
||||
/// <returns>The typed options the driver is constructed with.</returns>
|
||||
/// <exception cref="InvalidOperationException">A knob is out of range, or the two deadlines are inverted.</exception>
|
||||
internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
var defaultPollInterval = dto.DefaultPollInterval ?? TimeSpan.FromSeconds(5);
|
||||
var operationTimeout = dto.OperationTimeout ?? TimeSpan.FromSeconds(15);
|
||||
var commandTimeout = dto.CommandTimeout ?? TimeSpan.FromSeconds(10);
|
||||
var maxConcurrentGroups = dto.MaxConcurrentGroups ?? 4;
|
||||
|
||||
RequirePositive(defaultPollInterval, "defaultPollInterval", driverInstanceId);
|
||||
RequirePositive(operationTimeout, "operationTimeout", driverInstanceId);
|
||||
RequirePositive(commandTimeout, "commandTimeout", driverInstanceId);
|
||||
if (maxConcurrentGroups < 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' has maxConcurrentGroups {maxConcurrentGroups}; " +
|
||||
$"it must be at least 1.");
|
||||
}
|
||||
|
||||
// Design §8.3. The two deadlines are independent and BOTH are required: commandTimeout is the
|
||||
// server-side backstop, operationTimeout the client-side wall-clock bound that alone survives a
|
||||
// wedged socket (the R2-01/STAB-14 lesson). Inverted — or equal — the client-side abort always fires
|
||||
// first and the backstop can never be reached, so a deployment quietly runs on one bound instead of
|
||||
// two. Nothing downstream enforces this: the reader stays deliberately usable with it inverted.
|
||||
if (operationTimeout <= commandTimeout)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' has operationTimeout {operationTimeout} which " +
|
||||
$"is not greater than commandTimeout {commandTimeout}. The client-side operationTimeout must " +
|
||||
$"be strictly greater than the server-side commandTimeout backstop, or the backstop can " +
|
||||
$"never fire.");
|
||||
}
|
||||
|
||||
return new SqlDriverOptions
|
||||
{
|
||||
RawTags = dto.RawTags is { Count: > 0 } rawTags ? [.. rawTags] : [],
|
||||
DefaultPollInterval = defaultPollInterval,
|
||||
OperationTimeout = operationTimeout,
|
||||
CommandTimeout = commandTimeout,
|
||||
MaxConcurrentGroups = maxConcurrentGroups,
|
||||
NullIsBad = dto.NullIsBad ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rejects a non-positive duration, naming the authored field.</summary>
|
||||
private static void RequirePositive(TimeSpan value, string field, string driverInstanceId)
|
||||
{
|
||||
if (value <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' has {field} {value}; it must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the composition layer: <see cref="SqlDriverFactoryExtensions"/> (config blob → live driver) and
|
||||
/// <see cref="SqlConnectionStringResolver"/> (a <c>connectionStringRef</c> name → the secret it stands for).
|
||||
/// <para>Three things here are behaviour rather than plumbing, and each has its own test below.
|
||||
/// <b>(1) The string-enum guard</b> — the driver-page enum-serialization defect that bit the AdminUI shipped
|
||||
/// configs whose enum fields were written as ORDINALS while the consuming DTO was string-typed, faulting the
|
||||
/// driver at deploy time. The factory's options therefore both accept a numeric <c>provider</c> and write the
|
||||
/// NAME. <b>(2) Config validation</b> — <c>operationTimeout > commandTimeout</c> is deliberately
|
||||
/// unenforced in the reader and the shell, so the factory is the only place it can be caught before a
|
||||
/// deployment silently masks its own server-side backstop. <b>(3) Credential hygiene</b> — the resolved
|
||||
/// connection string is a secret; it must not reach a log line or an exception message, and the tests below
|
||||
/// assert that rather than trusting the reviewer.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriverFactoryTests
|
||||
{
|
||||
// ---- the plan's headline legs ----
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_missingConnectionStringRef_throwsActionable()
|
||||
=> Should.Throw<InvalidOperationException>(() =>
|
||||
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
|
||||
.Message.ShouldContain("connectionStringRef");
|
||||
|
||||
[Fact]
|
||||
public void ConnectionStringRef_resolvesFromEnv()
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=x;Database=y;");
|
||||
|
||||
SqlConnectionStringResolver.Resolve(name).ShouldBe("Server=x;Database=y;");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_serializesAsNameNotNumber()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
|
||||
SqlDriverFactoryExtensions.JsonOptionsForTest);
|
||||
|
||||
json.ShouldContain("\"SqlServer\"");
|
||||
json.ShouldNotContain("\"provider\":0");
|
||||
}
|
||||
|
||||
// ---- the other half of the enum guard: a NUMERIC provider must still parse ----
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_providerWrittenAsANumber_stillParses()
|
||||
{
|
||||
// The defect's real shape: an authoring surface serialises the enum as an ordinal. Rejecting that
|
||||
// would fault the driver at deploy time, which is exactly what the guard exists to prevent — so the
|
||||
// options accept both spellings on the way IN, and only ever emit the name on the way OUT.
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
||||
|
||||
var driver = SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":0,"connectionStringRef":"{{name}}"}""", null);
|
||||
|
||||
driver.DriverType.ShouldBe(SqlDriver.DriverTypeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_unknownJsonMembers_areIgnoredRatherThanFatal()
|
||||
{
|
||||
// A config blob authored against a newer (or older) schema must not brick the driver.
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
||||
|
||||
var driver = SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"connectionStringRef":"{{name}}","somethingNobodyHasShippedYet":true}""", null);
|
||||
|
||||
driver.DriverInstanceId.ShouldBe("d1");
|
||||
}
|
||||
|
||||
// ---- connection-string resolution ----
|
||||
|
||||
[Fact]
|
||||
public void Resolve_whenTheEnvironmentVariableIsAbsent_throwsNamingTheVariable()
|
||||
{
|
||||
var name = NewRef();
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => SqlConnectionStringResolver.Resolve(name));
|
||||
|
||||
// The operator's next action is "set this variable", so the message must name it exactly.
|
||||
ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_whenTheEnvironmentVariableIsBlank_isTreatedAsAbsent()
|
||||
{
|
||||
// An empty value is a half-provisioned deployment, not a connection string — failing here beats
|
||||
// handing "" to the provider and reporting an unintelligible ADO.NET error.
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), " ");
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => SqlConnectionStringResolver.Resolve(name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_whenTheRefResolvesToNothing_namesTheEnvironmentVariable_notTheRef()
|
||||
{
|
||||
var name = NewRef();
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"connectionStringRef":"{{name}}"}""", null));
|
||||
|
||||
ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name));
|
||||
}
|
||||
|
||||
// ---- provider gate ----
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo()
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null));
|
||||
|
||||
ex.Message.ShouldContain("Postgres");
|
||||
ex.Message.ShouldContain("not available in this build");
|
||||
}
|
||||
|
||||
// ---- config validation (the reader and the shell deliberately do NOT do this) ----
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_operationTimeoutNotGreaterThanCommandTimeout_isRejected()
|
||||
{
|
||||
// Inverted, the client-side wall-clock abort always fires first and the server-side CommandTimeout
|
||||
// backstop can never be reached — the deployment silently loses one of its two independent bounds.
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1",
|
||||
$$"""
|
||||
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"}
|
||||
""",
|
||||
null));
|
||||
|
||||
ex.Message.ShouldContain("operationTimeout");
|
||||
ex.Message.ShouldContain("commandTimeout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_equalTimeouts_areAlsoRejected()
|
||||
{
|
||||
// "Strictly greater" (design §8.3): equal leaves the two bounds racing, which is the same defect
|
||||
// with a coin toss on top.
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1",
|
||||
$$"""
|
||||
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:10","operationTimeout":"00:00:10"}
|
||||
""",
|
||||
null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("\"commandTimeout\":\"00:00:00\"")]
|
||||
[InlineData("\"operationTimeout\":\"-00:00:05\"")]
|
||||
[InlineData("\"defaultPollInterval\":\"00:00:00\"")]
|
||||
[InlineData("\"maxConcurrentGroups\":0")]
|
||||
public void CreateInstance_nonPositiveKnobs_areRejected(string knobJson)
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"connectionStringRef":"{{name}}",{{knobJson}}}""", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_theDocumentedDefaults_passValidation()
|
||||
{
|
||||
// The falsifiability control for the four rejection legs above: an all-defaults config must build.
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
||||
|
||||
var options = SqlDriverFactoryExtensions.BuildOptions(
|
||||
new SqlDriverConfigDto { ConnectionStringRef = name }, "d1");
|
||||
|
||||
options.CommandTimeout.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
options.OperationTimeout.ShouldBe(TimeSpan.FromSeconds(15));
|
||||
options.DefaultPollInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
options.MaxConcurrentGroups.ShouldBe(4);
|
||||
options.NullIsBad.ShouldBeFalse();
|
||||
options.OperationTimeout.ShouldBeGreaterThan(options.CommandTimeout);
|
||||
}
|
||||
|
||||
// ---- rawTags: how the deploy artifact actually delivers a driver's tags ----
|
||||
|
||||
[Fact]
|
||||
public void BuildOptions_carriesTheArtifactsRawTagsOntoTheOptions()
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<SqlDriverConfigDto>(
|
||||
"""
|
||||
{
|
||||
"connectionStringRef":"anything",
|
||||
"rawTags":[
|
||||
{"rawPath":"Plant/Sql/db1/Speed","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false},
|
||||
{"rawPath":"Plant/Sql/db1/Temp","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false}
|
||||
]
|
||||
}
|
||||
""",
|
||||
SqlDriverFactoryExtensions.JsonOptionsForTest)!;
|
||||
|
||||
var options = SqlDriverFactoryExtensions.BuildOptions(dto, "d1");
|
||||
|
||||
options.RawTags.Count.ShouldBe(2);
|
||||
options.RawTags[0].RawPath.ShouldBe("Plant/Sql/db1/Speed");
|
||||
options.RawTags[1].RawPath.ShouldBe("Plant/Sql/db1/Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildOptions_withNoRawTags_yieldsAnEmptyList_notNull()
|
||||
{
|
||||
var options = SqlDriverFactoryExtensions.BuildOptions(
|
||||
new SqlDriverConfigDto { ConnectionStringRef = "anything" }, "d1");
|
||||
|
||||
options.RawTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- v1 is read-only, structurally ----
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_anAuthoredAllowWrites_warnsLoudly_andTheDriverStaysReadOnly()
|
||||
{
|
||||
// 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.
|
||||
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);
|
||||
|
||||
driver.ShouldNotBeAssignableTo<IWritable>();
|
||||
var warning = loggerFactory.Entries
|
||||
.Where(e => e.Level >= LogLevel.Warning)
|
||||
.ShouldHaveSingleItem();
|
||||
warning.Message.ShouldContain("allowWrites");
|
||||
warning.Message.ShouldContain("read-only");
|
||||
}
|
||||
|
||||
[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");
|
||||
var loggerFactory = new RecordingLoggerFactory();
|
||||
|
||||
SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":false}""", loggerFactory);
|
||||
|
||||
loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty();
|
||||
// ...and the factory DID log — so "no warning" is a statement about severity, not about silence.
|
||||
loggerFactory.Entries.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
// ---- credential hygiene ----
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_neverPutsTheResolvedConnectionStringIntoALogOrTheEndpoint()
|
||||
{
|
||||
const string secret = "SuperSecret-PleaseDoNotLogMe";
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name),
|
||||
$"Server=srv;Database=db;User ID=sa;Password={secret}");
|
||||
var loggerFactory = new RecordingLoggerFactory();
|
||||
|
||||
var driver = (SqlDriver)SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory);
|
||||
|
||||
// The credential-free rendering IS emitted — the factory logs where the driver points, which is what
|
||||
// makes "no connection string" a real constraint rather than "log nothing and call it safe".
|
||||
driver.Endpoint.ShouldBe("srv/db");
|
||||
foreach (var entry in loggerFactory.Entries) entry.Message.ShouldNotContain(secret);
|
||||
loggerFactory.Entries.ShouldNotBeEmpty(); // ...and there WAS something to leak into.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_whenValidationFails_theExceptionCarriesNoConnectionString()
|
||||
{
|
||||
const string secret = "SuperSecret-PleaseDoNotThrowMe";
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name),
|
||||
$"Server=srv;Database=db;Password={secret}");
|
||||
|
||||
// Validation deliberately runs AFTER resolution here, so the throwing path is one that HAS the
|
||||
// secret in hand — the case where a careless $"...{connectionString}" would actually leak.
|
||||
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1",
|
||||
$$"""
|
||||
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"}
|
||||
""",
|
||||
null));
|
||||
|
||||
ex.ToString().ShouldNotContain(secret);
|
||||
}
|
||||
|
||||
// ---- registry wiring ----
|
||||
|
||||
[Fact]
|
||||
public void Register_registersTheFactoryUnderTheDriversTypeName()
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
||||
var registry = new DriverFactoryRegistry();
|
||||
|
||||
SqlDriverFactoryExtensions.Register(registry);
|
||||
|
||||
var factory = registry.TryGet(SqlDriverFactoryExtensions.DriverTypeName).ShouldNotBeNull();
|
||||
var driver = factory("d1", $$"""{"connectionStringRef":"{{name}}"}""");
|
||||
driver.ShouldBeOfType<SqlDriver>();
|
||||
driver.DriverType.ShouldBe("Sql");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverTypeName_matchesTheDriversOwnConstant()
|
||||
// Task 11 unifies both onto DriverTypeNames.Sql; until then they must not drift apart.
|
||||
=> SqlDriverFactoryExtensions.DriverTypeName.ShouldBe(SqlDriver.DriverTypeName);
|
||||
|
||||
// ---- argument guards ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void CreateInstance_blankInputs_areRejected(string blank)
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
SqlDriverFactoryExtensions.CreateInstance(blank, """{"connectionStringRef":"x"}""", null));
|
||||
Should.Throw<ArgumentException>(() => SqlDriverFactoryExtensions.CreateInstance("d1", blank, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_configThatIsNotJson_throwsNamingTheDriverInstance()
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(
|
||||
() => SqlDriverFactoryExtensions.CreateInstance("d1", "not json at all", null));
|
||||
|
||||
ex.Message.ShouldContain("d1");
|
||||
}
|
||||
|
||||
/// <summary>A per-test connection-string ref, so no two tests can collide over one process-wide env var.</summary>
|
||||
private static string NewRef() => $"OtOpcUaSqlTest{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets one environment variable for the life of the scope and restores its previous value — including
|
||||
/// restoring it to <see langword="null"/>. Environment variables are process-global, so a test that sets
|
||||
/// one without restoring it leaks into every test that runs after it in the same process.
|
||||
/// </summary>
|
||||
internal sealed class EnvironmentVariableScope : IDisposable
|
||||
{
|
||||
private readonly string _name;
|
||||
private readonly string? _previous;
|
||||
|
||||
/// <summary>Sets <paramref name="name"/> to <paramref name="value"/>, remembering what was there.</summary>
|
||||
/// <param name="name">The environment variable to set.</param>
|
||||
/// <param name="value">The value to set it to.</param>
|
||||
public EnvironmentVariableScope(string name, string? value)
|
||||
{
|
||||
_name = name;
|
||||
_previous = Environment.GetEnvironmentVariable(name);
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
/// <summary>Restores the previous value.</summary>
|
||||
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures what the factory logged, so "warned" and "did not leak the connection string" are asserted
|
||||
/// rather than assumed.
|
||||
/// </summary>
|
||||
internal sealed class RecordingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
private readonly List<LogEntry> _entries = [];
|
||||
|
||||
/// <summary>The log entries recorded so far, in order.</summary>
|
||||
public IReadOnlyList<LogEntry> Entries
|
||||
{
|
||||
get { lock (_entries) return [.. _entries]; }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ILogger CreateLogger(string categoryName) => new Recorder(this);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() { }
|
||||
|
||||
private void Record(LogLevel level, string message)
|
||||
{
|
||||
lock (_entries) _entries.Add(new LogEntry(level, message));
|
||||
}
|
||||
|
||||
/// <summary>One captured log entry.</summary>
|
||||
/// <param name="Level">The level it was logged at.</param>
|
||||
/// <param name="Message">The formatted message.</param>
|
||||
internal sealed record LogEntry(LogLevel Level, string Message);
|
||||
|
||||
private sealed class Recorder(RecordingLoggerFactory owner) : ILogger
|
||||
{
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
=> owner.Record(logLevel, formatter(state, exception));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user