From 150ba5e63f9fc1075a470093e8b42eae93bb597f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 17:45:55 -0400 Subject: [PATCH] feat(auditlog): site script-side emitters stamp ParentExecutionId --- .../Scripts/AuditingDbCommand.cs | 19 ++- .../Scripts/AuditingDbConnection.cs | 21 +++- .../Scripts/ScriptRuntimeContext.cs | 23 +++- .../DatabaseCachedWriteEmissionTests.cs | 34 ++++- .../Scripts/DatabaseSyncEmissionTests.cs | 59 ++++++++- .../ExecutionCorrelationContextTests.cs | 119 +++++++++++------- .../ExternalSystemCachedCallEmissionTests.cs | 40 +++++- .../ExternalSystemCallAuditEmissionTests.cs | 49 +++++++- .../Scripts/NotifySendAuditEmissionTests.cs | 39 +++++- 9 files changed, 343 insertions(+), 60 deletions(-) diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs index e5427ed..52b0e44 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs @@ -38,12 +38,22 @@ internal sealed class AuditingDbCommand : DbCommand private readonly string _instanceName; private readonly string? _sourceScript; private readonly Guid _executionId; + + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's id when this + /// run was inbound-API-routed; null for non-routed runs. Threaded + /// alongside and stamped onto the DbWrite + /// audit row. + /// + private readonly Guid? _parentExecutionId; + private readonly ILogger _logger; private DbConnection? _wrappingConnection; // Parameter ordering: executionId sits immediately after the ILogger, // consistent with the other three audit-threaded ctors (ExternalSystemHelper, - // DatabaseHelper, AuditingDbConnection). + // DatabaseHelper, AuditingDbConnection). parentExecutionId is a trailing + // optional param so existing positional callers stay source-compatible. public AuditingDbCommand( DbCommand inner, IAuditWriter auditWriter, @@ -52,7 +62,8 @@ internal sealed class AuditingDbCommand : DbCommand string instanceName, string? sourceScript, ILogger logger, - Guid executionId) + Guid executionId, + Guid? parentExecutionId = null) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); @@ -62,6 +73,7 @@ internal sealed class AuditingDbCommand : DbCommand _sourceScript = sourceScript; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _executionId = executionId; + _parentExecutionId = parentExecutionId; } // -- Forwarded surface ------------------------------------------------ @@ -438,6 +450,9 @@ internal sealed class AuditingDbCommand : DbCommand // trust-boundary rows from the same script run. CorrelationId = null, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning execution's id; + // null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs index c5a52e1..9ac91c9 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs @@ -37,11 +37,21 @@ internal sealed class AuditingDbConnection : DbConnection private readonly string _instanceName; private readonly string? _sourceScript; private readonly Guid _executionId; + + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's id when this + /// run was inbound-API-routed; null for non-routed runs. Threaded + /// alongside into the + /// so its DbWrite row stamps it. + /// + private readonly Guid? _parentExecutionId; + private readonly ILogger _logger; // Parameter ordering: executionId sits immediately after the ILogger, // consistent with the other three audit-threaded ctors (ExternalSystemHelper, - // DatabaseHelper, AuditingDbCommand). + // DatabaseHelper, AuditingDbCommand). parentExecutionId is a trailing + // optional param so existing positional callers stay source-compatible. public AuditingDbConnection( DbConnection inner, IAuditWriter auditWriter, @@ -50,7 +60,8 @@ internal sealed class AuditingDbConnection : DbConnection string instanceName, string? sourceScript, ILogger logger, - Guid executionId) + Guid executionId, + Guid? parentExecutionId = null) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); @@ -60,6 +71,7 @@ internal sealed class AuditingDbConnection : DbConnection _sourceScript = sourceScript; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _executionId = executionId; + _parentExecutionId = parentExecutionId; } // ConnectionString is settable on DbConnection — forward both halves. @@ -99,7 +111,10 @@ internal sealed class AuditingDbConnection : DbConnection _instanceName, _sourceScript, _logger, - _executionId); + _executionId, + // Audit Log #23 (ParentExecutionId): the spawning execution's id, + // threaded alongside _executionId. Null for non-routed runs. + _parentExecutionId); } protected override void Dispose(bool disposing) diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index a112928..57e6c11 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -626,6 +626,9 @@ public class ScriptRuntimeContext // per-execution id shared across this script run. CorrelationId = trackedId.Value, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -739,6 +742,9 @@ public class ScriptRuntimeContext // ExecutionId = per-execution id for this script run. CorrelationId = trackedId.Value, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -803,6 +809,9 @@ public class ScriptRuntimeContext // ExecutionId = per-execution id for this script run. CorrelationId = trackedId.Value, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -980,6 +989,9 @@ public class ScriptRuntimeContext // one script run can be correlated together. CorrelationId = null, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning execution's + // id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -1127,7 +1139,10 @@ public class ScriptRuntimeContext instanceName: _instanceName, sourceScript: _sourceScript, logger: _logger, - executionId: _executionId); + executionId: _executionId, + // Audit Log #23 (ParentExecutionId): the spawning execution's + // id, threaded alongside _executionId. Null for non-routed runs. + parentExecutionId: _parentExecutionId); } /// @@ -1203,6 +1218,9 @@ public class ScriptRuntimeContext // (TrackedOperationId); ExecutionId = per-execution id. CorrelationId = trackedId.Value, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -1571,6 +1589,9 @@ public class ScriptRuntimeContext // lifecycle id; ExecutionId carries the per-execution id. CorrelationId = correlationId, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index 405b38e..e10216c 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -47,7 +47,8 @@ public class DatabaseCachedWriteEmissionTests private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, - ICachedCallTelemetryForwarder? forwarder) + ICachedCallTelemetryForwarder? forwarder, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.DatabaseHelper( gateway, @@ -59,7 +60,8 @@ public class DatabaseCachedWriteEmissionTests TestExecutionId, siteId: SiteId, sourceScript: SourceScript, - cachedForwarder: forwarder); + cachedForwarder: forwarder, + parentExecutionId: parentExecutionId); } [Fact] @@ -91,6 +93,8 @@ public class DatabaseCachedWriteEmissionTests // ExecutionId is the per-execution id from the runtime context. Assert.Equal(trackedId.Value, packet.Audit.CorrelationId); Assert.Equal(TestExecutionId, packet.Audit.ExecutionId); + // Audit Log #23 (ParentExecutionId): null for a non-routed run. + Assert.Null(packet.Audit.ParentExecutionId); Assert.Equal(trackedId, packet.Operational.TrackedOperationId); Assert.Equal("DbOutbound", packet.Operational.Channel); @@ -126,6 +130,32 @@ public class DatabaseCachedWriteEmissionTests Assert.Equal(SiteId, packet.Operational.SourceSite); } + [Fact] + public async Task CachedWrite_RoutedRun_StampsParentExecutionId_OnSubmitTelemetry() + { + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // carries the spawning execution's id; the CachedSubmit telemetry row + // must stamp it in ParentExecutionId. + var parentExecutionId = Guid.NewGuid(); + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + It.IsAny(), It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(gateway.Object, forwarder, parentExecutionId); + await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)"); + + var packet = Assert.Single(forwarder.Telemetry); + Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId); + } + [Fact] public async Task CachedWrite_ReturnsTrackedOperationId_ThreadsIdToGateway() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs index 021cae5..f610e80 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs @@ -63,7 +63,8 @@ public class DatabaseSyncEmissionTests private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, IAuditWriter? auditWriter, - Guid executionId) + Guid executionId, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.DatabaseHelper( gateway, @@ -73,7 +74,8 @@ public class DatabaseSyncEmissionTests auditWriter: auditWriter, siteId: SiteId, sourceScript: SourceScript, - cachedForwarder: null); + cachedForwarder: null, + parentExecutionId: parentExecutionId); } /// @@ -287,9 +289,62 @@ public class DatabaseSyncEmissionTests // 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() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs index 280830d..ab7412f 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs @@ -92,22 +92,6 @@ public class ExecutionCorrelationContextTests parentExecutionId: parentExecutionId); } - /// - /// Reads a private / field off a - /// . The ParentExecutionId plumbing (Audit - /// Log #23, Task 4) only stores the value on the context — no emitter stamps - /// it onto an audit row yet (that is Task 5) — so the field is inspected - /// directly rather than through an emitted row. - /// - private static object? ReadPrivateField(ScriptRuntimeContext context, string fieldName) - { - var field = typeof(ScriptRuntimeContext).GetField( - fieldName, - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - Assert.NotNull(field); - return field!.GetValue(context); - } - /// /// 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 @@ -203,52 +187,99 @@ public class ExecutionCorrelationContextTests } [Fact] - public void ParentExecutionIdSupplied_StoredVerbatim_AndOwnExecutionIdIsFreshAndDistinct() + public async Task ParentExecutionIdSupplied_StampedOnEmittedRow_AndDistinctFromOwnExecutionId() { - // Audit Log #23 (ParentExecutionId, Task 4): an inbound-API-routed call + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed call // supplies the spawning execution's ExecutionId as the routed script's - // ParentExecutionId. The context must store that value verbatim AND - // still mint its OWN fresh ExecutionId — the routed script is a new - // execution, it does not inherit the parent's id. + // 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( - externalSystemClient: null, + client.Object, databaseGateway: null, - auditWriter: null, + writer, // executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs. parentExecutionId: parentExecutionId); + await context.ExternalSystem.Call("ERP", "GetOrder"); - var storedParent = ReadPrivateField(context, "_parentExecutionId"); - var ownExecutionId = ReadPrivateField(context, "_executionId"); - - // The parent id is carried through untouched. - Assert.Equal(parentExecutionId, storedParent); - + 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(ownExecutionId); - var ownId = Assert.IsType(ownExecutionId); - Assert.NotEqual(Guid.Empty, ownId); - Assert.NotEqual(parentExecutionId, ownId); + Assert.NotNull(evt.ExecutionId); + Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); + Assert.NotEqual(parentExecutionId, evt.ExecutionId!.Value); } [Fact] - public void NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNull() + public async Task NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNullOnEmittedRow() { // A normal (tag-change / timer) script run is not inbound-API-routed — - // no ParentExecutionId is supplied, so _parentExecutionId stays null - // while the run still gets its own fresh ExecutionId. + // 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( - externalSystemClient: null, - databaseGateway: null, - auditWriter: null); + client.Object, gateway.Object, writer, parentExecutionId: parentExecutionId); - var storedParent = ReadPrivateField(context, "_parentExecutionId"); - var ownExecutionId = ReadPrivateField(context, "_executionId"); + await context.ExternalSystem.Call("ERP", "GetOrder"); - Assert.Null(storedParent); - var ownId = Assert.IsType(ownExecutionId); - Assert.NotEqual(Guid.Empty, ownId); + 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); } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index ce392cb..4bf31c3 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -49,7 +49,8 @@ public class ExternalSystemCachedCallEmissionTests private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, - ICachedCallTelemetryForwarder? forwarder) + ICachedCallTelemetryForwarder? forwarder, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.ExternalSystemHelper( client, @@ -62,7 +63,8 @@ public class ExternalSystemCachedCallEmissionTests auditWriter: null, siteId: SiteId, sourceScript: SourceScript, - cachedForwarder: forwarder); + cachedForwarder: forwarder, + parentExecutionId: parentExecutionId); } [Fact] @@ -386,6 +388,40 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal("Delivered", resolve.Operational.Status); // Terminal row carries TerminalAtUtc. Assert.NotNull(resolve.Operational.TerminalAtUtc); + + // Audit Log #23 (ParentExecutionId): null on every script-side cached + // row for a non-routed run. + Assert.Null(submit.Audit.ParentExecutionId); + Assert.Null(attempted.Audit.ParentExecutionId); + Assert.Null(resolve.Audit.ParentExecutionId); + } + + [Fact] + public async Task CachedCall_RoutedRun_StampsParentExecutionId_OnAllScriptSideRows() + { + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // carries the spawning execution's id; every script-side cached row + // (CachedSubmit, ApiCallCached, CachedResolve) must stamp it in + // ParentExecutionId. + var parentExecutionId = Guid.NewGuid(); + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(client.Object, forwarder, parentExecutionId); + await helper.CachedCall("ERP", "GetOrder"); + + Assert.Equal(3, forwarder.Telemetry.Count); + Assert.All(forwarder.Telemetry, t => + Assert.Equal(parentExecutionId, t.Audit.ParentExecutionId)); } /// diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs index 6632783..b9e7948 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs @@ -60,7 +60,8 @@ public class ExternalSystemCallAuditEmissionTests private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, IAuditWriter? auditWriter, - Guid executionId) + Guid executionId, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.ExternalSystemHelper( client, @@ -69,7 +70,9 @@ public class ExternalSystemCallAuditEmissionTests executionId, auditWriter, SiteId, - SourceScript); + SourceScript, + cachedForwarder: null, + parentExecutionId: parentExecutionId); } [Fact] @@ -230,6 +233,48 @@ public class ExternalSystemCallAuditEmissionTests // 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); + } + + [Fact] + public async Task Call_RoutedRun_StampsParentExecutionId_FromContext() + { + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // carries the spawning execution's id; the sync ApiCall row must stamp + // it in ParentExecutionId alongside 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 parentExecutionId = Guid.NewGuid(); + + var helper = CreateHelper(client.Object, writer, TestExecutionId, parentExecutionId); + await helper.Call("ERP", "GetOrder"); + + var evt = Assert.Single(writer.Events); + Assert.Equal(parentExecutionId, evt.ParentExecutionId); + Assert.Equal(TestExecutionId, evt.ExecutionId); + } + + [Fact] + public async Task Call_NonRoutedRun_ParentExecutionIdIsNull() + { + // A normal (tag/timer) run is not routed — no parent id supplied, so + // the emitted ApiCall row's ParentExecutionId stays null. + 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 helper = CreateHelper(client.Object, writer); + await helper.Call("ERP", "GetOrder"); + + var evt = Assert.Single(writer.Events); + Assert.Null(evt.ParentExecutionId); } [Fact] diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs index 402821b..89fdbea 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs @@ -95,7 +95,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable private ScriptRuntimeContext.NotifyHelper CreateHelper( IAuditWriter? auditWriter, - string? sourceScript = SourceScript) + string? sourceScript = SourceScript, + Guid? parentExecutionId = null) { // siteCommunicationActor is unused by Send — pass a probe so the helper // is fully constructed. @@ -109,7 +110,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable TimeSpan.FromSeconds(3), NullLogger.Instance, TestExecutionId, - auditWriter); + auditWriter, + parentExecutionId: parentExecutionId); } [Fact] @@ -229,6 +231,39 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.NotNull(evt.CorrelationId); Assert.Equal(expected, evt.CorrelationId); Assert.Equal(TestExecutionId, evt.ExecutionId); + // Audit Log #23 (ParentExecutionId): null for a non-routed run. + Assert.Null(evt.ParentExecutionId); + } + + [Fact] + public async Task Send_RoutedRun_StampsParentExecutionId_OnNotifySendRow() + { + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // carries the spawning execution's id; the NotifySend row must stamp + // it in ParentExecutionId alongside its own ExecutionId. + var parentExecutionId = Guid.NewGuid(); + var writer = new CapturingAuditWriter(); + var notify = CreateHelper(writer, parentExecutionId: parentExecutionId); + + await notify.To(ListName).Send(Subject, Body); + + var evt = Assert.Single(writer.Events); + Assert.Equal(parentExecutionId, evt.ParentExecutionId); + Assert.Equal(TestExecutionId, evt.ExecutionId); + } + + [Fact] + public async Task Send_NonRoutedRun_ParentExecutionIdIsNull() + { + // A normal (tag/timer) run is not routed — the NotifySend row's + // ParentExecutionId stays null. + var writer = new CapturingAuditWriter(); + var notify = CreateHelper(writer); + + await notify.To(ListName).Send(Subject, Body); + + var evt = Assert.Single(writer.Events); + Assert.Null(evt.ParentExecutionId); } [Fact]