336 lines
14 KiB
C#
336 lines
14 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.SiteRuntime.Scripts;
|
|
|
|
namespace ScadaLink.SiteRuntime.Tests.Scripts;
|
|
|
|
/// <summary>
|
|
/// Audit Log #23 — M4 Bundle A (Tasks A1+A2): every synchronous DB call made
|
|
/// through <c>Database.Connection("name")</c> emits exactly one
|
|
/// <c>DbOutbound</c>/<c>DbWrite</c> audit event with an <c>Extra</c> envelope
|
|
/// distinguishing writes (<c>op="write"</c>, <c>rowsAffected=N</c>) from reads
|
|
/// (<c>op="read"</c>, <c>rowsReturned=N</c>). The audit emission is
|
|
/// best-effort — a thrown <see cref="IAuditWriter.WriteAsync"/> must never
|
|
/// abort the script's call, and the original ADO.NET result (or original
|
|
/// exception) must surface to the caller unchanged.
|
|
/// </summary>
|
|
public class DatabaseSyncEmissionTests
|
|
{
|
|
/// <summary>
|
|
/// In-memory <see cref="IAuditWriter"/> mirroring the M2 Bundle F stub —
|
|
/// captures every event and may be configured to throw to verify the
|
|
/// 3-layer fail-safe (mirrors <c>CapturingAuditWriter</c> in
|
|
/// <c>ExternalSystemCallAuditEmissionTests</c>).
|
|
/// </summary>
|
|
private sealed class CapturingAuditWriter : IAuditWriter
|
|
{
|
|
public List<AuditEvent> Events { get; } = new();
|
|
public Exception? ThrowOnWrite { get; set; }
|
|
|
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
if (ThrowOnWrite != null)
|
|
{
|
|
return Task.FromException(ThrowOnWrite);
|
|
}
|
|
|
|
Events.Add(evt);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private const string SiteId = "site-77";
|
|
private const string InstanceName = "Plant.Pump42";
|
|
private const string SourceScript = "ScriptActor:Sync";
|
|
private const string ConnectionName = "machineData";
|
|
|
|
/// <summary>
|
|
/// Audit Log #23: a fixed execution-wide correlation id used by the
|
|
/// default <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
|
|
/// overload so assertions can compare against a known value.
|
|
/// </summary>
|
|
private static readonly Guid TestCorrelationId = Guid.NewGuid();
|
|
|
|
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
|
|
IDatabaseGateway gateway,
|
|
IAuditWriter? auditWriter)
|
|
=> CreateHelper(gateway, auditWriter, TestCorrelationId);
|
|
|
|
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
|
|
IDatabaseGateway gateway,
|
|
IAuditWriter? auditWriter,
|
|
Guid correlationId)
|
|
{
|
|
return new ScriptRuntimeContext.DatabaseHelper(
|
|
gateway,
|
|
InstanceName,
|
|
NullLogger.Instance,
|
|
correlationId,
|
|
auditWriter: auditWriter,
|
|
siteId: SiteId,
|
|
sourceScript: SourceScript,
|
|
cachedForwarder: null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spin up a fresh in-memory SQLite database with a tiny single-table
|
|
/// schema we can write to and read from. The connection is returned in
|
|
/// the open state so the test only has to call <c>Connection()</c> via
|
|
/// the helper. SQLite in-memory databases live as long as the connection
|
|
/// holding them, so the keep-alive root must outlive any auditing
|
|
/// wrapper the test exercises.
|
|
/// </summary>
|
|
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
|
|
{
|
|
// The shared-cache name is per-test (Guid) so concurrent tests don't
|
|
// collide. mode=memory keeps it RAM-only; cache=shared lets the
|
|
// keep-alive root and the gateway-returned connection see the same
|
|
// in-memory DB. The keepAlive connection must remain open for the
|
|
// duration of the test or the in-memory DB is discarded.
|
|
var dbName = $"db-{Guid.NewGuid():N}";
|
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
|
|
|
keepAlive = new SqliteConnection(connStr);
|
|
keepAlive.Open();
|
|
using (var seed = keepAlive.CreateCommand())
|
|
{
|
|
seed.CommandText =
|
|
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);" +
|
|
"INSERT INTO t (id, name) VALUES (1, 'alpha');" +
|
|
"INSERT INTO t (id, name) VALUES (2, 'beta');";
|
|
seed.ExecuteNonQuery();
|
|
}
|
|
|
|
var live = new SqliteConnection(connStr);
|
|
live.Open();
|
|
return live;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_InsertSuccess_EmitsOneEvent_KindDbWrite_StatusDelivered_OpWrite_RowsAffected()
|
|
{
|
|
using var keepAlive = new SqliteConnection("Data Source=k;Mode=Memory;Cache=Shared");
|
|
var inner = NewInMemoryDb(out var _);
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
var writer = new CapturingAuditWriter();
|
|
|
|
var helper = CreateHelper(gateway.Object, writer);
|
|
await using var conn = await helper.Connection(ConnectionName);
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (3, 'gamma')";
|
|
var rows = await cmd.ExecuteNonQueryAsync();
|
|
|
|
Assert.Equal(1, rows);
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
|
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
|
Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
|
|
Assert.NotNull(evt.Extra);
|
|
Assert.Contains("\"op\":\"write\"", evt.Extra);
|
|
Assert.Contains("\"rowsAffected\":1", evt.Extra);
|
|
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
|
|
Assert.NotEqual(Guid.Empty, evt.EventId);
|
|
Assert.StartsWith(ConnectionName, evt.Target);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteScalar_Success_EmitsKindDbWrite_OpWrite()
|
|
{
|
|
using var keepAlive = new SqliteConnection("Data Source=k2;Mode=Memory;Cache=Shared");
|
|
var inner = NewInMemoryDb(out var _);
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
var writer = new CapturingAuditWriter();
|
|
|
|
var helper = CreateHelper(gateway.Object, writer);
|
|
await using var conn = await helper.Connection(ConnectionName);
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT COUNT(*) FROM t";
|
|
var scalar = await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.NotNull(scalar);
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
|
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
|
Assert.NotNull(evt.Extra);
|
|
// ExecuteScalar is classified as "write" per the M4 vocabulary lock
|
|
// (Channel=DbOutbound, Kind=DbWrite, Extra.op="write") — the
|
|
// rowsAffected for a SELECT-on-SqlCommand is -1 in ADO.NET; the audit
|
|
// wrapper records whatever DbCommand.ExecuteScalar returned via the
|
|
// built-in path, plus the rowsAffected counter the wrapper observed.
|
|
Assert.Contains("\"op\":\"write\"", evt.Extra);
|
|
Assert.Contains("rowsAffected", evt.Extra);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_Throws_EmitsEvent_StatusFailed_ErrorMessageSet()
|
|
{
|
|
using var keepAlive = new SqliteConnection("Data Source=k3;Mode=Memory;Cache=Shared");
|
|
var inner = NewInMemoryDb(out var _);
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
var writer = new CapturingAuditWriter();
|
|
|
|
var helper = CreateHelper(gateway.Object, writer);
|
|
await using var conn = await helper.Connection(ConnectionName);
|
|
await using var cmd = conn.CreateCommand();
|
|
// Reference an undefined column — SQLite throws SqliteException synchronously.
|
|
cmd.CommandText = "INSERT INTO t (does_not_exist) VALUES (1)";
|
|
await Assert.ThrowsAsync<SqliteException>(() => cmd.ExecuteNonQueryAsync());
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(AuditStatus.Failed, evt.Status);
|
|
Assert.False(string.IsNullOrEmpty(evt.ErrorMessage));
|
|
Assert.NotNull(evt.ErrorDetail);
|
|
Assert.Contains("does_not_exist", evt.ErrorDetail);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteReader_Success_EmitsKindDbWrite_OpRead_RowsReturned()
|
|
{
|
|
using var keepAlive = new SqliteConnection("Data Source=k4;Mode=Memory;Cache=Shared");
|
|
var inner = NewInMemoryDb(out var _);
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
var writer = new CapturingAuditWriter();
|
|
|
|
var helper = CreateHelper(gateway.Object, writer);
|
|
await using var conn = await helper.Connection(ConnectionName);
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT id, name FROM t ORDER BY id";
|
|
await using var reader = await cmd.ExecuteReaderAsync();
|
|
var rows = 0;
|
|
while (await reader.ReadAsync())
|
|
{
|
|
rows++;
|
|
}
|
|
// Close the reader explicitly so the audit emission (deferred to
|
|
// reader-close per the wrapper contract) fires before assertion.
|
|
await reader.CloseAsync();
|
|
|
|
Assert.Equal(2, rows);
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
|
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
|
Assert.NotNull(evt.Extra);
|
|
Assert.Contains("\"op\":\"read\"", evt.Extra);
|
|
Assert.Contains("\"rowsReturned\":2", evt.Extra);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuditWriter_Throws_ScriptCall_ReturnsOriginalResult()
|
|
{
|
|
using var keepAlive = new SqliteConnection("Data Source=k5;Mode=Memory;Cache=Shared");
|
|
var inner = NewInMemoryDb(out var _);
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
var writer = new CapturingAuditWriter
|
|
{
|
|
ThrowOnWrite = new InvalidOperationException("audit writer down")
|
|
};
|
|
|
|
var helper = CreateHelper(gateway.Object, writer);
|
|
await using var conn = await helper.Connection(ConnectionName);
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (4, 'delta')";
|
|
var rows = await cmd.ExecuteNonQueryAsync();
|
|
|
|
// Original ADO.NET result must surface unchanged despite the audit
|
|
// writer faulting — the wrapper swallows + logs the audit failure.
|
|
Assert.Equal(1, rows);
|
|
Assert.Empty(writer.Events);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Provenance_PopulatedFromContext()
|
|
{
|
|
using var keepAlive = new SqliteConnection("Data Source=k6;Mode=Memory;Cache=Shared");
|
|
var inner = NewInMemoryDb(out var _);
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
var writer = new CapturingAuditWriter();
|
|
|
|
var helper = CreateHelper(gateway.Object, writer);
|
|
await using var conn = await helper.Connection(ConnectionName);
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (5, 'epsilon')";
|
|
await cmd.ExecuteNonQueryAsync();
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(SiteId, evt.SourceSiteId);
|
|
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
|
Assert.Equal(SourceScript, evt.SourceScript);
|
|
// Outbound channel: Actor carries the calling script identity.
|
|
Assert.Equal(SourceScript, evt.Actor);
|
|
// Audit Log #23: the sync DbWrite row now carries the execution-wide
|
|
// correlation id the helper was constructed with.
|
|
Assert.Equal(TestCorrelationId, evt.CorrelationId);
|
|
Assert.NotEqual(Guid.Empty, evt.EventId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SyncDbWrite_StampsExecutionCorrelationId()
|
|
{
|
|
using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared");
|
|
var inner = NewInMemoryDb(out var _);
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
var writer = new CapturingAuditWriter();
|
|
var correlationId = Guid.NewGuid();
|
|
|
|
var helper = CreateHelper(gateway.Object, writer, correlationId);
|
|
await using var conn = await helper.Connection(ConnectionName);
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')";
|
|
await cmd.ExecuteNonQueryAsync();
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(correlationId, evt.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DurationMs_NonZero()
|
|
{
|
|
using var keepAlive = new SqliteConnection("Data Source=k7;Mode=Memory;Cache=Shared");
|
|
var inner = NewInMemoryDb(out var _);
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
var writer = new CapturingAuditWriter();
|
|
|
|
var helper = CreateHelper(gateway.Object, writer);
|
|
await using var conn = await helper.Connection(ConnectionName);
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (6, 'zeta')";
|
|
await cmd.ExecuteNonQueryAsync();
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.NotNull(evt.DurationMs);
|
|
Assert.True(evt.DurationMs >= 0, $"DurationMs={evt.DurationMs} should be >= 0");
|
|
Assert.True(evt.DurationMs <= 5000, $"DurationMs={evt.DurationMs} should be <= 5000");
|
|
}
|
|
}
|