Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs
T
Joseph Doherty 463aced7a1 test(inboundapi): root the shared in-memory SQLite keep-alive so the database cannot vanish mid-test
Script_UsingDatabase_QueriesViaGateway failed in about 2 of 27 full InboundAPI runs. Looping the
suite 30 times captured the failure 4 times, always as "Internal script error". The exception
underneath was SqliteException 'no such table: Machine'.

Cause: the seeding helper opened its keep-alive SqliteConnection in a local. Once the helper
returned, nothing referenced that connection. After a GC (likely during the Roslyn compile, under
full-suite load), Microsoft.Data.Sqlite's pool reclaimed the leaked connection and closed the last
connection to the shared in-memory database, which destroyed it.

Fix: hold the keep-alive in a static field. InboundDatabaseHelperTests had the same latent bug and
gets the same fix. The test now forces GC plus SqliteConnection.ClearAllPools() before the query.
That makes the old pattern fail deterministically and the fix pass deterministically.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 06:12:34 -05:00

187 lines
9.1 KiB
C#

using System.Data.Common;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
public class InboundDatabaseHelperTests
{
private sealed class SqliteGateway : IDatabaseGateway
{
private readonly string _cs;
// 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;
/// <summary>
/// Initializes a new instance of the <c>SqliteGateway</c> class.
/// </summary>
/// <param name="cs">The cs.</param>
/// <param name="honorTokenOnOpen">The honor token on open.</param>
public SqliteGateway(string cs, bool honorTokenOnOpen = true)
{ _cs = cs; _honorTokenOnOpen = honorTokenOnOpen; }
/// <inheritdoc />
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
{ var c = new SqliteConnection(_cs); await c.OpenAsync(_honorTokenOnOpen ? ct : CancellationToken.None); return c; }
/// <inheritdoc />
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();
}
/// <summary>
/// Keep-alive for the shared in-memory database, rooted in a static field. It used to be a
/// local in the seeding helper: once the helper returned nothing referenced it, so after a GC the
/// Sqlite pool could reclaim the leaked connection, close the last connection and destroy the
/// database before the test's query ran (<c>no such table: Machine</c>). This is the same latent
/// flake the executor tests hit.
/// </summary>
private static readonly SqliteConnection KeepAlive = OpenKeepAlive();
private static SqliteConnection OpenKeepAlive()
{
var keep = new SqliteConnection("DataSource=file:movein?mode=memory&cache=shared");
keep.Open(); // lives until process exit
return keep;
}
private static SqliteGateway SeededGateway(bool honorTokenOnOpen = true)
{
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();
return new SqliteGateway("DataSource=file:movein?mode=memory&cache=shared", honorTokenOnOpen);
}
/// <summary>Verifies that query single async returns first column with bound parameter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QuerySingleAsync_returns_first_column_with_bound_parameter()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = await helper.QuerySingleAsync<string>("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
Assert.Equal("Z28061A", code);
}
/// <summary>Verifies that query single async returns default when no rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QuerySingleAsync_returns_default_when_no_rows()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = await helper.QuerySingleAsync<string>("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" });
Assert.Null(code);
}
/// <summary>Verifies that query async returns row with case insensitive keys.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_returns_row_with_case_insensitive_keys()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
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"]);
Assert.Equal("Z28061A", row["CODE"]);
}
/// <summary>Verifies that query async returns empty list when no rows match.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_returns_empty_list_when_no_rows_match()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var rows = await helper.QueryAsync("BTDB", "SELECT Code, SAPID FROM Machine WHERE SAPID=@s", new { s = "999999" });
Assert.Empty(rows);
}
// --- Writes are permitted (design decision) ---
/// <summary>Verifies that execute async performs write and returns rows affected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
// --- SQL-injection protection — values are bound, not concatenated ---
/// <summary>Verifies that parameter values are bound not concatenated.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
// --- Negative paths ---
/// <summary>Verifies that query single async null gateway throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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"));
}
/// <summary>Verifies that query async null gateway throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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"));
}
/// <summary>Verifies that execute async null gateway throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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"));
}
// --- The method-deadline token bounds the query on the EXECUTE path ---
/// <summary>Verifies that query single async honours cancellation token on execute.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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"));
}
}