fix(inbound): authorize+secure Database helper, async/deadline-bound DB, wait-timeout-bound WaitForAttribute
Resolves InboundAPI-026/027/028/029 (+ newly-surfaced -030). - 026: authorize the scoped Database helper in the design doc; SQL-injection protection is parameter binding (values never concatenated); allow writes via ExecuteAsync; drop the false 'read-only' claim. Named connections only. - 027: async ADO.NET end-to-end (no .GetAwaiter().GetResult()); honour the method deadline token on ExecuteScalarAsync/ExecuteReaderAsync/ExecuteNonQueryAsync + a CommandTimeout backstop derived from the method timeout. - 028: negative-path tests (null-gateway, deadline cancellation, parameterization) + e2e Database + WaitForAttribute cases through the real endpoint. - 029: WaitForAttribute is bounded by its WAIT timeout (per-wait CTS + client-abort + explicit token), NOT the method deadline (spec §6) — a long wait may outlive the method timeout; WithRequestAborted threads the raw client-abort token separately. - 030: Central UI compile-surface mirrors (InboundScriptHost / SandboxInboundScriptHost) gained the Database member (drifted since the runtime helper was added) so the authorized async API type-checks at the design-time gate.
This commit is contained in:
@@ -189,7 +189,7 @@ Inbound API scripts **cannot** call shared scripts directly — shared scripts a
|
||||
- `Route.To("instanceUniqueCode").GetAttributes("attr1", "attr2", ...)` — Read multiple attribute values in a **single call**, returned as a dictionary of name-value pairs.
|
||||
- `Route.To("instanceUniqueCode").SetAttribute("attributeName", value)` — Write a single attribute value on a specific instance at any site.
|
||||
- `Route.To("instanceUniqueCode").SetAttributes(dictionary)` — Write multiple attribute values in a **single call**, accepting a dictionary of name-value pairs.
|
||||
- `Route.To("instanceUniqueCode").WaitForAttribute("attributeName", targetValue, timeout)` — Wait, event-driven, until an attribute on a specific instance at any site reaches `targetValue` (value-equality only across the wire), bounded by `timeout`. Returns `true` if matched within the timeout, `false` if it timed out. The cluster call is bounded by the wait timeout rather than the generic integration timeout.
|
||||
- `Route.To("instanceUniqueCode").WaitForAttribute("attributeName", targetValue, timeout)` — Wait, event-driven, until an attribute on a specific instance at any site reaches `targetValue` (value-equality only across the wire), bounded by `timeout`. Returns `true` if matched within the timeout, `false` if it timed out. **The wait is bounded by its own `timeout`, not the generic method-level timeout** — this is the one routed call that may legitimately outlive the method timeout (the site enforces `timeout` and returns `false` when it elapses). A client disconnect still cancels the wait. This is the deliberate exception to the rule below that routed calls inherit the method-level timeout (see "Routing Behavior"): a long event-driven wait is the explicit reason `timeout` governs here.
|
||||
|
||||
#### Input/Output
|
||||
- **Input parameters** are available as defined in the method definition.
|
||||
@@ -199,20 +199,38 @@ Inbound API scripts **cannot** call shared scripts directly — shared scripts a
|
||||
- `Parameters["key"]` — Raw dictionary access.
|
||||
- `Parameters.Get<T>("key")` — Typed access (same API as site runtime scripts). See Site Runtime component for full type support.
|
||||
|
||||
> **No direct database access.** Inbound API scripts are not given a raw database
|
||||
> client. Handing a script a raw `SqlConnection` is in direct tension with the
|
||||
> ScadaBridge script trust model (scripts are forbidden `System.IO`, `Process`,
|
||||
> `Threading`, `Reflection`, and raw network access). The `ForbiddenApiChecker`
|
||||
> statically enforces this by delegating to the shared `ScriptAnalysis`
|
||||
> `ScriptTrustValidator` (component #25), which is the single authoritative
|
||||
> source of truth for the forbidden-API policy. The unified policy permits
|
||||
> `System.Diagnostics.Stopwatch`/`Debug` while retaining the `Process`-only ban,
|
||||
> and adds reflection-gateway member and `dynamic`/`Activator` hardening.
|
||||
> This is defence-in-depth static enforcement, not a true runtime sandbox. Scripts
|
||||
> interact with the system only through the curated `Route` and `Parameters`
|
||||
> surfaces above. If a method needs data from the configuration or machine-data
|
||||
> databases, that access belongs behind a dedicated, scoped helper — not a
|
||||
> general-purpose connection — and would be added here as an explicit design change.
|
||||
#### Database Access
|
||||
|
||||
Inbound API scripts may read from and write to the configuration / machine-data
|
||||
databases through the **curated, scoped `Database` helper** — `InboundDatabaseHelper`,
|
||||
exposed as `InboundScriptContext.Database`. This is the "dedicated, scoped helper added
|
||||
as an explicit design change" that the script trust model requires: scripts are **never**
|
||||
handed a raw `SqlConnection`, and never reference `System.Data` (the `ForbiddenApiChecker`,
|
||||
delegating to the shared `ScriptAnalysis` `ScriptTrustValidator` (#25), still statically
|
||||
bans `System.IO`, `Process`, `Threading`, `Reflection`, and raw network access — that is
|
||||
defence-in-depth static enforcement, not a true runtime sandbox).
|
||||
|
||||
The helper API (all asynchronous — scripts `await` them; bounded by the method timeout):
|
||||
|
||||
- `await Database.QuerySingleAsync<T>("connectionName", sql, parameters)` — first column of the first row as `T` (default if no rows).
|
||||
- `await Database.QueryAsync("connectionName", sql, parameters)` — all rows as case-insensitive column→value dictionaries.
|
||||
- `await Database.ExecuteAsync("connectionName", sql, parameters)` — run a write (INSERT/UPDATE/DELETE/DDL); returns rows affected. **Writes are permitted** — the move-in integration records results, not just reads them.
|
||||
|
||||
Containment rules (enforced, not advisory):
|
||||
|
||||
- **Named connections only.** `connectionName` selects one of the connections configured
|
||||
on the central database gateway (`IDatabaseGateway`); a script cannot supply an arbitrary
|
||||
connection string or reach a database the gateway is not configured for.
|
||||
- **SQL-injection protection.** Statement text is authored by the (design-time) method
|
||||
script, but every request-derived **value** is passed via `parameters` and bound as a
|
||||
named `@`-prefixed SQL parameter — never string-concatenated into the command text.
|
||||
Request input therefore reaches the database only through parameter binding.
|
||||
- **Deadline-bound.** Calls use the async ADO.NET path end-to-end (no pool-thread blocking)
|
||||
and honour the method deadline token, with a `CommandTimeout` backstop derived from the
|
||||
method timeout, so a slow query is bounded by the method timeout.
|
||||
|
||||
For everything else, scripts interact with the system through the curated `Route` and
|
||||
`Parameters` surfaces above.
|
||||
|
||||
### Routing Behavior
|
||||
- The `Route.To()` helper resolves the instance's site assignment from the configuration database and routes the request to the correct site cluster via the Communication Layer.
|
||||
|
||||
@@ -20,11 +20,52 @@ public class InboundScriptHost
|
||||
/// </summary>
|
||||
public RouteHelper Route { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Scoped, parameterized database access. Editor mirror of
|
||||
/// ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper (InboundAPI-026/030) — its
|
||||
/// signatures must match the runtime helper so a script using <c>Database.*</c>
|
||||
/// type-checks during analysis.
|
||||
/// </summary>
|
||||
public DatabaseAccessor Database { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The cancellation token for the operation.
|
||||
/// </summary>
|
||||
public System.Threading.CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>Editor mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper.</summary>
|
||||
public class DatabaseAccessor
|
||||
{
|
||||
/// <summary>First column of the first row as <typeparamref name="T"/> (default if no rows).</summary>
|
||||
/// <typeparam name="T">The scalar result type.</typeparam>
|
||||
/// <param name="connectionName">A configured database connection name.</param>
|
||||
/// <param name="sql">The SQL statement; values supplied via <paramref name="parameters"/>.</param>
|
||||
/// <param name="parameters">Optional anonymous object of bound parameters.</param>
|
||||
/// <returns>A task that resolves to the scalar, or default.</returns>
|
||||
public System.Threading.Tasks.Task<T?> QuerySingleAsync<T>(
|
||||
string connectionName, string sql, object? parameters = null) =>
|
||||
System.Threading.Tasks.Task.FromResult<T?>(default);
|
||||
|
||||
/// <summary>All rows as case-insensitive column→value dictionaries.</summary>
|
||||
/// <param name="connectionName">A configured database connection name.</param>
|
||||
/// <param name="sql">The SQL statement; values supplied via <paramref name="parameters"/>.</param>
|
||||
/// <param name="parameters">Optional anonymous object of bound parameters.</param>
|
||||
/// <returns>A task that resolves to the result rows.</returns>
|
||||
public System.Threading.Tasks.Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string connectionName, string sql, object? parameters = null) =>
|
||||
System.Threading.Tasks.Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>(
|
||||
new List<IReadOnlyDictionary<string, object?>>());
|
||||
|
||||
/// <summary>Runs a write statement; returns rows affected.</summary>
|
||||
/// <param name="connectionName">A configured database connection name.</param>
|
||||
/// <param name="sql">The SQL statement; values supplied via <paramref name="parameters"/>.</param>
|
||||
/// <param name="parameters">Optional anonymous object of bound parameters.</param>
|
||||
/// <returns>A task that resolves to the number of rows affected.</returns>
|
||||
public System.Threading.Tasks.Task<int> ExecuteAsync(
|
||||
string connectionName, string sql, object? parameters = null) =>
|
||||
System.Threading.Tasks.Task.FromResult(0);
|
||||
}
|
||||
|
||||
/// <summary>Editor mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteHelper.</summary>
|
||||
public class RouteHelper
|
||||
{
|
||||
|
||||
@@ -23,6 +23,48 @@ public class SandboxInboundScriptHost
|
||||
/// <summary>Gets the route accessor; every call throws <see cref="ScriptSandboxException"/> in a test run.</summary>
|
||||
public RouteAccessor Route { get; } = new();
|
||||
|
||||
/// <summary>Gets the database accessor; every call throws <see cref="ScriptSandboxException"/> in a test run.</summary>
|
||||
public DatabaseAccessor Database { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper — signatures match
|
||||
/// the runtime helper so the same user code compiles, but every call throws because a
|
||||
/// central Test Run has no configured database connection to reach (mirrors how
|
||||
/// <see cref="RouteAccessor"/> throws on cross-site routing).
|
||||
/// </summary>
|
||||
public class DatabaseAccessor
|
||||
{
|
||||
/// <summary>Always throws <see cref="ScriptSandboxException"/>; database access is unavailable in a Test Run.</summary>
|
||||
/// <typeparam name="T">The scalar result type.</typeparam>
|
||||
/// <param name="connectionName">Connection name (included in the exception message).</param>
|
||||
/// <param name="sql">Unused SQL.</param>
|
||||
/// <param name="parameters">Unused parameters.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
|
||||
public Task<T?> QuerySingleAsync<T>(string connectionName, string sql, object? parameters = null) =>
|
||||
throw Unavailable($"Database.QuerySingleAsync(\"{connectionName}\")");
|
||||
|
||||
/// <summary>Always throws <see cref="ScriptSandboxException"/>; database access is unavailable in a Test Run.</summary>
|
||||
/// <param name="connectionName">Connection name (included in the exception message).</param>
|
||||
/// <param name="sql">Unused SQL.</param>
|
||||
/// <param name="parameters">Unused parameters.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string connectionName, string sql, object? parameters = null) =>
|
||||
throw Unavailable($"Database.QueryAsync(\"{connectionName}\")");
|
||||
|
||||
/// <summary>Always throws <see cref="ScriptSandboxException"/>; database access is unavailable in a Test Run.</summary>
|
||||
/// <param name="connectionName">Connection name (included in the exception message).</param>
|
||||
/// <param name="sql">Unused SQL.</param>
|
||||
/// <param name="parameters">Unused parameters.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
|
||||
public Task<int> ExecuteAsync(string connectionName, string sql, object? parameters = null) =>
|
||||
throw Unavailable($"Database.ExecuteAsync(\"{connectionName}\")");
|
||||
|
||||
private static ScriptSandboxException Unavailable(string operation) =>
|
||||
new($"{operation} is not available in Test Run — database access needs the " +
|
||||
"central configuration / machine-data databases.");
|
||||
}
|
||||
|
||||
/// <summary>Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteHelper.</summary>
|
||||
public class RouteAccessor
|
||||
{
|
||||
|
||||
@@ -5,45 +5,90 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only database access exposed to inbound API scripts. All ADO.NET stays
|
||||
/// internal here — scripts call QuerySingle/Query by name and never reference
|
||||
/// System.Data. Named connections only; parameters are bound (anonymous-object
|
||||
/// properties become @-prefixed SQL parameters), never string-concatenated.
|
||||
/// Scoped, parameterized database access exposed to inbound API scripts as
|
||||
/// <c>InboundScriptContext.Database</c>. This is the dedicated, curated data-access
|
||||
/// helper authorized by the design doc (Component-InboundAPI.md, "Database access") —
|
||||
/// not a raw connection handed to the script. All ADO.NET stays internal here: scripts
|
||||
/// call <see cref="QueryAsync"/> / <see cref="QuerySingleAsync{T}"/> / <see cref="ExecuteAsync"/>
|
||||
/// by name and never reference <c>System.Data</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>SQL-injection protection (InboundAPI-026).</b> Statement text is authored by the
|
||||
/// (design-time) method script, but every <em>value</em> is bound as a named SQL
|
||||
/// parameter (anonymous-object properties become <c>@</c>-prefixed parameters via
|
||||
/// <see cref="AddParameters"/>) and is NEVER string-concatenated into the command text.
|
||||
/// Request-derived values therefore reach the database only through parameter binding,
|
||||
/// closing the injection vector. Connection access is restricted to the named
|
||||
/// connections configured on the central <see cref="IDatabaseGateway"/> — a script
|
||||
/// cannot supply an arbitrary connection string.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Reads and writes are both permitted</b> (InboundAPI-026 design decision): the move-in
|
||||
/// integration needs to record results, not just read them. Use <see cref="QueryAsync"/>
|
||||
/// / <see cref="QuerySingleAsync{T}"/> for reads and <see cref="ExecuteAsync"/> for writes.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Async + deadline-bound (InboundAPI-027).</b> Every call uses the async ADO.NET path
|
||||
/// end-to-end (no <c>.GetAwaiter().GetResult()</c> blocking a pool thread) and honours the
|
||||
/// executing method's deadline token on the command itself, with a <see cref="DbCommand.CommandTimeout"/>
|
||||
/// backstop derived from the method timeout — so a slow query is bounded by the method
|
||||
/// timeout instead of running unbounded.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InboundDatabaseHelper
|
||||
{
|
||||
private readonly IDatabaseGateway? _gateway;
|
||||
private readonly CancellationToken _ct;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct)
|
||||
{ _gateway = gateway; _ct = ct; }
|
||||
/// <summary>
|
||||
/// Initializes the helper.
|
||||
/// </summary>
|
||||
/// <param name="gateway">The central database gateway, or null when no gateway is registered (the helper then throws on first use).</param>
|
||||
/// <param name="ct">The executing method's deadline token; forwarded to every async DB call so a slow query is bounded by the method timeout.</param>
|
||||
/// <param name="commandTimeout">The method timeout; used to derive a <see cref="DbCommand.CommandTimeout"/> backstop. <see cref="TimeSpan.Zero"/> (the default) leaves the provider default.</param>
|
||||
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct, TimeSpan commandTimeout = default)
|
||||
{
|
||||
_gateway = gateway;
|
||||
_ct = ct;
|
||||
_commandTimeoutSeconds = commandTimeout > TimeSpan.Zero
|
||||
? (int)Math.Ceiling(commandTimeout.TotalSeconds)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/// <summary>First column of the first row converted to T (default if no rows).</summary>
|
||||
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
|
||||
/// <summary>First column of the first row converted to <typeparamref name="T"/> (default if no rows).</summary>
|
||||
/// <typeparam name="T">The type to convert the scalar result to.</typeparam>
|
||||
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
|
||||
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
|
||||
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
|
||||
/// <returns>The converted scalar, or <c>default</c> when there are no rows / a NULL value.</returns>
|
||||
public async Task<T?> QuerySingleAsync<T>(string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
AddParameters(cmd, parameters);
|
||||
var result = cmd.ExecuteScalar();
|
||||
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
|
||||
await using var cmd = CreateCommand(conn, sql, parameters);
|
||||
var result = await cmd.ExecuteScalarAsync(_ct);
|
||||
if (result is null or DBNull) return default;
|
||||
if (result is T t) return t;
|
||||
return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>All rows as column→value dictionaries (case-insensitive keys).</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
|
||||
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
|
||||
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
|
||||
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
|
||||
/// <returns>The result rows; empty when nothing matches.</returns>
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
AddParameters(cmd, parameters);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
|
||||
await using var cmd = CreateCommand(conn, sql, parameters);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(_ct);
|
||||
var rows = new List<IReadOnlyDictionary<string, object?>>();
|
||||
while (reader.Read())
|
||||
while (await reader.ReadAsync(_ct))
|
||||
{
|
||||
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
@@ -56,6 +101,35 @@ public sealed class InboundDatabaseHelper
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a write statement (INSERT/UPDATE/DELETE/DDL) and returns the number of
|
||||
/// rows affected. Writes are authorized for inbound API scripts (InboundAPI-026);
|
||||
/// values are still bound as parameters, never concatenated.
|
||||
/// </summary>
|
||||
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
|
||||
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
|
||||
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
|
||||
/// <returns>The number of rows affected.</returns>
|
||||
public async Task<int> ExecuteAsync(string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
|
||||
await using var cmd = CreateCommand(conn, sql, parameters);
|
||||
return await cmd.ExecuteNonQueryAsync(_ct);
|
||||
}
|
||||
|
||||
private DbCommand CreateCommand(DbConnection conn, string sql, object? parameters)
|
||||
{
|
||||
var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
// InboundAPI-027: a CommandTimeout backstop derived from the method timeout so a
|
||||
// slow query cannot outrun the method deadline even if the provider does not
|
||||
// honour token cancellation mid-statement.
|
||||
if (_commandTimeoutSeconds > 0) cmd.CommandTimeout = _commandTimeoutSeconds;
|
||||
AddParameters(cmd, parameters);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static void AddParameters(DbCommand cmd, object? parameters)
|
||||
{
|
||||
if (parameters is null) return;
|
||||
|
||||
@@ -237,12 +237,13 @@ public class InboundScriptExecutor
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken, timeoutCts.Token);
|
||||
|
||||
// IpsenMES MoveIn: expose a read-only Database helper to the script.
|
||||
// Resolve the gateway from a fresh DI scope (declared outside the try so it
|
||||
// lives until after the script handler runs and is disposed here) so a scoped
|
||||
// IDatabaseGateway is honoured. GetService (not GetRequiredService) so a method
|
||||
// that never touches Database still runs even when no IDatabaseGateway is
|
||||
// registered; the helper throws on first use if built without a gateway.
|
||||
// IpsenMES MoveIn: expose the scoped, parameterized Database helper to the script
|
||||
// (reads + writes — InboundAPI-026). Resolve the gateway from a fresh DI scope
|
||||
// (declared outside the try so it lives until after the script handler runs and is
|
||||
// disposed here) so a scoped IDatabaseGateway is honoured. GetService (not
|
||||
// GetRequiredService) so a method that never touches Database still runs even when
|
||||
// no IDatabaseGateway is registered; the helper throws on first use if built
|
||||
// without a gateway.
|
||||
//
|
||||
// A service provider that cannot produce a scope (e.g. a bare test double with
|
||||
// no IServiceScopeFactory) is tolerated: the gateway is simply unavailable, so
|
||||
@@ -265,18 +266,28 @@ public class InboundScriptExecutor
|
||||
try
|
||||
{
|
||||
var gateway = scope?.ServiceProvider.GetService<IDatabaseGateway>();
|
||||
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token);
|
||||
// InboundAPI-027: pass the method timeout so the helper derives a
|
||||
// CommandTimeout backstop and forwards cts.Token to every async DB call —
|
||||
// a slow query is then bounded by the method deadline.
|
||||
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token, timeout);
|
||||
|
||||
// InboundAPI-016: bind the route helper to the method deadline so a
|
||||
// routed Route.To(...).Call(...) inherits the method-level timeout
|
||||
// without the script having to thread the context token by hand.
|
||||
//
|
||||
// InboundAPI-029: also pass the raw request-abort token separately so
|
||||
// Route.To(...).WaitForAttribute(...) can be bounded by its WAIT timeout
|
||||
// (not the generic method deadline) while still being cancelled by a
|
||||
// client disconnect — see RouteTarget.WaitForAttribute.
|
||||
//
|
||||
// Audit Log #23 (ParentExecutionId): also bind the inbound request's
|
||||
// ExecutionId so a routed call carries it as ParentExecutionId — the
|
||||
// spawned site script execution points back at this inbound request.
|
||||
var context = new InboundScriptContext(
|
||||
parameters,
|
||||
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
|
||||
route.WithDeadline(cts.Token)
|
||||
.WithRequestAborted(cancellationToken)
|
||||
.WithParentExecutionId(parentExecutionId),
|
||||
dbHelper,
|
||||
cts.Token);
|
||||
|
||||
@@ -382,7 +393,8 @@ public class InboundScriptContext
|
||||
public RouteHelper Route { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Read-only database access for the script (named connections; bound parameters).
|
||||
/// Scoped, parameterized database access for the script (named connections; bound
|
||||
/// parameters; reads and writes — see <see cref="InboundDatabaseHelper"/>).
|
||||
/// </summary>
|
||||
public InboundDatabaseHelper Database { get; }
|
||||
|
||||
@@ -396,7 +408,7 @@ public class InboundScriptContext
|
||||
/// </summary>
|
||||
/// <param name="parameters">The input parameters for the script.</param>
|
||||
/// <param name="route">The route helper for cross-site routing.</param>
|
||||
/// <param name="database">The read-only database helper for the script.</param>
|
||||
/// <param name="database">The scoped, parameterized database helper for the script.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public InboundScriptContext(
|
||||
IReadOnlyDictionary<string, object?> parameters,
|
||||
|
||||
@@ -19,6 +19,7 @@ public class RouteHelper
|
||||
private readonly IInstanceLocator _instanceLocator;
|
||||
private readonly IInstanceRouter _instanceRouter;
|
||||
private readonly CancellationToken _deadlineToken;
|
||||
private readonly CancellationToken _requestAbortedToken;
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
/// <summary>
|
||||
@@ -29,7 +30,7 @@ public class RouteHelper
|
||||
public RouteHelper(
|
||||
IInstanceLocator instanceLocator,
|
||||
IInstanceRouter instanceRouter)
|
||||
: this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
|
||||
: this(instanceLocator, instanceRouter, CancellationToken.None, CancellationToken.None, parentExecutionId: null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -37,11 +38,13 @@ public class RouteHelper
|
||||
IInstanceLocator instanceLocator,
|
||||
IInstanceRouter instanceRouter,
|
||||
CancellationToken deadlineToken,
|
||||
CancellationToken requestAbortedToken,
|
||||
Guid? parentExecutionId)
|
||||
{
|
||||
_instanceLocator = instanceLocator;
|
||||
_instanceRouter = instanceRouter;
|
||||
_deadlineToken = deadlineToken;
|
||||
_requestAbortedToken = requestAbortedToken;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
@@ -55,7 +58,20 @@ public class RouteHelper
|
||||
/// <param name="deadlineToken">The executing method's timeout cancellation token to inherit for routed calls.</param>
|
||||
/// <returns>A new <see cref="RouteHelper"/> inheriting the given deadline token.</returns>
|
||||
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
|
||||
new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId);
|
||||
new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId);
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-029: returns a <see cref="RouteHelper"/> carrying the raw request-abort
|
||||
/// token (a client disconnect) <em>separately</em> from the method deadline. Most
|
||||
/// routed calls remain bounded by the method deadline (which already incorporates the
|
||||
/// abort), but <see cref="RouteTarget.WaitForAttribute"/> uses this token so its wait
|
||||
/// is bounded by the WAIT timeout — not the generic method deadline (spec §6) — while
|
||||
/// still being cancelled by a client disconnect.
|
||||
/// </summary>
|
||||
/// <param name="requestAbortedToken">The raw client-disconnect token, independent of the method timeout.</param>
|
||||
/// <returns>A new <see cref="RouteHelper"/> carrying the given request-abort token.</returns>
|
||||
public RouteHelper WithRequestAborted(CancellationToken requestAbortedToken) =>
|
||||
new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
|
||||
@@ -69,7 +85,7 @@ public class RouteHelper
|
||||
/// <param name="parentExecutionId">The inbound request's execution id to stamp as the spawning parent on routed calls, or null for non-routed runs.</param>
|
||||
/// <returns>A new <see cref="RouteHelper"/> carrying the given parent execution id.</returns>
|
||||
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
|
||||
new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId);
|
||||
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, parentExecutionId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a route target for the specified instance.
|
||||
@@ -79,7 +95,7 @@ public class RouteHelper
|
||||
public RouteTarget To(string instanceCode)
|
||||
{
|
||||
return new RouteTarget(
|
||||
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId);
|
||||
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +104,18 @@ public class RouteHelper
|
||||
/// </summary>
|
||||
public class RouteTarget
|
||||
{
|
||||
// InboundAPI-029: a small grace past the wait timeout. The SITE enforces the wait
|
||||
// timeout and returns Matched=false when it elapses; the local backstop fires only
|
||||
// if the site fails to respond, so it must sit slightly LATER than the wait timeout
|
||||
// (it must not pre-empt the site's own timed-out response and turn a clean `false`
|
||||
// into a cancellation).
|
||||
private static readonly TimeSpan WaitResponseGrace = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _instanceCode;
|
||||
private readonly IInstanceLocator _instanceLocator;
|
||||
private readonly IInstanceRouter _instanceRouter;
|
||||
private readonly CancellationToken _deadlineToken;
|
||||
private readonly CancellationToken _requestAbortedToken;
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
/// <summary>
|
||||
@@ -101,18 +125,21 @@ public class RouteTarget
|
||||
/// <param name="instanceLocator">Service to resolve the site id for the instance.</param>
|
||||
/// <param name="instanceRouter">Service to route cross-site calls.</param>
|
||||
/// <param name="deadlineToken">Cancellation token representing the method-level deadline.</param>
|
||||
/// <param name="requestAbortedToken">Raw client-disconnect token, independent of the method timeout (InboundAPI-029).</param>
|
||||
/// <param name="parentExecutionId">Optional parent execution id for audit correlation on routed calls.</param>
|
||||
internal RouteTarget(
|
||||
string instanceCode,
|
||||
IInstanceLocator instanceLocator,
|
||||
IInstanceRouter instanceRouter,
|
||||
CancellationToken deadlineToken,
|
||||
CancellationToken requestAbortedToken,
|
||||
Guid? parentExecutionId)
|
||||
{
|
||||
_instanceCode = instanceCode;
|
||||
_instanceLocator = instanceLocator;
|
||||
_instanceRouter = instanceRouter;
|
||||
_deadlineToken = deadlineToken;
|
||||
_requestAbortedToken = requestAbortedToken;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
@@ -211,11 +238,22 @@ public class RouteTarget
|
||||
/// wire: the target is canonically encoded via <see cref="AttributeValueCodec"/> and
|
||||
/// the site evaluates equality — there is no predicate and no quality flag in the
|
||||
/// comparison.
|
||||
///
|
||||
/// <para>
|
||||
/// InboundAPI-029: unlike the other routed calls (which inherit the method deadline
|
||||
/// per InboundAPI-016), the wait is bounded by <paramref name="timeout"/> — the WAIT
|
||||
/// timeout — NOT the generic method deadline. The SITE enforces <paramref name="timeout"/>
|
||||
/// and returns <c>Matched=false</c> when it elapses; the local token is built from a
|
||||
/// per-wait CTS (the wait timeout plus a small grace), an explicit caller token, and
|
||||
/// the client-disconnect token, but deliberately EXCLUDES the method deadline. So a
|
||||
/// wait longer than the method timeout runs to its full wait timeout, as spec §6
|
||||
/// requires, while a client disconnect still cancels it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="attributeName">Name of the attribute to wait on.</param>
|
||||
/// <param name="targetValue">Target value the attribute must equal for the wait to match.</param>
|
||||
/// <param name="timeout">Maximum time to wait for the attribute to reach the target value.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token; defaults to the method deadline.</param>
|
||||
/// <param name="timeout">Maximum time to wait for the attribute to reach the target value; this — not the method deadline — bounds the wait.</param>
|
||||
/// <param name="cancellationToken">Optional explicit cancellation token (a tighter caller-supplied bound); the wait is otherwise bounded by <paramref name="timeout"/>.</param>
|
||||
/// <returns>A task that resolves to <c>true</c> if the attribute reached the target value, <c>false</c> if the wait timed out.</returns>
|
||||
public async Task<bool> WaitForAttribute(
|
||||
string attributeName,
|
||||
@@ -223,7 +261,12 @@ public class RouteTarget
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = Effective(cancellationToken);
|
||||
// InboundAPI-029: bound the wait by the WAIT timeout (+ grace backstop), the
|
||||
// client-disconnect token, and an explicit caller token — NOT the method deadline.
|
||||
using var waitCts = new CancellationTokenSource(timeout + WaitResponseGrace);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
waitCts.Token, _requestAbortedToken, cancellationToken);
|
||||
var token = linked.Token;
|
||||
var siteId = await ResolveSiteAsync(token);
|
||||
|
||||
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the
|
||||
|
||||
+17
@@ -660,4 +660,21 @@ public class ScriptAnalysisServiceTests
|
||||
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
|
||||
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InboundScript_Database_DiagnosesClean()
|
||||
{
|
||||
// InboundAPI-026/030: the scoped Database helper is a shipped inbound-script
|
||||
// capability. The editor must not flag its async read/write API as an error —
|
||||
// the InboundScriptHost mirror must expose QuerySingleAsync/QueryAsync/ExecuteAsync
|
||||
// with signatures matching the runtime helper so Roslyn resolves them.
|
||||
var resp = _svc.Diagnose(new DiagnoseRequest(
|
||||
Code: "var code = await Database.QuerySingleAsync<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] });\n"
|
||||
+ "var rows = await Database.QueryAsync(\"BTDB\", \"SELECT Code FROM Machine\");\n"
|
||||
+ "var n = await Database.ExecuteAsync(\"BTDB\", \"UPDATE Machine SET Code=@c WHERE SAPID=@s\", new { c = code, s = \"131453\" });",
|
||||
Kind: ScriptKind.InboundApi));
|
||||
|
||||
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
|
||||
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,12 @@ using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -548,6 +552,102 @@ public sealed class EndpointExtensionsTests : IDisposable
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
|
||||
// --- InboundAPI-028: drive the new script-facing capabilities (Database +
|
||||
// WaitForAttribute) end-to-end through the real POST /api/{methodName} flow,
|
||||
// so a wiring regression in executor→context→helper / RouteHelper is caught. ---
|
||||
|
||||
[Fact]
|
||||
public async Task Script_UsingDatabase_RunsEndToEndThroughEndpoint()
|
||||
{
|
||||
// A method whose script reads through Database.QueryAsync — proving the executor
|
||||
// builds the Database helper, resolves IDatabaseGateway from the per-execution
|
||||
// DI scope, and the parameterized read reaches the body. (InboundAPI-028)
|
||||
var method = SeedMethod(1, "movein",
|
||||
"return await Database.QuerySingleAsync<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] });",
|
||||
"""[{"name":"sap","type":"String","required":true}]""");
|
||||
|
||||
using var host = await BuildHostAsync(method, additionalServices: services =>
|
||||
{
|
||||
services.AddSingleton<IDatabaseGateway>(SeededSqliteGateway());
|
||||
});
|
||||
var token = await SeedKeyAsync(host, keyId: "key1", displayName: "movein-caller",
|
||||
scopes: new[] { "movein" });
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var response = await client.SendAsync(BuildPost("movein", """{"sap":"131453"}""", token));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Z28061A", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_UsingWaitForAttribute_RunsEndToEndThroughEndpoint()
|
||||
{
|
||||
// A method whose script awaits Route.To(...).WaitForAttribute(...) — proving the
|
||||
// executor binds the RouteHelper and the wait routes through IInstanceRouter and
|
||||
// returns its result through the endpoint. (InboundAPI-028)
|
||||
var method = SeedMethod(2, "waitmethod",
|
||||
"return await Route.To(\"inst-1\").WaitForAttribute(\"Flag\", true, System.TimeSpan.FromSeconds(1));");
|
||||
|
||||
var locator = Substitute.For<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
|
||||
Matched: true, Value: true, Quality: "Good", TimedOut: false,
|
||||
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
|
||||
using var host = await BuildHostAsync(method, additionalServices: services =>
|
||||
{
|
||||
services.RemoveAll<IInstanceLocator>();
|
||||
services.AddSingleton(locator);
|
||||
services.RemoveAll<IInstanceRouter>();
|
||||
services.AddSingleton(router);
|
||||
});
|
||||
var token = await SeedKeyAsync(host, keyId: "key1", displayName: "wait-caller",
|
||||
scopes: new[] { "waitmethod" });
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var response = await client.SendAsync(BuildPost("waitmethod", "{}", token));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("true", body);
|
||||
}
|
||||
|
||||
// SQLite-backed IDatabaseGateway fake. Owns its keep-alive connection so the shared
|
||||
// in-memory db lives for the gateway's (= host's) lifetime — NOT a GC-eligible local,
|
||||
// which could be collected mid-suite (under GC pressure + SqliteConnection.ClearAllPools
|
||||
// in Dispose) and drop the db before the request opens its own connection. A unique db
|
||||
// name per instance also keeps tests from contaminating each other. Seeded with
|
||||
// Machine(Code,SAPID)=('Z28061A','131453').
|
||||
private sealed class SqliteGateway : IDatabaseGateway, IDisposable
|
||||
{
|
||||
private readonly string _cs;
|
||||
private readonly SqliteConnection _keepAlive;
|
||||
public SqliteGateway(string cs)
|
||||
{
|
||||
_cs = cs;
|
||||
_keepAlive = new SqliteConnection(cs);
|
||||
_keepAlive.Open();
|
||||
using var cmd = _keepAlive.CreateCommand();
|
||||
cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
|
||||
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
|
||||
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
|
||||
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
|
||||
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
|
||||
=> throw new NotImplementedException();
|
||||
public void Dispose() => _keepAlive.Dispose();
|
||||
}
|
||||
|
||||
private static SqliteGateway SeededSqliteGateway() =>
|
||||
new($"DataSource=file:movein-endpoint-{Guid.NewGuid():N}?mode=memory&cache=shared");
|
||||
|
||||
private static HttpRequestMessage BuildPost(string methodName, string body, string bearerToken)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
||||
|
||||
@@ -12,46 +12,51 @@ public class InboundDatabaseHelperTests
|
||||
private sealed class SqliteGateway : IDatabaseGateway
|
||||
{
|
||||
private readonly string _cs;
|
||||
public SqliteGateway(string cs) => _cs = cs;
|
||||
// InboundAPI-027 test seam: when false the open ignores the passed token, so a
|
||||
// pre-cancelled token surfaces at Execute/Read (not at open) — letting a test
|
||||
// prove the EXECUTE path honours the deadline token, not just the open.
|
||||
private readonly bool _honorTokenOnOpen;
|
||||
public SqliteGateway(string cs, bool honorTokenOnOpen = true)
|
||||
{ _cs = cs; _honorTokenOnOpen = honorTokenOnOpen; }
|
||||
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
|
||||
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
|
||||
{ var c = new SqliteConnection(_cs); await c.OpenAsync(_honorTokenOnOpen ? ct : CancellationToken.None); return c; }
|
||||
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
|
||||
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
|
||||
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static SqliteGateway SeededGateway()
|
||||
private static SqliteGateway SeededGateway(bool honorTokenOnOpen = true)
|
||||
{
|
||||
var keep = new SqliteConnection("DataSource=file:movein?mode=memory&cache=shared");
|
||||
keep.Open(); // keep-alive: shared in-memory db lives until process exit
|
||||
using var cmd = keep.CreateCommand();
|
||||
cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');";
|
||||
cmd.ExecuteNonQuery();
|
||||
return new SqliteGateway("DataSource=file:movein?mode=memory&cache=shared");
|
||||
return new SqliteGateway("DataSource=file:movein?mode=memory&cache=shared", honorTokenOnOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuerySingle_returns_first_column_with_bound_parameter()
|
||||
public async Task QuerySingleAsync_returns_first_column_with_bound_parameter()
|
||||
{
|
||||
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
|
||||
var code = helper.QuerySingle<string>("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
|
||||
var code = await helper.QuerySingleAsync<string>("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
|
||||
Assert.Equal("Z28061A", code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuerySingle_returns_default_when_no_rows()
|
||||
public async Task QuerySingleAsync_returns_default_when_no_rows()
|
||||
{
|
||||
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
|
||||
var code = helper.QuerySingle<string>("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" });
|
||||
var code = await helper.QuerySingleAsync<string>("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" });
|
||||
Assert.Null(code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_returns_row_with_case_insensitive_keys()
|
||||
public async Task QueryAsync_returns_row_with_case_insensitive_keys()
|
||||
{
|
||||
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
|
||||
var rows = helper.Query("BTDB", "SELECT Code, SAPID FROM Machine WHERE SAPID=@s", new { s = "131453" });
|
||||
var rows = await helper.QueryAsync("BTDB", "SELECT Code, SAPID FROM Machine WHERE SAPID=@s", new { s = "131453" });
|
||||
Assert.Single(rows);
|
||||
var row = rows[0];
|
||||
Assert.Equal("Z28061A", row["code"]);
|
||||
@@ -59,10 +64,82 @@ public class InboundDatabaseHelperTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_returns_empty_list_when_no_rows_match()
|
||||
public async Task QueryAsync_returns_empty_list_when_no_rows_match()
|
||||
{
|
||||
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
|
||||
var rows = helper.Query("BTDB", "SELECT Code, SAPID FROM Machine WHERE SAPID=@s", new { s = "999999" });
|
||||
var rows = await helper.QueryAsync("BTDB", "SELECT Code, SAPID FROM Machine WHERE SAPID=@s", new { s = "999999" });
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
|
||||
// --- InboundAPI-026: writes are permitted (design decision) ---
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_performs_write_and_returns_rows_affected()
|
||||
{
|
||||
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
|
||||
var affected = await helper.ExecuteAsync(
|
||||
"BTDB", "UPDATE Machine SET Code=@c WHERE SAPID=@s", new { c = "UPDATED", s = "131453" });
|
||||
Assert.Equal(1, affected);
|
||||
|
||||
// The write actually landed (read it back through the same helper).
|
||||
var code = await helper.QuerySingleAsync<string>("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
|
||||
Assert.Equal("UPDATED", code);
|
||||
}
|
||||
|
||||
// --- InboundAPI-026: SQL-injection protection — values are bound, not concatenated ---
|
||||
|
||||
[Fact]
|
||||
public async Task ParameterValues_are_bound_not_concatenated()
|
||||
{
|
||||
// A classic injection payload as the parameter VALUE. If the helper concatenated
|
||||
// it, "WHERE SAPID='131453' OR '1'='1'" would match the seeded row and return
|
||||
// "Z28061A". Because the value is bound as a literal parameter, it matches no
|
||||
// SAPID and the query returns null — proving the value never altered the SQL.
|
||||
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
|
||||
var code = await helper.QuerySingleAsync<string>(
|
||||
"BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453' OR '1'='1" });
|
||||
Assert.Null(code);
|
||||
}
|
||||
|
||||
// --- InboundAPI-028: negative paths ---
|
||||
|
||||
[Fact]
|
||||
public async Task QuerySingleAsync_null_gateway_throws()
|
||||
{
|
||||
var helper = new InboundDatabaseHelper(gateway: null, CancellationToken.None);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => helper.QuerySingleAsync<string>("BTDB", "SELECT 1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_null_gateway_throws()
|
||||
{
|
||||
var helper = new InboundDatabaseHelper(gateway: null, CancellationToken.None);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => helper.QueryAsync("BTDB", "SELECT 1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_null_gateway_throws()
|
||||
{
|
||||
var helper = new InboundDatabaseHelper(gateway: null, CancellationToken.None);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => helper.ExecuteAsync("BTDB", "DELETE FROM Machine"));
|
||||
}
|
||||
|
||||
// --- InboundAPI-027: the method-deadline token bounds the query on the EXECUTE path ---
|
||||
|
||||
[Fact]
|
||||
public async Task QuerySingleAsync_honours_cancellation_token_on_execute()
|
||||
{
|
||||
// The gateway opens with None (so the open succeeds), but the helper forwards the
|
||||
// pre-cancelled deadline token to ExecuteScalarAsync — which must observe it. This
|
||||
// proves the fix threads the token past the connection open, where the old
|
||||
// synchronous ExecuteScalar() ignored cancellation entirely.
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
var helper = new InboundDatabaseHelper(SeededGateway(honorTokenOnOpen: false), cts.Token);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => helper.QuerySingleAsync<string>("BTDB", "SELECT Code FROM Machine"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +549,7 @@ public class InboundScriptExecutorTests
|
||||
[Fact]
|
||||
public async Task Script_UsingDatabase_QueriesViaGateway()
|
||||
{
|
||||
// A script that calls Database.QuerySingle runs against an executor whose
|
||||
// A script that calls Database.QuerySingleAsync runs against an executor whose
|
||||
// ServiceProvider registers an IDatabaseGateway backed by in-memory SQLite.
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IDatabaseGateway>(SeededSqliteGateway());
|
||||
@@ -560,7 +560,7 @@ public class InboundScriptExecutorTests
|
||||
|
||||
var method = new ApiMethod(
|
||||
"movein",
|
||||
"return new { v = Database.QuerySingle<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] }) };")
|
||||
"return new { v = await Database.QuerySingleAsync<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] }) };")
|
||||
{
|
||||
Id = 1,
|
||||
TimeoutSeconds = 10,
|
||||
|
||||
@@ -214,10 +214,15 @@ public class RouteHelperTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
public async Task WaitForAttribute_IsNotBoundByMethodDeadline_WaitTimeoutGoverns()
|
||||
{
|
||||
// InboundAPI-029 (spec §6): unlike Call/GetAttributes/SetAttributes, the wait is
|
||||
// bounded by its OWN timeout, not the method deadline. Even with the method
|
||||
// deadline ALREADY cancelled, the wait still proceeds and returns the site's
|
||||
// wait-timeout result — the deadline does not cut it short.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
await deadline.CancelAsync(); // method deadline already elapsed
|
||||
CancellationToken seen = default;
|
||||
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
@@ -226,9 +231,52 @@ public class RouteHelperTests
|
||||
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithDeadline(deadline.Token);
|
||||
var matched = await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.False(matched); // the site's wait-timeout result, not a cancellation
|
||||
Assert.False(seen.IsCancellationRequested); // the wait token ignores the cancelled method deadline
|
||||
Assert.NotEqual(deadline.Token, seen); // it is a per-wait token, not the method deadline
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_ExplicitToken_IsHonoured()
|
||||
{
|
||||
// An explicit caller token still applies (a tighter bound the script chose).
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var explicitCts = new CancellationTokenSource();
|
||||
await explicitCts.CancelAsync();
|
||||
CancellationToken seen = default;
|
||||
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
|
||||
Matched: false, Value: null, Quality: null, TimedOut: true,
|
||||
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
|
||||
await CreateHelper().To("inst-1")
|
||||
.WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30), explicitCts.Token);
|
||||
|
||||
Assert.True(seen.IsCancellationRequested); // the explicit caller token cancels the wait
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_ClientDisconnect_CancelsTheWait()
|
||||
{
|
||||
// A client disconnect (the raw request-abort token, threaded via WithRequestAborted)
|
||||
// still cancels the wait even though the method deadline does not bound it.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var clientAbort = new CancellationTokenSource();
|
||||
await clientAbort.CancelAsync();
|
||||
CancellationToken seen = default;
|
||||
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
|
||||
Matched: false, Value: null, Quality: null, TimedOut: true,
|
||||
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithRequestAborted(clientAbort.Token);
|
||||
await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
Assert.True(seen.IsCancellationRequested); // the client-abort token cancels the wait
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user