Files
scadalink-design/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs
Joseph Doherty 0149ce6180 feat(auditlog): site script-side emitters stamp ExecutionId
Move the per-script-execution Guid on ScriptRuntimeContext from
_auditCorrelationId to _executionId, and stamp it into the dedicated
AuditEvent.ExecutionId column on every script-side audit row:

- Sync ApiCall / DbWrite: ExecutionId set; CorrelationId reverts to
  null (a sync one-shot call has no operation lifecycle).
- Cached-call script-side rows (CachedSubmit, immediate-completion
  ApiCallCached + CachedResolve) and NotifySend: ExecutionId set;
  CorrelationId unchanged (per-operation TrackedOperationId /
  NotificationId).

Renames the threaded ctor param/field across ExternalSystemHelper,
DatabaseHelper, AuditingDbConnection and AuditingDbCommand, and threads
the id through NotifyHelper/NotifyTarget. The S&F retry-loop cached rows
(CachedCallLifecycleBridge) are out of scope here.
2026-05-21 15:05:00 -04:00

340 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 per-execution id used by the default
/// <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
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)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
executionId,
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 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);
Assert.NotEqual(Guid.Empty, evt.EventId);
}
[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<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.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<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");
}
}