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; /// /// Audit Log #23 — M4 Bundle A (Tasks A1+A2): every synchronous DB call made /// through Database.Connection("name") emits exactly one /// DbOutbound/DbWrite audit event with an Extra envelope /// distinguishing writes (op="write", rowsAffected=N) from reads /// (op="read", rowsReturned=N). The audit emission is /// best-effort — a thrown must never /// abort the script's call, and the original ADO.NET result (or original /// exception) must surface to the caller unchanged. /// public class DatabaseSyncEmissionTests { /// /// In-memory mirroring the M2 Bundle F stub — /// captures every event and may be configured to throw to verify the /// 3-layer fail-safe (mirrors CapturingAuditWriter in /// ExternalSystemCallAuditEmissionTests). /// private sealed class CapturingAuditWriter : IAuditWriter { public List 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"; /// /// Audit Log #23: a fixed per-execution id used by the default /// /// overload so assertions can compare against a known value. /// private static readonly Guid TestExecutionId = Guid.NewGuid(); private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, IAuditWriter? auditWriter) => CreateHelper(gateway, auditWriter, TestExecutionId); private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, IAuditWriter? auditWriter, Guid executionId, Guid? parentExecutionId = null) { return new ScriptRuntimeContext.DatabaseHelper( gateway, InstanceName, NullLogger.Instance, executionId, auditWriter: auditWriter, siteId: SiteId, sourceScript: SourceScript, cachedForwarder: null, parentExecutionId: parentExecutionId); } /// /// 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 Connection() 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. /// 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(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .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(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .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(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .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(() => 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(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .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(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .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(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .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 carries the per-execution id the // helper was constructed with in ExecutionId. CorrelationId is null — // a sync one-shot call has no operation lifecycle. Assert.Equal(TestExecutionId, evt.ExecutionId); Assert.Null(evt.CorrelationId); // Audit Log #23 (ParentExecutionId): null for a non-routed run — the // default CreateHelper supplies no parentExecutionId. Assert.Null(evt.ParentExecutionId); Assert.NotEqual(Guid.Empty, evt.EventId); } [Fact] public async Task SyncDbWrite_RoutedRun_StampsParentExecutionId_FromContext() { // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run // carries the spawning execution's id; the sync DbWrite row must stamp // it in ParentExecutionId alongside its own fresh ExecutionId. using var keepAlive = new SqliteConnection("Data Source=kp;Mode=Memory;Cache=Shared"); var inner = NewInMemoryDb(out var _); var gateway = new Mock(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .ReturnsAsync(inner); var writer = new CapturingAuditWriter(); var executionId = Guid.NewGuid(); var parentExecutionId = Guid.NewGuid(); var helper = CreateHelper(gateway.Object, writer, executionId, parentExecutionId); await using var conn = await helper.Connection(ConnectionName); await using var cmd = conn.CreateCommand(); cmd.CommandText = "INSERT INTO t (id, name) VALUES (9, 'theta')"; await cmd.ExecuteNonQueryAsync(); var evt = Assert.Single(writer.Events); Assert.Equal(parentExecutionId, evt.ParentExecutionId); Assert.Equal(executionId, evt.ExecutionId); } [Fact] public async Task SyncDbWrite_NonRoutedRun_ParentExecutionIdIsNull() { // A normal (tag/timer) run is not routed — no parent id supplied, so // the emitted DbWrite row's ParentExecutionId stays null. using var keepAlive = new SqliteConnection("Data Source=kn;Mode=Memory;Cache=Shared"); var inner = NewInMemoryDb(out var _); var gateway = new Mock(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .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 (10, 'iota')"; await cmd.ExecuteNonQueryAsync(); var evt = Assert.Single(writer.Events); Assert.Null(evt.ParentExecutionId); } [Fact] public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId() { using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared"); var inner = NewInMemoryDb(out var _); var gateway = new Mock(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .ReturnsAsync(inner); var writer = new CapturingAuditWriter(); var executionId = Guid.NewGuid(); var helper = CreateHelper(gateway.Object, writer, executionId); 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(executionId, evt.ExecutionId); // Sync one-shot call: CorrelationId is null (no operation lifecycle). Assert.Null(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(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .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"); } }