using Akka.Actor; 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 — execution-correlation tests exercised through a full /// : /// /// /// /// The ?? Guid.NewGuid() fallback in the /// ctor: when no execution id is supplied (tag-change / timer-triggered /// executions) a fresh, non-empty id is minted and stamped on the emitted rows. /// /// /// The execution-wide contract: an ExternalSystem.Call and a sync /// Database write performed through ONE context share a single /// . The per-operation /// stays null for these sync one-shot /// calls — a sync call has no operation lifecycle. /// /// /// public class ExecutionCorrelationContextTests { /// /// In-memory capturing every emitted event /// (mirrors the CapturingAuditWriter stubs in /// / /// ). /// private sealed class CapturingAuditWriter : IAuditWriter { public List Events { get; } = new(); public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; } } private const string InstanceName = "Plant.Pump42"; private const string ConnectionName = "machineData"; /// /// Builds a full wired with the external /// system client, database gateway and audit writer the cross-helper test /// needs. The actor refs are — the /// integration helpers (ExternalSystem / Database) never touch them — and /// defaults to null so the ctor's /// ?? Guid.NewGuid() fallback is exercised unless a test supplies one. /// private static ScriptRuntimeContext CreateContext( IExternalSystemClient? externalSystemClient, IDatabaseGateway? databaseGateway, IAuditWriter? auditWriter, Guid? executionId = null, Guid? parentExecutionId = null) { var compilationService = new ScriptCompilationService( NullLogger.Instance); var sharedScriptLibrary = new SharedScriptLibrary( compilationService, NullLogger.Instance); return new ScriptRuntimeContext( ActorRefs.Nobody, ActorRefs.Nobody, sharedScriptLibrary, currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), instanceName: InstanceName, logger: NullLogger.Instance, externalSystemClient: externalSystemClient, databaseGateway: databaseGateway, storeAndForward: null, siteCommunicationActor: null, siteId: "site-77", sourceScript: "ScriptActor:OnTick", auditWriter: auditWriter, operationTrackingStore: null, cachedForwarder: null, executionId: executionId, parentExecutionId: parentExecutionId); } /// /// Spin up a fresh in-memory SQLite database with a tiny single-table /// schema. The keep-alive root must outlive any auditing wrapper the test /// exercises (mirrors DatabaseSyncEmissionTests.NewInMemoryDb). /// private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive) { 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);"; seed.ExecuteNonQuery(); } var live = new SqliteConnection(connStr); live.Open(); return live; } [Fact] public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId() { // No executionId argument — the ScriptRuntimeContext ctor's // `?? Guid.NewGuid()` fallback must mint one (this is the unsupplied-id // branch every other audit test bypasses by passing an explicit id). var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var writer = new CapturingAuditWriter(); var context = CreateContext(client.Object, databaseGateway: null, writer); await context.ExternalSystem.Call("ERP", "GetOrder"); var evt = Assert.Single(writer.Events); Assert.NotNull(evt.ExecutionId); Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); // A sync one-shot call has no operation lifecycle — CorrelationId is null. Assert.Null(evt.CorrelationId); } [Fact] public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId() { // The execution-wide contract: an ExternalSystem.Call AND a sync // Database write performed through ONE ScriptRuntimeContext must both // carry the same ExecutionId, so an audit reader can tie every // trust-boundary action from one script run together. using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared"); var innerDb = NewInMemoryDb(out var _); var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var gateway = new Mock(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .ReturnsAsync(innerDb); var writer = new CapturingAuditWriter(); var context = CreateContext(client.Object, gateway.Object, writer); // 1) outbound API call through the context's ExternalSystem helper. await context.ExternalSystem.Call("ERP", "GetOrder"); // 2) sync DB write through the SAME context's Database helper. await using (var conn = await context.Database.Connection(ConnectionName)) await using (var cmd = conn.CreateCommand()) { cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')"; await cmd.ExecuteNonQueryAsync(); } Assert.Equal(2, writer.Events.Count); var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound); var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound); Assert.NotNull(apiRow.ExecutionId); Assert.NotEqual(Guid.Empty, apiRow.ExecutionId!.Value); // The ApiCall row and the DbWrite row, emitted by two different helpers // resolved off one context, carry the identical ExecutionId. Assert.Equal(apiRow.ExecutionId, dbRow.ExecutionId); // Both are sync one-shot calls — neither carries a CorrelationId. Assert.Null(apiRow.CorrelationId); Assert.Null(dbRow.CorrelationId); } [Fact] public async Task ParentExecutionIdSupplied_StampedOnEmittedRow_AndDistinctFromOwnExecutionId() { // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed call // supplies the spawning execution's ExecutionId as the routed script's // ParentExecutionId. Every audit row the routed script emits must carry // that value in AuditEvent.ParentExecutionId — and still carry its OWN // fresh ExecutionId, distinct from the parent (the routed script is a // new execution, it does not inherit the parent's id). var parentExecutionId = Guid.NewGuid(); var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var writer = new CapturingAuditWriter(); var context = CreateContext( client.Object, databaseGateway: null, writer, // executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs. parentExecutionId: parentExecutionId); await context.ExternalSystem.Call("ERP", "GetOrder"); var evt = Assert.Single(writer.Events); // The parent id is stamped on the emitted row untouched. Assert.Equal(parentExecutionId, evt.ParentExecutionId); // The routed script's own ExecutionId is freshly generated, non-empty, // and NOT the parent id — they are separate correlation values. Assert.NotNull(evt.ExecutionId); Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); Assert.NotEqual(parentExecutionId, evt.ExecutionId!.Value); } [Fact] public async Task NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNullOnEmittedRow() { // A normal (tag-change / timer) script run is not inbound-API-routed — // no ParentExecutionId is supplied, so every emitted audit row carries // a null ParentExecutionId while the run still gets its own fresh // ExecutionId. var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var writer = new CapturingAuditWriter(); var context = CreateContext(client.Object, databaseGateway: null, writer); await context.ExternalSystem.Call("ERP", "GetOrder"); var evt = Assert.Single(writer.Events); Assert.Null(evt.ParentExecutionId); Assert.NotNull(evt.ExecutionId); Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); } [Fact] public async Task ParentExecutionIdSupplied_StampedOnApiAndDbRows_FromSameContext() { // The execution-wide contract extends to ParentExecutionId: an // ExternalSystem.Call and a sync Database write performed through ONE // routed context both carry the identical ParentExecutionId. var parentExecutionId = Guid.NewGuid(); using var keepAlive = new SqliteConnection("Data Source=ecc-parent;Mode=Memory;Cache=Shared"); var innerDb = NewInMemoryDb(out var _); var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var gateway = new Mock(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .ReturnsAsync(innerDb); var writer = new CapturingAuditWriter(); var context = CreateContext( client.Object, gateway.Object, writer, parentExecutionId: parentExecutionId); await context.ExternalSystem.Call("ERP", "GetOrder"); await using (var conn = await context.Database.Connection(ConnectionName)) await using (var cmd = conn.CreateCommand()) { cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')"; await cmd.ExecuteNonQueryAsync(); } Assert.Equal(2, writer.Events.Count); var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound); var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound); Assert.Equal(parentExecutionId, apiRow.ParentExecutionId); Assert.Equal(parentExecutionId, dbRow.ParentExecutionId); } }