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:
Joseph Doherty
2026-06-23 22:00:17 -04:00
parent d39089f4ed
commit b3c9014379
11 changed files with 540 additions and 68 deletions
@@ -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
{