Files
ScadaBridge/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs
T
Joseph Doherty ac96b83b08 fix(high-severity): close 9 of 10 open High findings across 8 modules
Comm-016: delete dead HandleConnectionStateChanged + _debugSubscriptions /
_inProgressDeployments tracking + ConnectionStateChanged message record.
Disconnect detection is owned by the transport layers (gRPC keepalive PING
~25s; Ask-timeout at CommunicationService). Updates the
Component-Communication.md design doc to make that explicit.

SnF-018: NotificationForwarder.DeliverAsync now discards a corrupt buffered
payload (Warning log + return true) instead of returning false and parking
the row — honoring the design's "notifications do not park" invariant.

DM-018: reconciliation no longer force-sets Enabled, preserving an
intentional Disabled state after central failover.

ESG-018: DeliverBufferedAsync (both ExternalSystemClient + DatabaseGateway)
catches JsonException and returns false, turning a corrupt buffered row
into a parked operation instead of a retry-forever poison message.

InboundAPI-022: register ActiveNodeGate as IActiveNodeGate in the Central
DI branch so standby-node gating is actually wired up in production.

NS-019: remove orphaned NotificationDeliveryService /
INotificationDeliveryService / NotificationResult; central notification
delivery now lives entirely in NotificationOutbox.

SEL-016: normalise From/To filters to UTC before ISO-string compare so
non-UTC DateTimeOffset clients no longer get spuriously excluded events.

TE-017: include Description on attributes/alarms and a HashableConnections
projection (protocol, endpoint JSON, failover count) in the revision hash
and DiffService; staleness detection now catches description-only and
connection-endpoint edits.

Transport-001 and Transport-002 (also High) remain Open — they're being
handled in a follow-up batch because both touch BundleImporter.cs and
must serialise.
2026-05-28 05:40:15 -04:00

297 lines
12 KiB
C#

using System.Data;
using System.Data.Common;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Interfaces.Repositories;
namespace ScadaLink.ExternalSystemGateway.Tests;
/// <summary>
/// WP-9: Tests for Database access — connection resolution, cached writes.
/// </summary>
public class DatabaseGatewayTests
{
private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
/// <summary>
/// Configures the repository substitute for the name-keyed connection-resolution
/// path used by <c>DatabaseGateway</c> (ExternalSystemGateway-011). A <c>null</c>
/// connection models a "not found" — the substitute returns <c>null</c> by default,
/// so no stub is needed for the absent entity.
/// </summary>
private void StubConnection(DatabaseConnectionDefinition? connection)
{
if (connection != null)
{
_repository.GetDatabaseConnectionByNameAsync(connection.Name, Arg.Any<CancellationToken>())
.Returns(connection);
}
}
[Fact]
public async Task GetConnection_NotFound_Throws()
{
StubConnection(connection: null);
var gateway = new DatabaseGateway(
_repository,
NullLogger<DatabaseGateway>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(
() => gateway.GetConnectionAsync("nonexistent"));
}
[Fact]
public async Task CachedWrite_NoStoreAndForward_Throws()
{
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
StubConnection(conn);
var gateway = new DatabaseGateway(
_repository,
NullLogger<DatabaseGateway>.Instance,
storeAndForward: null);
await Assert.ThrowsAsync<InvalidOperationException>(
() => gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)"));
}
[Fact]
public async Task CachedWrite_ConnectionNotFound_Throws()
{
StubConnection(connection: null);
var gateway = new DatabaseGateway(
_repository,
NullLogger<DatabaseGateway>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(
() => gateway.CachedWriteAsync("nonexistent", "INSERT INTO t VALUES (1)"));
}
// ── ExternalSystemGateway-014: CachedWrite happy-path buffering ──
[Fact]
public async Task CachedWrite_BuffersTheWriteWithConnectionRetrySettings()
{
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test")
{
Id = 1,
MaxRetries = 5,
RetryDelay = TimeSpan.FromSeconds(12),
};
StubConnection(conn);
var dbName = $"EsgCachedWrite_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
using var keepAlive = new Microsoft.Data.Sqlite.SqliteConnection(connStr);
keepAlive.Open();
var storage = new ScadaLink.StoreAndForward.StoreAndForwardStorage(
connStr, NullLogger<ScadaLink.StoreAndForward.StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
var sfOptions = new ScadaLink.StoreAndForward.StoreAndForwardOptions
{
DefaultMaxRetries = 99,
DefaultRetryInterval = TimeSpan.FromMinutes(10),
RetryTimerInterval = TimeSpan.FromMinutes(10),
};
var sf = new ScadaLink.StoreAndForward.StoreAndForwardService(
storage, sfOptions, NullLogger<ScadaLink.StoreAndForward.StoreAndForwardService>.Instance);
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance, storeAndForward: sf);
// Audit Log #23 (ExecutionId Task 4): a known execution id / source
// script so the gateway -> EnqueueAsync hop can be asserted below.
var executionId = Guid.NewGuid();
const string sourceScript = "ScriptActor:WriteAudit";
await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (@v)",
new Dictionary<string, object?> { ["v"] = 1 },
executionId: executionId, sourceScript: sourceScript);
var depth = await storage.GetBufferDepthByCategoryAsync();
Assert.Equal(1, depth[ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite]);
var buffered = ReadBufferedRetrySettings(connStr);
Assert.Equal(5, buffered.MaxRetries);
Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, buffered.RetryIntervalMs);
// ExecutionId Task 4: the gateway must forward executionId / sourceScript
// into EnqueueAsync, and the S&F layer must persist them on the
// sf_messages row so the retry loop can stamp the right provenance.
Assert.Equal(executionId, buffered.ExecutionId);
Assert.Equal(sourceScript, buffered.SourceScript);
}
[Fact]
public async Task CachedWrite_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever()
{
// ExternalSystemGateway-015: a stored MaxRetries of 0 is interpreted by the
// Store-and-Forward retry sweep as "no limit" (retry forever). The entity's
// non-nullable int default is also 0, so the gateway must treat the
// connection's MaxRetries == 0 as "unset" and pass null — the bounded S&F
// default must apply, never 0.
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test")
{
Id = 1,
MaxRetries = 0,
RetryDelay = TimeSpan.FromSeconds(3),
};
StubConnection(conn);
var dbName = $"EsgCachedWriteZero_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
using var keepAlive = new Microsoft.Data.Sqlite.SqliteConnection(connStr);
keepAlive.Open();
var storage = new ScadaLink.StoreAndForward.StoreAndForwardStorage(
connStr, NullLogger<ScadaLink.StoreAndForward.StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
var sfOptions = new ScadaLink.StoreAndForward.StoreAndForwardOptions
{
DefaultMaxRetries = 99,
DefaultRetryInterval = TimeSpan.FromMinutes(10),
RetryTimerInterval = TimeSpan.FromMinutes(10),
};
var sf = new ScadaLink.StoreAndForward.StoreAndForwardService(
storage, sfOptions, NullLogger<ScadaLink.StoreAndForward.StoreAndForwardService>.Instance);
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance, storeAndForward: sf);
await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)");
var (maxRetries, _, _, _) = ReadBufferedRetrySettings(connStr);
// Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever.
Assert.Equal(99, maxRetries);
Assert.NotEqual(0, maxRetries);
}
private static (int MaxRetries, long RetryIntervalMs, Guid? ExecutionId, string? SourceScript)
ReadBufferedRetrySettings(string connStr)
{
using var conn = new Microsoft.Data.Sqlite.SqliteConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT max_retries, retry_interval_ms, execution_id, source_script FROM sf_messages";
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read(), "expected exactly one buffered message");
var result = (
reader.GetInt32(0),
reader.GetInt64(1),
reader.IsDBNull(2) ? (Guid?)null : Guid.Parse(reader.GetString(2)),
reader.IsDBNull(3) ? null : reader.GetString(3));
Assert.False(reader.Read(), "expected exactly one buffered message");
return result;
}
// ── ExternalSystemGateway-001: buffered CachedDbWrite delivery handler ──
[Fact]
public async Task DeliverBuffered_ConnectionNoLongerExists_ReturnsFalseSoMessageParks()
{
StubConnection(connection: null);
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance);
var message = new ScadaLink.StoreAndForward.StoreAndForwardMessage
{
Id = Guid.NewGuid().ToString("N"),
Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite,
Target = "gone-db",
PayloadJson =
"""{"ConnectionName":"gone-db","Sql":"INSERT INTO t VALUES (1)","Parameters":null}""",
};
var delivered = await gateway.DeliverBufferedAsync(message);
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── ExternalSystemGateway-018: malformed JSON payload must park, not retry-forever ──
[Fact]
public async Task DeliverBuffered_MalformedJsonPayload_ReturnsFalseSoMessageParks()
{
// No connection stub needed — deserialization fails before any
// resolution or SQL execution. If the JsonException were to escape (the
// pre-018 behaviour) the S&F engine would treat it as transient and
// retry the same poison row forever.
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance);
var poisonMessage = new ScadaLink.StoreAndForward.StoreAndForwardMessage
{
Id = Guid.NewGuid().ToString("N"),
Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite,
Target = "someDb",
// Truncated mid-write — `{` opens an object that never closes.
PayloadJson = "{\"ConnectionName\":\"someDb\",\"Sql\":\"INSERT",
};
var delivered = await gateway.DeliverBufferedAsync(poisonMessage);
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── ExternalSystemGateway-010: SqlConnection must not leak when OpenAsync fails ──
[Fact]
public async Task GetConnection_OpenFails_DisposesConnectionBeforeRethrowing()
{
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
StubConnection(conn);
var fake = new ThrowingDbConnection();
var gateway = new ConnectionFactoryStubGateway(_repository, fake);
await Assert.ThrowsAsync<InvalidOperationException>(
() => gateway.GetConnectionAsync("testDb"));
Assert.True(fake.WasDisposed, "The SqlConnection was leaked — it must be disposed when OpenAsync fails");
}
/// <summary>Test gateway that substitutes the connection factory with a stub.</summary>
private sealed class ConnectionFactoryStubGateway : DatabaseGateway
{
private readonly DbConnection _connection;
public ConnectionFactoryStubGateway(IExternalSystemRepository repository, DbConnection connection)
: base(repository, NullLogger<DatabaseGateway>.Instance) => _connection = connection;
internal override DbConnection CreateConnection(string connectionString) => _connection;
}
/// <summary>A DbConnection whose OpenAsync always fails, tracking whether it was disposed.</summary>
private sealed class ThrowingDbConnection : DbConnection
{
public bool WasDisposed { get; private set; }
public override Task OpenAsync(CancellationToken cancellationToken) =>
throw new InvalidOperationException("simulated open failure");
public override void Open() => throw new InvalidOperationException("simulated open failure");
protected override void Dispose(bool disposing)
{
if (disposing) WasDisposed = true;
base.Dispose(disposing);
}
public override ValueTask DisposeAsync()
{
WasDisposed = true;
return base.DisposeAsync();
}
// Unused abstract members.
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => string.Empty;
public override string DataSource => string.Empty;
public override string ServerVersion => string.Empty;
public override ConnectionState State => ConnectionState.Closed;
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
public override void Close() { }
protected override DbTransaction BeginDbTransaction(IsolationLevel il) => throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => throw new NotSupportedException();
}
}