refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,395 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — M3 Bundle E (Task E6): every script-initiated
/// <c>Database.CachedWrite</c> emits exactly one <c>CachedSubmit</c>
/// combined-telemetry packet at enqueue time on the <c>DbOutbound</c>
/// channel, returns a fresh <see cref="TrackedOperationId"/>, and threads
/// the id into the database gateway so the store-and-forward retry loop can
/// emit per-attempt + terminal telemetry under the same id.
/// </summary>
public class DatabaseCachedWriteEmissionTests
{
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
{
public List<CachedCallTelemetry> Telemetry { get; } = new();
public Exception? ThrowOnForward { get; set; }
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
if (ThrowOnForward != null)
{
return Task.FromException(ThrowOnForward);
}
Telemetry.Add(telemetry);
return Task.CompletedTask;
}
}
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:WriteAudit";
/// <summary>
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
ICachedCallTelemetryForwarder? forwarder,
Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
// Audit Log #23: the per-execution id stamped into ExecutionId on
// every script-side row. Cached rows keep CorrelationId =
// TrackedOperationId (the per-operation lifecycle id).
TestExecutionId,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder,
parentExecutionId: parentExecutionId);
}
[Fact]
public async Task CachedWrite_EmitsSubmitTelemetry_OnEnqueue_KindCachedSubmit_ChannelDbOutbound()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(AuditChannel.DbOutbound, packet.Audit.Channel);
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("myDb", packet.Audit.Target);
// CorrelationId is the per-operation lifecycle id (TrackedOperationId);
// 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);
Assert.Equal("myDb", packet.Operational.Target);
Assert.Equal(SiteId, packet.Operational.SourceSite);
Assert.Equal("Submitted", packet.Operational.Status);
Assert.Equal(0, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public async Task CachedWrite_ProvenancePopulated()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(SiteId, packet.Audit.SourceSiteId);
Assert.Equal(InstanceName, packet.Audit.SourceInstanceId);
Assert.Equal(SourceScript, packet.Audit.SourceScript);
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<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.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()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the
/// threading chain. The cached-write helper must forward the runtime
/// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into
/// <see cref="IDatabaseGateway.CachedWriteAsync"/> — so the buffered retry
/// loop later stamps the right provenance onto its audit rows. This
/// asserts the exact id/script (not <c>It.IsAny</c>), so a regression that
/// dropped the threading would fail here.
/// </summary>
[Fact]
public async Task CachedWrite_ThreadsExecutionIdAndSourceScript_IntoGateway()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
// The known TestExecutionId and SourceScript must reach the gateway
// unchanged — these are what the S&F retry loop persists and replays.
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.Is<Guid?>(id => id == TestExecutionId),
It.Is<string?>(s => s == SourceScript),
It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for
/// <c>ParentExecutionId</c>. A cached write enqueued from an inbound-API-
/// routed script run must forward the runtime context's
/// <c>ParentExecutionId</c> verbatim into
/// <see cref="IDatabaseGateway.CachedWriteAsync"/> so the buffered retry
/// loop later stamps it onto its audit rows.
/// </summary>
[Fact]
public async Task CachedWrite_ThreadsParentExecutionId_IntoGateway()
{
var parentExecutionId = Guid.NewGuid();
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder, parentExecutionId);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == parentExecutionId)),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a
/// <c>null</c> ParentExecutionId into the gateway — the additive default.
/// </summary>
[Fact]
public async Task CachedWrite_NonRoutedRun_ThreadsNullParentExecutionId_IntoGateway()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == null)),
Times.Once);
}
[Fact]
public async Task CachedWrite_ForwarderThrows_StillReturnsTrackedOperationId()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder
{
ThrowOnForward = new InvalidOperationException("simulated forwarder outage"),
};
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
// ── SourceNode-stamping (Task 14) ──
[Fact]
public async Task CachedWrite_StampsSourceNode_OnSubmitTelemetryRow()
{
// Symmetric to ExternalSystemCachedCallEmissionTests's
// CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow — locks
// the DbOutbound emitter against a future refactor that drops
// _sourceNode from the Database.CachedWrite CachedSubmit row.
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = new ScriptRuntimeContext.DatabaseHelper(
gateway.Object,
InstanceName,
NullLogger.Instance,
TestExecutionId,
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder,
parentExecutionId: null,
sourceNode: "node-a");
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal("node-a", packet.Operational.SourceNode);
}
[Fact]
public async Task CachedWrite_NoSourceNodeWired_LeavesSourceNodeNull()
{
// Default CreateHelper does NOT pass sourceNode — the legacy / test
// host path. The operational row carries null SourceNode, leaving
// central's SiteCalls.SourceNode NULL.
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Null(packet.Operational.SourceNode);
}
}
@@ -0,0 +1,394 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.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,
Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
executionId,
auditWriter: auditWriter,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: null,
parentExecutionId: parentExecutionId);
}
/// <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);
// 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<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.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<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 (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<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");
}
}
@@ -0,0 +1,285 @@
using Akka.Actor;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — execution-correlation tests exercised through a full
/// <see cref="ScriptRuntimeContext"/>:
///
/// <list type="bullet">
/// <item><description>
/// The <c>?? Guid.NewGuid()</c> fallback in the <see cref="ScriptRuntimeContext"/>
/// 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.
/// </description></item>
/// <item><description>
/// The execution-wide contract: an <c>ExternalSystem.Call</c> and a sync
/// <c>Database</c> write performed through ONE context share a single
/// <see cref="AuditEvent.ExecutionId"/>. The per-operation
/// <see cref="AuditEvent.CorrelationId"/> stays null for these sync one-shot
/// calls — a sync call has no operation lifecycle.
/// </description></item>
/// </list>
/// </summary>
public class ExecutionCorrelationContextTests
{
/// <summary>
/// In-memory <see cref="IAuditWriter"/> capturing every emitted event
/// (mirrors the <c>CapturingAuditWriter</c> stubs in
/// <see cref="ExternalSystemCallAuditEmissionTests"/> /
/// <see cref="DatabaseSyncEmissionTests"/>).
/// </summary>
private sealed class CapturingAuditWriter : IAuditWriter
{
public List<AuditEvent> 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";
/// <summary>
/// Builds a full <see cref="ScriptRuntimeContext"/> wired with the external
/// system client, database gateway and audit writer the cross-helper test
/// needs. The actor refs are <see cref="ActorRefs.Nobody"/> — the
/// integration helpers (ExternalSystem / Database) never touch them — and
/// <paramref name="executionId"/> defaults to null so the ctor's
/// <c>?? Guid.NewGuid()</c> fallback is exercised unless a test supplies one.
/// </summary>
private static ScriptRuntimeContext CreateContext(
IExternalSystemClient? externalSystemClient,
IDatabaseGateway? databaseGateway,
IAuditWriter? auditWriter,
Guid? executionId = null,
Guid? parentExecutionId = null)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.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);
}
/// <summary>
/// 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 <c>DatabaseSyncEmissionTests.NewInMemoryDb</c>).
/// </summary>
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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.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);
}
}
@@ -0,0 +1,638 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — M3 Bundle E (Task E3): every script-initiated
/// <c>ExternalSystem.CachedCall</c> emits exactly one <c>CachedSubmit</c>
/// combined-telemetry packet at enqueue time, returns a fresh
/// <see cref="TrackedOperationId"/>, and threads that id down to the
/// store-and-forward layer so the retry-loop emissions (Tasks E4/E5) can join
/// them by id. The audit emission is best-effort: a thrown forwarder must
/// never abort the script's call, and the original
/// <see cref="ExternalCallResult"/> must surface to the caller unchanged.
/// </summary>
public class ExternalSystemCachedCallEmissionTests
{
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
{
public List<CachedCallTelemetry> Telemetry { get; } = new();
public Exception? ThrowOnForward { get; set; }
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
if (ThrowOnForward != null)
{
return Task.FromException(ThrowOnForward);
}
Telemetry.Add(telemetry);
return Task.CompletedTask;
}
}
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary>
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
ICachedCallTelemetryForwarder? forwarder,
Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
InstanceName,
NullLogger.Instance,
// Audit Log #23: the per-execution id stamped into ExecutionId on
// every script-side row. Cached rows keep CorrelationId =
// TrackedOperationId (the per-operation lifecycle id).
TestExecutionId,
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder,
parentExecutionId: parentExecutionId);
}
[Fact]
public async Task CachedCall_EmitsSubmitTelemetry_OnEnqueue()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, trackedId);
Assert.Single(forwarder.Telemetry);
var packet = forwarder.Telemetry[0];
Assert.Equal(AuditChannel.ApiOutbound, packet.Audit.Channel);
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("ERP.GetOrder", packet.Audit.Target);
// CorrelationId is the per-operation lifecycle id (TrackedOperationId);
// ExecutionId is the per-execution id from the runtime context.
Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState);
// Operational mirror — same id, Submitted, RetryCount 0, not terminal.
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("ApiOutbound", packet.Operational.Channel);
Assert.Equal("ERP.GetOrder", packet.Operational.Target);
Assert.Equal(SiteId, packet.Operational.SourceSite);
Assert.Equal("Submitted", packet.Operational.Status);
Assert.Equal(0, packet.Operational.RetryCount);
Assert.Null(packet.Operational.LastError);
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var args = new Dictionary<string, object?> { ["orderId"] = 42 };
await helper.CachedCall("ERP", "GetOrder", args);
// Immediate completion (WasBuffered=false) emits Submit, Attempted, Resolve.
Assert.Equal(3, forwarder.Telemetry.Count);
var submit = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedSubmit);
var attempted = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.ApiCallCached);
var resolve = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedResolve);
// Every row carries the request args; the two post-call rows also carry
// the response body (Submit precedes the call, so it has no response).
Assert.Equal("{\"orderId\":42}", submit.Audit.RequestSummary);
Assert.Null(submit.Audit.ResponseSummary);
Assert.Equal("{\"orderId\":42}", attempted.Audit.RequestSummary);
Assert.Equal("{\"ok\":true}", attempted.Audit.ResponseSummary);
Assert.Equal("{\"orderId\":42}", resolve.Audit.RequestSummary);
Assert.Equal("{\"ok\":true}", resolve.Audit.ResponseSummary);
}
[Fact]
public async Task CachedCall_ReturnsTrackedOperationId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var id1 = await helper.CachedCall("ERP", "GetOrder");
var id2 = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, id1);
Assert.NotEqual(default, id2);
Assert.NotEqual(id1, id2);
// Both ids were threaded into the client invocations.
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id1,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id2,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the
/// threading chain. The cached-call helper must forward the runtime
/// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into
/// <see cref="IExternalSystemClient.CachedCallAsync"/> — so the buffered
/// retry loop later stamps the right provenance onto its audit rows.
/// This asserts the exact id/script (not <c>It.IsAny</c>), so a regression
/// that dropped the threading would fail here.
/// </summary>
[Fact]
public async Task CachedCall_ThreadsExecutionIdAndSourceScript_IntoClient()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
// The known TestExecutionId and SourceScript must reach the client
// unchanged — these are what the S&F retry loop persists and replays.
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.Is<Guid?>(id => id == TestExecutionId),
It.Is<string?>(s => s == SourceScript),
It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for
/// <c>ParentExecutionId</c>. A cached call enqueued from an inbound-API-
/// routed script run must forward the runtime context's
/// <c>ParentExecutionId</c> verbatim into
/// <see cref="IExternalSystemClient.CachedCallAsync"/> so the buffered
/// retry loop later stamps it onto its audit rows.
/// </summary>
[Fact]
public async Task CachedCall_ThreadsParentExecutionId_IntoClient()
{
var parentExecutionId = Guid.NewGuid();
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder, parentExecutionId);
await helper.CachedCall("ERP", "GetOrder");
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == parentExecutionId)),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a
/// <c>null</c> ParentExecutionId into the client — the additive default.
/// </summary>
[Fact]
public async Task CachedCall_NonRoutedRun_ThreadsNullParentExecutionId_IntoClient()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == null)),
Times.Once);
}
[Fact]
public async Task CachedCall_ForwarderThrows_StillReturnsTrackedOperationId_OriginalCallProceeds()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder
{
ThrowOnForward = new InvalidOperationException("simulated forwarder outage"),
};
var helper = CreateHelper(client.Object, forwarder);
// Must not throw — best-effort emission contract.
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, trackedId);
// The underlying call still ran exactly once.
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
[Fact]
public async Task CachedCall_Provenance_Populated_FromContext()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(SiteId, packet.Audit.SourceSiteId);
Assert.Equal(InstanceName, packet.Audit.SourceInstanceId);
Assert.Equal(SourceScript, packet.Audit.SourceScript);
Assert.Equal(SiteId, packet.Operational.SourceSite);
}
[Fact]
public async Task CachedCall_NoForwarder_StillReturnsTrackedOperationId()
{
// Forwarder not wired (tests / minimal hosts) — must still return a
// fresh id and invoke the underlying call.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var helper = CreateHelper(client.Object, forwarder: null);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, trackedId);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 — M3 Bundle F (F2): when the underlying client call
/// completes immediately (no S&amp;F buffering, <c>WasBuffered=false</c>),
/// the S&amp;F retry loop never engages and the
/// <c>ICachedCallLifecycleObserver</c> hook never fires. The cached-call
/// helper itself must therefore emit the terminal lifecycle rows —
/// otherwise <c>Tracking.Status(id)</c> would return <c>Submitted</c>
/// forever and the audit log would be missing the <c>Attempted</c> /
/// <c>CachedResolve</c> pair the M3 contract requires.
///
/// Expected emissions on immediate success:
/// 1. CachedSubmit / Submitted (already covered)
/// 2. ApiCallCached / Attempted
/// 3. CachedResolve / Delivered (TerminalAtUtc set)
/// </summary>
[Fact]
public async Task CachedCall_ImmediateSuccess_EmitsAttemptedAndCachedResolve()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
// WasBuffered=false — the immediate HTTP attempt succeeded; S&F
// is bypassed entirely.
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
// Three telemetry packets emitted: Submit, Attempted, Resolve.
Assert.Equal(3, forwarder.Telemetry.Count);
var submit = forwarder.Telemetry[0];
Assert.Equal(AuditKind.CachedSubmit, submit.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, submit.Audit.Status);
Assert.Equal(TestExecutionId, submit.Audit.ExecutionId);
Assert.Equal(trackedId, submit.Operational.TrackedOperationId);
Assert.Null(submit.Operational.TerminalAtUtc);
var attempted = forwarder.Telemetry[1];
Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.Channel);
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
// Cached rows: CorrelationId = TrackedOperationId; ExecutionId is the
// per-execution id from the runtime context.
Assert.Equal(trackedId.Value, attempted.Audit.CorrelationId);
Assert.Equal(TestExecutionId, attempted.Audit.ExecutionId);
Assert.Equal("ERP.GetOrder", attempted.Audit.Target);
Assert.Equal(trackedId, attempted.Operational.TrackedOperationId);
Assert.Equal("Attempted", attempted.Operational.Status);
Assert.Null(attempted.Operational.TerminalAtUtc);
var resolve = forwarder.Telemetry[2];
Assert.Equal(AuditChannel.ApiOutbound, resolve.Audit.Channel);
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
Assert.Equal(trackedId.Value, resolve.Audit.CorrelationId);
Assert.Equal(TestExecutionId, resolve.Audit.ExecutionId);
Assert.Equal(trackedId, resolve.Operational.TrackedOperationId);
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<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.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));
}
/// <summary>
/// Audit Log #23 — M3 Bundle F (F2): the immediate-failure terminal
/// path. When the client returns <c>Success=false</c> with
/// <c>WasBuffered=false</c> (a permanent failure or a transient failure
/// without an S&amp;F engine to buffer it), the cached-call helper must
/// still emit Attempted + CachedResolve with the failed status.
/// </summary>
[Fact]
public async Task CachedCall_ImmediateFailure_EmitsAttemptedAndCachedResolveFailed()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(
false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.Equal(3, forwarder.Telemetry.Count);
var attempted = forwarder.Telemetry[1];
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
// The per-attempt row carries the error message.
Assert.NotNull(attempted.Audit.ErrorMessage);
var resolve = forwarder.Telemetry[2];
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
// Immediate permanent failure -> Failed audit status / operational Failed.
Assert.Equal(AuditStatus.Failed, resolve.Audit.Status);
Assert.Equal("Failed", resolve.Operational.Status);
Assert.NotNull(resolve.Operational.TerminalAtUtc);
Assert.NotNull(resolve.Operational.LastError);
}
/// <summary>
/// Audit Log #23 — M3 Bundle F (F2): when the client reports
/// <c>WasBuffered=true</c>, the helper hands the operation to S&amp;F and
/// the retry-loop observer owns the Attempted + Resolve emissions. The
/// helper must NOT emit those rows itself (otherwise we'd get duplicate
/// Attempted + Resolve audit rows under the same tracking id).
/// </summary>
[Fact]
public async Task CachedCall_BufferedPath_DoesNotEmitTerminalTelemetryFromHelper()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
// S&F took ownership — Attempted + Resolve come from the
// CachedCallLifecycleBridge driven by the retry loop, not the helper.
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
// Only the CachedSubmit row — no Attempted / Resolve from the helper.
var only = Assert.Single(forwarder.Telemetry);
Assert.Equal(AuditKind.CachedSubmit, only.Audit.Kind);
}
// ── SourceNode-stamping (Task 14) ──
[Fact]
public async Task CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow()
{
// SourceNode-stamping (Task 14): when the helper is constructed with
// a non-null sourceNode, every SiteCallOperational it produces
// (CachedSubmit on enqueue + the immediate-completion Attempted/
// CachedResolve pair when WasBuffered=false) carries that node name.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
// Immediate completion — helper produces all three rows itself.
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
client.Object,
InstanceName,
NullLogger.Instance,
TestExecutionId,
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder,
parentExecutionId: null,
sourceNode: "node-a");
await helper.CachedCall("ERP", "GetOrder");
Assert.Equal(3, forwarder.Telemetry.Count);
Assert.All(forwarder.Telemetry, t => Assert.Equal("node-a", t.Operational.SourceNode));
}
[Fact]
public async Task CachedCall_NoSourceNodeWired_LeavesSourceNodeNull()
{
// Default CreateHelper does NOT pass sourceNode — the legacy / test
// host path. Every operational row carries null SourceNode, leaving
// central's SiteCalls.SourceNode NULL.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
var only = Assert.Single(forwarder.Telemetry);
Assert.Null(only.Operational.SourceNode);
}
}
@@ -0,0 +1,344 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — M2 Bundle F (Task F1): every script-initiated
/// <c>ExternalSystem.Call</c> emits exactly one <c>ApiOutbound</c>/<c>ApiCall</c>
/// audit event via the wrapper inside
/// <see cref="ScriptRuntimeContext.ExternalSystemHelper"/>. The audit emission
/// is best-effort: a thrown <see cref="IAuditWriter.WriteAsync"/> must never
/// abort the script's call, and the original <see cref="ExternalCallResult"/>
/// (or original exception) must surface to the caller unchanged.
/// </summary>
public class ExternalSystemCallAuditEmissionTests
{
/// <summary>
/// In-memory <see cref="IAuditWriter"/> that records every event passed to
/// <see cref="WriteAsync"/>. Optionally configurable to throw, simulating a
/// catastrophic audit-writer failure that the wrapper must swallow.
/// </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:CheckPressure";
/// <summary>
/// Audit Log #23: a fixed per-execution id used by the default
/// <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter)
=> CreateHelper(client, auditWriter, TestExecutionId);
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter,
Guid executionId,
Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
InstanceName,
NullLogger.Instance,
executionId,
auditWriter,
SiteId,
SourceScript,
cachedForwarder: null,
parentExecutionId: parentExecutionId);
}
[Fact]
public async Task Call_Success_EmitsOneEvent_Channel_ApiOutbound_Kind_ApiCall_Status_Delivered()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.True(result.Success);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
Assert.Equal(AuditKind.ApiCall, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal("ERP.GetOrder", evt.Target);
Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.False(evt.PayloadTruncated);
// No call arguments → null request summary; the response body is captured.
Assert.Null(evt.RequestSummary);
Assert.Equal("{}", evt.ResponseSummary);
}
[Fact]
public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var args = new Dictionary<string, object?> { ["city"] = "Dublin" };
await helper.Call("Weather", "GetCurrent", args);
var evt = Assert.Single(writer.Events);
// RequestSummary is the serialized argument dictionary; ResponseSummary
// is the verbatim response body. (Cap + redaction are the writer's job.)
Assert.Equal("{\"city\":\"Dublin\"}", evt.RequestSummary);
Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary);
}
[Fact]
public async Task Call_HTTP500_EmitsEvent_Status_Failed_HttpStatus_500_ErrorMessage_Set()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(false, null, "Transient error: HTTP 500 from ERP: Internal Server Error"));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.False(result.Success);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(500, evt.HttpStatus);
Assert.False(string.IsNullOrEmpty(evt.ErrorMessage));
Assert.Contains("500", evt.ErrorMessage);
}
[Fact]
public async Task Call_HTTP400_EmitsEvent_Status_Failed_HttpStatus_400()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(false, null, "Permanent error: HTTP 400 from ERP: Bad Request"));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.False(result.Success);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(400, evt.HttpStatus);
}
[Fact]
public async Task Call_ClientThrows_NetworkException_EmitsEvent_Status_Failed_ErrorMessage_FromException()
{
var client = new Mock<IExternalSystemClient>();
var networkEx = new HttpRequestException("network down");
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(networkEx);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var thrown = await Assert.ThrowsAsync<HttpRequestException>(() => helper.Call("ERP", "GetOrder"));
Assert.Same(networkEx, thrown);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Null(evt.HttpStatus);
Assert.Equal("network down", evt.ErrorMessage);
Assert.NotNull(evt.ErrorDetail);
Assert.Contains("HttpRequestException", evt.ErrorDetail);
}
[Fact]
public async Task AuditWriter_Throws_Script_Call_Returns_Original_Result_Unchanged()
{
var client = new Mock<IExternalSystemClient>();
var expected = new ExternalCallResult(true, "{\"v\":1}", null);
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var writer = new CapturingAuditWriter
{
ThrowOnWrite = new InvalidOperationException("audit writer down")
};
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.Same(expected, result);
Assert.Empty(writer.Events);
}
[Fact]
public async Task Provenance_Populated_FromContext()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, null, null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var beforeId = Guid.NewGuid();
await helper.Call("ERP", "GetOrder");
var evt = writer.Events[0];
Assert.NotEqual(beforeId, evt.EventId);
Assert.NotEqual(Guid.Empty, evt.EventId);
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 ApiCall 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);
}
[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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.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]
public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var executionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, executionId);
await helper.Call("ERP", "GetOrder");
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 Call_TwoCallsOnSameHelper_ShareTheSameExecutionId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var executionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, executionId);
await helper.Call("ERP", "GetOrder");
await helper.Call("ERP", "GetCustomer");
Assert.Equal(2, writer.Events.Count);
// Both sync ApiCall rows from one execution carry the same ExecutionId.
Assert.Equal(executionId, writer.Events[0].ExecutionId);
Assert.Equal(executionId, writer.Events[1].ExecutionId);
Assert.Equal(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
// Neither sync call carries a CorrelationId.
Assert.Null(writer.Events[0].CorrelationId);
Assert.Null(writer.Events[1].CorrelationId);
}
[Fact]
public async Task DurationMs_Recorded_NonZero()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "Slow", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.Returns(async () =>
{
await Task.Delay(20);
return new ExternalCallResult(true, null, null);
});
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
await helper.Call("ERP", "Slow");
var evt = writer.Events[0];
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");
}
}
@@ -0,0 +1,320 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Notification Outbox (Task 19): tests for the async <c>Notify.Send</c> /
/// <c>Notify.Status</c> script API.
///
/// In the outbox design <c>Notify.To("list").Send(...)</c> no longer delivers email
/// inline — it generates a stable <c>NotificationId</c>, enqueues a
/// <see cref="StoreAndForwardCategory.Notification"/> message into the site
/// Store-and-Forward Engine (which Task 18 retargets to forward to central), and
/// returns the <c>NotificationId</c> immediately. <c>Notify.Status(id)</c> queries
/// central for delivery status, reporting the site-local <c>Forwarding</c> state
/// while the notification is still buffered at the site.
/// </summary>
public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _saf;
public NotifyHelperTests()
{
var dbName = $"NotifyTests_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
_keepAlive = new SqliteConnection(connStr);
_keepAlive.Open();
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
var options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10)
};
_saf = new StoreAndForwardService(_storage, options, NullLogger<StoreAndForwardService>.Instance);
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
protected override void Dispose(bool disposing)
{
if (disposing)
{
_keepAlive.Dispose();
}
base.Dispose(disposing);
}
private ScriptRuntimeContext.NotifyHelper CreateHelper(
IActorRef siteCommunicationActor,
string? sourceScript = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? sourceNode = null)
{
return new ScriptRuntimeContext.NotifyHelper(
_saf,
siteCommunicationActor,
"site-7",
"Plant.Pump3",
sourceScript,
TimeSpan.FromSeconds(3),
NullLogger.Instance,
executionId ?? Guid.NewGuid(),
auditWriter: null,
parentExecutionId: parentExecutionId,
sourceNode: sourceNode);
}
[Fact]
public async Task Send_EnqueuesNotificationIntoStoreAndForward_AndReturnsNotificationIdImmediately()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
// Send returns a non-empty NotificationId string immediately (no central round-trip).
Assert.False(string.IsNullOrEmpty(notificationId));
// Exactly one Notification-category message was buffered for the S&F forwarder.
var depth = await _saf.GetBufferDepthAsync();
Assert.Equal(1, depth.GetValueOrDefault(StoreAndForwardCategory.Notification));
}
[Fact]
public async Task Send_BufferedPayload_CarriesListSubjectBodyAndNotificationId()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
Assert.Equal(StoreAndForwardCategory.Notification, buffered!.Category);
Assert.Equal("Operators", buffered.Target);
Assert.Equal("Plant.Pump3", buffered.OriginInstanceName);
// The S&F message Id is the NotificationId — the single idempotency key.
Assert.Equal(notificationId, buffered.Id);
// The payload is a NotificationSubmit carrying the same NotificationId and the
// list / subject / body the script supplied — the shape the forwarder reads.
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered.PayloadJson);
Assert.NotNull(payload);
Assert.Equal(notificationId, payload!.NotificationId);
Assert.Equal("Operators", payload.ListName);
Assert.Equal("Pump alarm", payload.Subject);
Assert.Equal("Pump 3 tripped", payload.Body);
Assert.Equal("Plant.Pump3", payload.SourceInstanceId);
}
[Fact]
public async Task Send_WhenHelperHasSourceScript_StampsItOnTheNotificationSubmit()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, sourceScript: "ScriptActor:MonitorSpeed");
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
// FU3: the executing script name is threaded down and stamped for the audit trail.
Assert.Equal("ScriptActor:MonitorSpeed", payload!.SourceScript);
}
[Fact]
public async Task Send_StampsExecutionId_OnTheNotificationSubmitPayload()
{
// Audit Log #23 (ExecutionId Task 5): Notify.Send must stamp the
// script run's ExecutionId onto the NotificationSubmit so it rides
// inside the serialized S&F payload to central, where the dispatcher
// echoes it onto the NotifyDeliver rows. This is the SAME id stamped
// onto the site-emitted NotifySend audit row.
var executionId = Guid.NewGuid();
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, executionId: executionId);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Equal(executionId, payload!.OriginExecutionId);
}
[Fact]
public async Task Send_StampsParentExecutionId_OnTheNotificationSubmitPayload()
{
// Audit Log ParentExecutionId (Task 7): for an inbound-API-routed run,
// Notify.Send must stamp the routed run's parent ExecutionId onto the
// NotificationSubmit so it rides inside the serialized S&F payload to
// central, where the dispatcher echoes it onto the NotifyDeliver rows.
// This is the SAME parent id stamped onto the site-emitted NotifySend row.
var parentExecutionId = Guid.NewGuid();
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, parentExecutionId: parentExecutionId);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Equal(parentExecutionId, payload!.OriginParentExecutionId);
}
[Fact]
public async Task Send_NonRoutedRun_LeavesOriginParentExecutionIdNull()
{
// Non-routed runs have no parent execution — OriginParentExecutionId
// stays null on the NotificationSubmit payload.
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, parentExecutionId: null);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Null(payload!.OriginParentExecutionId);
}
[Fact]
public async Task Send_StampsSourceNode_OnTheNotificationSubmitPayload()
{
// SourceNode-stamping (Task 13): when the helper is wired with the
// local INodeIdentityProvider's NodeName, Notify.Send must stamp it
// onto the NotificationSubmit so it rides inside the serialized S&F
// payload to central, where NotificationOutboxActor persists it on
// the Notifications row.
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, sourceNode: "node-a");
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Equal("node-a", payload!.SourceNode);
}
[Fact]
public async Task Send_NoNodeIdentity_LeavesSourceNodeNull()
{
// Hosts that don't wire INodeIdentityProvider (legacy / tests) pass
// null through. The NotificationSubmit payload's SourceNode stays
// null so the central Notifications row persists NULL rather than
// falling back to a placeholder.
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, sourceNode: null);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Null(payload!.SourceNode);
}
[Fact]
public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, sourceScript: null);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.Null(payload!.SourceScript);
}
[Fact]
public async Task Status_WhenStillBufferedAtSite_ReportsForwarding()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
// Enqueue but never let it forward — the message stays buffered at the site.
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var statusTask = notify.Status(notificationId);
// The status query goes to central; central has no row for an un-forwarded
// notification, so it answers Found: false.
var query = await commProbe.ExpectMsgAsync<NotificationStatusQuery>();
Assert.Equal(notificationId, query.NotificationId);
commProbe.Reply(new NotificationStatusResponse(
query.CorrelationId, Found: false, Status: "Unknown",
RetryCount: 0, LastError: null, DeliveredAt: null));
var status = await statusTask;
// Found: false AND still in the site S&F buffer → the site-local Forwarding state.
Assert.Equal("Forwarding", status.Status);
}
[Fact]
public async Task Status_WhenCentralReportsDelivered_MapsTheCentralResponse()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
var deliveredAt = DateTimeOffset.UtcNow;
var statusTask = notify.Status("not-buffered-id");
var query = await commProbe.ExpectMsgAsync<NotificationStatusQuery>();
commProbe.Reply(new NotificationStatusResponse(
query.CorrelationId, Found: true, Status: "Delivered",
RetryCount: 2, LastError: "earlier transient", DeliveredAt: deliveredAt));
var status = await statusTask;
Assert.Equal("Delivered", status.Status);
Assert.Equal(2, status.RetryCount);
Assert.Equal("earlier transient", status.LastError);
Assert.Equal(deliveredAt, status.DeliveredAt);
}
[Fact]
public async Task Status_WhenCentralNotFoundAndNotBuffered_ReportsUnknown()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
var statusTask = notify.Status("never-existed-id");
var query = await commProbe.ExpectMsgAsync<NotificationStatusQuery>();
commProbe.Reply(new NotificationStatusResponse(
query.CorrelationId, Found: false, Status: "Unknown",
RetryCount: 0, LastError: null, DeliveredAt: null));
var status = await statusTask;
// Not at central, not in the site buffer → genuinely unknown, NOT Forwarding.
Assert.Equal("Unknown", status.Status);
}
}
@@ -0,0 +1,283 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — M4 Bundle C (Task C1): every script-initiated
/// <c>Notify.To("list").Send(...)</c> emits exactly one
/// <c>Notification</c>/<c>NotifySend</c> audit event via the wrapper inside
/// <see cref="ScriptRuntimeContext.NotifyTarget"/>. The audit emission is
/// best-effort: a thrown <see cref="IAuditWriter.WriteAsync"/> must never
/// abort the script's <c>Send</c> — the original <c>NotificationId</c> must
/// still flow back to the caller and the underlying S&amp;F enqueue must still
/// have happened.
/// </summary>
public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
{
/// <summary>
/// In-memory <see cref="IAuditWriter"/> that records every event passed to
/// <see cref="WriteAsync"/>. Optionally configurable to throw, simulating a
/// catastrophic audit-writer failure that the wrapper must swallow per
/// alog.md §7.
/// </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-7";
private const string InstanceName = "Plant.Pump3";
private const string SourceScript = "ScriptActor:CheckPressure";
private const string ListName = "Operators";
private const string Subject = "Pump alarm";
private const string Body = "Pump 3 tripped";
/// <summary>
/// Audit Log #23: a fixed per-execution id so the NotifySend test can
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _saf;
public NotifySendAuditEmissionTests()
{
var dbName = $"NotifySendAudit_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
_keepAlive = new SqliteConnection(connStr);
_keepAlive.Open();
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
var options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10)
};
_saf = new StoreAndForwardService(_storage, options, NullLogger<StoreAndForwardService>.Instance);
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
protected override void Dispose(bool disposing)
{
if (disposing)
{
_keepAlive.Dispose();
}
base.Dispose(disposing);
}
private ScriptRuntimeContext.NotifyHelper CreateHelper(
IAuditWriter? auditWriter,
string? sourceScript = SourceScript,
Guid? parentExecutionId = null)
{
// siteCommunicationActor is unused by Send — pass a probe so the helper
// is fully constructed.
var probe = CreateTestProbe();
return new ScriptRuntimeContext.NotifyHelper(
_saf,
probe.Ref,
SiteId,
InstanceName,
sourceScript,
TimeSpan.FromSeconds(3),
NullLogger.Instance,
TestExecutionId,
auditWriter,
parentExecutionId: parentExecutionId);
}
[Fact]
public async Task Send_Success_EmitsOneEvent_KindNotifySend_StatusSubmitted()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
var notificationId = await notify.To(ListName).Send(Subject, Body);
Assert.False(string.IsNullOrEmpty(notificationId));
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditChannel.Notification, evt.Channel);
Assert.Equal(AuditKind.NotifySend, evt.Kind);
Assert.Equal(AuditStatus.Submitted, evt.Status);
Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.False(evt.PayloadTruncated);
Assert.Null(evt.DurationMs);
Assert.Null(evt.HttpStatus);
Assert.Null(evt.ErrorMessage);
Assert.Null(evt.ErrorDetail);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
}
[Fact]
public async Task Send_PopulatesTarget_AsListName()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
await notify.To(ListName).Send(Subject, Body);
var evt = writer.Events[0];
Assert.Equal(ListName, evt.Target);
}
[Fact]
public async Task Send_PopulatesRequestSummary_AsSubjectBodyJson()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
await notify.To(ListName).Send(Subject, Body);
var evt = writer.Events[0];
Assert.NotNull(evt.RequestSummary);
// Round-trip the JSON to assert the exact shape, not raw text — the
// contract is "JSON of {subject, body}", which downstream redaction
// (M5) can reshape; M4 captures verbatim.
using var doc = JsonDocument.Parse(evt.RequestSummary!);
var root = doc.RootElement;
Assert.Equal(JsonValueKind.Object, root.ValueKind);
Assert.Equal(Subject, root.GetProperty("subject").GetString());
Assert.Equal(Body, root.GetProperty("body").GetString());
}
[Fact]
public async Task Send_AuditWriter_Throws_OriginalSendStillReturns()
{
var writer = new CapturingAuditWriter
{
ThrowOnWrite = new InvalidOperationException("audit writer down")
};
var notify = CreateHelper(writer);
// The Send call must NOT bubble the audit-writer failure: the script
// contract is that the notification is buffered and the id is returned
// even when the audit pipeline is sick.
var notificationId = await notify.To(ListName).Send(Subject, Body);
Assert.False(string.IsNullOrEmpty(notificationId));
// And the underlying S&F enqueue must still have happened — audit is
// purely additive, never aborts the user-facing action.
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
Assert.Equal(notificationId, buffered!.Id);
Assert.Empty(writer.Events);
}
[Fact]
public async Task Send_Provenance_PopulatedFromContext()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
await notify.To(ListName).Send(Subject, Body);
var evt = writer.Events[0];
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);
}
[Fact]
public async Task Send_NotificationIdParsed_AsCorrelationId()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
var notificationId = await notify.To(ListName).Send(Subject, Body);
// NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char
// hex form, which Guid.TryParse accepts. The audit row's CorrelationId
// must round-trip back to the same Guid value (the per-operation
// lifecycle id). ExecutionId carries the per-execution id instead.
Assert.True(Guid.TryParse(notificationId, out var expected),
$"NotificationId '{notificationId}' should be a parseable Guid");
var evt = writer.Events[0];
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]
public async Task Send_WithoutAuditWriter_StillReturnsNotificationId_AndEnqueues()
{
// Audit is opt-in (mirrors M2 Bundle F behaviour): a null writer must
// degrade to a no-op audit path so tests / minimal hosts that don't
// wire AddAuditLog still work.
var notify = CreateHelper(auditWriter: null);
var notificationId = await notify.To(ListName).Send(Subject, Body);
Assert.False(string.IsNullOrEmpty(notificationId));
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
}
}
@@ -0,0 +1,308 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// WP-6 (Phase 8): Script sandboxing verification.
/// Adversarial tests that verify forbidden APIs are blocked at compilation time.
/// </summary>
public class SandboxTests
{
private readonly ScriptCompilationService _service;
public SandboxTests()
{
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
}
// ── System.IO forbidden ──
[Fact]
public void Sandbox_FileRead_Blocked()
{
var result = _service.Compile("evil", """System.IO.File.ReadAllText("/etc/passwd")""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("System.IO"));
}
[Fact]
public void Sandbox_FileWrite_Blocked()
{
var result = _service.Compile("evil", """System.IO.File.WriteAllText("/tmp/hack.txt", "pwned")""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_DirectoryCreate_Blocked()
{
var result = _service.Compile("evil", """System.IO.Directory.CreateDirectory("/tmp/evil")""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_FileStream_Blocked()
{
var result = _service.Compile("evil", """new System.IO.FileStream("/tmp/x", System.IO.FileMode.Create)""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_StreamReader_Blocked()
{
var result = _service.Compile("evil", """new System.IO.StreamReader("/tmp/x")""");
Assert.False(result.IsSuccess);
}
// ── Process forbidden ──
[Fact]
public void Sandbox_ProcessStart_Blocked()
{
var result = _service.Compile("evil", """System.Diagnostics.Process.Start("cmd.exe", "/c dir")""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("Process"));
}
[Fact]
public void Sandbox_ProcessStartInfo_Blocked()
{
var code = """
var psi = new System.Diagnostics.Process();
psi.StartInfo.FileName = "bash";
""";
var result = _service.Compile("evil", code);
Assert.False(result.IsSuccess);
}
// ── Threading forbidden (except Tasks/CancellationToken) ──
[Fact]
public void Sandbox_ThreadCreate_Blocked()
{
var result = _service.Compile("evil", """new System.Threading.Thread(() => {}).Start()""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("System.Threading"));
}
[Fact]
public void Sandbox_Mutex_Blocked()
{
var result = _service.Compile("evil", """new System.Threading.Mutex()""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_Semaphore_Blocked()
{
var result = _service.Compile("evil", """new System.Threading.Semaphore(1, 1)""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_TaskDelay_Allowed()
{
// async/await and Tasks are explicitly allowed
var violations = _service.ValidateTrustModel("await System.Threading.Tasks.Task.Delay(100)");
Assert.Empty(violations);
}
[Fact]
public void Sandbox_CancellationToken_Allowed()
{
var violations = _service.ValidateTrustModel(
"var ct = System.Threading.CancellationToken.None;");
Assert.Empty(violations);
}
[Fact]
public void Sandbox_CancellationTokenSource_Allowed()
{
var violations = _service.ValidateTrustModel(
"var cts = new System.Threading.CancellationTokenSource();");
Assert.Empty(violations);
}
// ── Reflection forbidden ──
[Fact]
public void Sandbox_GetType_Reflection_Blocked()
{
var result = _service.Compile("evil",
"""typeof(string).GetMethods(System.Reflection.BindingFlags.NonPublic)""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_AssemblyLoad_Blocked()
{
var result = _service.Compile("evil",
"""System.Reflection.Assembly.Load("System.Runtime")""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_ActivatorCreateInstance_ViaReflection_Blocked()
{
var result = _service.Compile("evil",
"""System.Reflection.Assembly.GetExecutingAssembly()""");
Assert.False(result.IsSuccess);
}
// ── Raw network forbidden ──
[Fact]
public void Sandbox_TcpClient_Blocked()
{
var result = _service.Compile("evil", """new System.Net.Sockets.TcpClient("evil.com", 80)""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("System.Net.Sockets"));
}
[Fact]
public void Sandbox_UdpClient_Blocked()
{
var result = _service.Compile("evil", """new System.Net.Sockets.UdpClient(1234)""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_HttpClient_Blocked()
{
var result = _service.Compile("evil", """new System.Net.Http.HttpClient()""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("System.Net.Http"));
}
[Fact]
public void Sandbox_HttpRequestMessage_Blocked()
{
var result = _service.Compile("evil",
"""new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, "https://evil.com")""");
Assert.False(result.IsSuccess);
}
// ── Allowed operations ──
[Fact]
public void Sandbox_BasicMath_Allowed()
{
var result = _service.Compile("safe", "Math.Max(1, 2)");
Assert.True(result.IsSuccess);
}
[Fact]
public void Sandbox_LinqOperations_Allowed()
{
var result = _service.Compile("safe",
"new List<int> { 1, 2, 3 }.Where(x => x > 1).Sum()");
Assert.True(result.IsSuccess);
}
[Fact]
public void Sandbox_StringOperations_Allowed()
{
var result = _service.Compile("safe",
"""string.Join(", ", new[] { "a", "b", "c" })""");
Assert.True(result.IsSuccess);
}
[Fact]
public void Sandbox_DateTimeOperations_Allowed()
{
var result = _service.Compile("safe",
"DateTime.UtcNow.AddHours(1).ToString(\"o\")");
Assert.True(result.IsSuccess);
}
// ── Execution timeout ──
[Fact]
public async Task Sandbox_InfiniteLoop_CancelledByToken()
{
// Compile a script that loops forever
var code = """
while (true) {
CancellationToken.ThrowIfCancellationRequested();
}
return null;
""";
var result = _service.Compile("infinite", code);
Assert.True(result.IsSuccess, "Infinite loop compiles but should be cancelled at runtime");
// Execute with a short timeout
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
var globals = new ScriptGlobals
{
Instance = null!,
Parameters = new ScriptParameters(),
CancellationToken = cts.Token
};
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await result.CompiledScript!.RunAsync(globals, cts.Token);
});
}
[Fact]
public async Task Sandbox_LongRunningScript_TimesOut()
{
// A script that does heavy computation with cancellation checks
var code = """
var sum = 0;
for (var i = 0; i < 100_000_000; i++) {
sum += i;
if (i % 10000 == 0) CancellationToken.ThrowIfCancellationRequested();
}
return sum;
""";
var result = _service.Compile("heavy", code);
Assert.True(result.IsSuccess);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var globals = new ScriptGlobals
{
Instance = null!,
Parameters = new ScriptParameters(),
CancellationToken = cts.Token
};
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await result.CompiledScript!.RunAsync(globals, cts.Token);
});
}
// ── Combined adversarial attempts ──
[Fact]
public void Sandbox_MultipleViolationsInOneScript_AllDetected()
{
var code = """
System.IO.File.ReadAllText("/etc/passwd");
System.Diagnostics.Process.Start("cmd");
new System.Net.Sockets.TcpClient();
new System.Net.Http.HttpClient();
""";
var violations = _service.ValidateTrustModel(code);
Assert.True(violations.Count >= 4,
$"Expected at least 4 violations but got {violations.Count}: {string.Join("; ", violations)}");
}
[Fact]
public void Sandbox_UsingDirective_StillDetected()
{
var code = """
// Even with using aliases, the namespace string is still detected
var x = System.IO.Path.GetTempPath();
""";
var violations = _service.ValidateTrustModel(code);
Assert.NotEmpty(violations);
}
}
@@ -0,0 +1,87 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Phase 1 of the script-scope rollout: verify path arithmetic for the new
/// Attributes / Children / Parent accessors. The actor-mediated reads/writes
/// are exercised end-to-end in Phase 2 once flattening carries scope info.
/// </summary>
public class ScopeAccessorTests
{
[Fact]
public void Root_SelfPath_Empty()
{
Assert.Equal("", ScriptScope.Root.SelfPath);
Assert.Null(ScriptScope.Root.ParentPath);
Assert.False(ScriptScope.Root.HasParent);
}
[Fact]
public void CompositionScope_HasParent()
{
var scope = new ScriptScope("TempSensor", "");
Assert.True(scope.HasParent);
Assert.Equal("", scope.ParentPath);
}
[Fact]
public void AttributeAccessor_RootScope_ResolvesBareKey()
{
var acc = new AttributeAccessor(null!, "");
Assert.Equal("Temperature", acc.Resolve("Temperature"));
}
[Fact]
public void AttributeAccessor_ComposedScope_PrependsPath()
{
var acc = new AttributeAccessor(null!, "TempSensor");
Assert.Equal("TempSensor.Temperature", acc.Resolve("Temperature"));
}
[Fact]
public void AttributeAccessor_NestedScope_ChainsPath()
{
var acc = new AttributeAccessor(null!, "Motor.TempSensor");
Assert.Equal("Motor.TempSensor.Temperature", acc.Resolve("Temperature"));
}
[Fact]
public void CompositionAccessor_AttributesShareScope()
{
var comp = new CompositionAccessor(null!, "TempSensor");
Assert.Equal("TempSensor", comp.Path);
Assert.Equal("TempSensor", comp.Attributes.ScopePrefix);
}
[Fact]
public void CompositionAccessor_ResolveScript_PrependsPath()
{
var comp = new CompositionAccessor(null!, "TempSensor");
Assert.Equal("TempSensor.Sample", comp.ResolveScript("Sample"));
}
[Fact]
public void CompositionAccessor_EmptyPath_LeavesScriptNameBare()
{
var comp = new CompositionAccessor(null!, "");
Assert.Equal("Sample", comp.ResolveScript("Sample"));
}
[Fact]
public void ChildrenAccessor_FromRoot_GivesUnpathedChild()
{
var children = new ChildrenAccessor(null!, "");
var temp = children["TempSensor"];
Assert.Equal("TempSensor", temp.Path);
}
[Fact]
public void ChildrenAccessor_FromComposition_PrefixesChild()
{
var children = new ChildrenAccessor(null!, "Motor");
var temp = children["TempSensor"];
Assert.Equal("Motor.TempSensor", temp.Path);
}
}
@@ -0,0 +1,111 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// WP-19: Script Trust Model tests — validates forbidden API detection and compilation.
/// </summary>
public class ScriptCompilationServiceTests
{
private readonly ScriptCompilationService _service;
public ScriptCompilationServiceTests()
{
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
}
[Fact]
public void Compile_ValidScript_Succeeds()
{
var result = _service.Compile("test", "1 + 1");
Assert.True(result.IsSuccess);
Assert.NotNull(result.CompiledScript);
Assert.Empty(result.Errors);
}
[Fact]
public void Compile_InvalidSyntax_ReturnsErrors()
{
var result = _service.Compile("bad", "this is not valid C# {{{");
Assert.False(result.IsSuccess);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void ValidateTrustModel_SystemIO_Forbidden()
{
var violations = _service.ValidateTrustModel("System.IO.File.ReadAllText(\"test\")");
Assert.NotEmpty(violations);
Assert.Contains(violations, v => v.Contains("System.IO"));
}
[Fact]
public void ValidateTrustModel_Process_Forbidden()
{
var violations = _service.ValidateTrustModel(
"System.Diagnostics.Process.Start(\"cmd\")");
Assert.NotEmpty(violations);
}
[Fact]
public void ValidateTrustModel_Reflection_Forbidden()
{
var violations = _service.ValidateTrustModel(
"typeof(string).GetType().GetMethods(System.Reflection.BindingFlags.Public)");
Assert.NotEmpty(violations);
}
[Fact]
public void ValidateTrustModel_Sockets_Forbidden()
{
var violations = _service.ValidateTrustModel(
"new System.Net.Sockets.TcpClient()");
Assert.NotEmpty(violations);
}
[Fact]
public void ValidateTrustModel_HttpClient_Forbidden()
{
var violations = _service.ValidateTrustModel(
"new System.Net.Http.HttpClient()");
Assert.NotEmpty(violations);
}
[Fact]
public void ValidateTrustModel_AsyncAwait_Allowed()
{
// System.Threading.Tasks should be allowed (async/await support)
var violations = _service.ValidateTrustModel(
"await System.Threading.Tasks.Task.Delay(100)");
Assert.Empty(violations);
}
[Fact]
public void ValidateTrustModel_CancellationToken_Allowed()
{
var violations = _service.ValidateTrustModel(
"System.Threading.CancellationToken.None");
Assert.Empty(violations);
}
[Fact]
public void ValidateTrustModel_CleanCode_NoViolations()
{
var code = @"
var x = 1 + 2;
var list = new List<int> { 1, 2, 3 };
var sum = list.Sum();
sum";
var violations = _service.ValidateTrustModel(code);
Assert.Empty(violations);
}
[Fact]
public void Compile_ForbiddenApi_FailsValidation()
{
var result = _service.Compile("evil", "System.IO.File.Delete(\"/tmp/test\")");
Assert.False(result.IsSuccess);
Assert.NotEmpty(result.Errors);
}
}
@@ -0,0 +1,47 @@
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// SiteRuntime-009: the dedicated script-execution scheduler must run script bodies on
/// its own dedicated threads, not on the shared .NET thread pool, so blocking script
/// I/O cannot starve the global pool.
/// </summary>
public class ScriptExecutionSchedulerTests
{
[Fact]
public async Task Scheduler_RunsWork_OffTheThreadPool()
{
using var scheduler = new ScriptExecutionScheduler(2);
bool wasThreadPoolThread = true;
string? threadName = null;
await Task.Factory.StartNew(() =>
{
wasThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
threadName = Thread.CurrentThread.Name;
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler);
Assert.False(wasThreadPoolThread,
"Script work must not run on a shared thread-pool thread.");
Assert.StartsWith("script-execution-", threadName);
}
[Fact]
public void Scheduler_RespectsConfiguredThreadCount()
{
using var scheduler = new ScriptExecutionScheduler(4);
Assert.Equal(4, scheduler.MaximumConcurrencyLevel);
}
[Fact]
public void Scheduler_Shared_ReturnsSameInstanceForOptions()
{
var options = new SiteRuntimeOptions { ScriptExecutionThreadCount = 3 };
var a = ScriptExecutionScheduler.Shared(options);
var b = ScriptExecutionScheduler.Shared(options);
Assert.Same(a, b);
}
}
@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// WP-17: Shared Script Library tests — compile, register, execute inline.
/// </summary>
public class SharedScriptLibraryTests
{
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _library;
public SharedScriptLibraryTests()
{
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_library = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
[Fact]
public void CompileAndRegister_ValidScript_Succeeds()
{
var result = _library.CompileAndRegister("add", "1 + 2");
Assert.True(result);
Assert.True(_library.Contains("add"));
}
[Fact]
public void CompileAndRegister_InvalidScript_ReturnsFalse()
{
var result = _library.CompileAndRegister("bad", "this is not valid {{{");
Assert.False(result);
Assert.False(_library.Contains("bad"));
}
[Fact]
public void CompileAndRegister_ForbiddenApi_ReturnsFalse()
{
var result = _library.CompileAndRegister("evil", "System.IO.File.Delete(\"/tmp\")");
Assert.False(result);
}
[Fact]
public void CompileAndRegister_Replaces_ExistingScript()
{
_library.CompileAndRegister("calc", "1 + 1");
_library.CompileAndRegister("calc", "2 + 2");
Assert.True(_library.Contains("calc"));
// Should have only one entry
Assert.Equal(1, _library.GetRegisteredScriptNames().Count(n => n == "calc"));
}
[Fact]
public void Remove_RegisteredScript_ReturnsTrue()
{
_library.CompileAndRegister("temp", "42");
Assert.True(_library.Remove("temp"));
Assert.False(_library.Contains("temp"));
}
[Fact]
public void Remove_NonexistentScript_ReturnsFalse()
{
Assert.False(_library.Remove("nonexistent"));
}
[Fact]
public void GetRegisteredScriptNames_ReturnsAllNames()
{
_library.CompileAndRegister("a", "1");
_library.CompileAndRegister("b", "2");
_library.CompileAndRegister("c", "3");
var names = _library.GetRegisteredScriptNames();
Assert.Equal(3, names.Count);
Assert.Contains("a", names);
Assert.Contains("b", names);
Assert.Contains("c", names);
}
[Fact]
public async Task ExecuteAsync_NonexistentScript_Throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(
() => _library.ExecuteAsync("missing", null!));
}
}
@@ -0,0 +1,77 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 (M3 Bundle A — Task A3) — script-side API tests for
/// <c>Tracking.Status(TrackedOperationId)</c>. The helper reads the site-local
/// <see cref="IOperationTrackingStore"/> directly (no central round-trip) and
/// returns the latest <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when
/// the id is unknown.
/// </summary>
public class TrackingApiTests
{
private static ScriptRuntimeContext.TrackingHelper CreateHelper(
IOperationTrackingStore? store)
{
return new ScriptRuntimeContext.TrackingHelper(store, NullLogger.Instance);
}
[Fact]
public async Task Status_UnknownId_ReturnsNull()
{
var store = new Mock<IOperationTrackingStore>();
store
.Setup(s => s.GetStatusAsync(It.IsAny<TrackedOperationId>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((TrackingStatusSnapshot?)null);
var helper = CreateHelper(store.Object);
var result = await helper.Status(TrackedOperationId.New());
Assert.Null(result);
}
[Fact]
public async Task Status_KnownId_ReturnsLatestSnapshot()
{
var id = TrackedOperationId.New();
var expected = new TrackingStatusSnapshot(
Id: id,
Kind: "ApiCallCached",
TargetSummary: "ERP.GetOrder",
Status: "Delivered",
RetryCount: 2,
LastError: null,
HttpStatus: 200,
CreatedAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
UpdatedAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc),
TerminalAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc),
SourceInstanceId: "Plant.Pump42",
SourceScript: "ScriptActor:OnTick",
SourceNode: null);
var store = new Mock<IOperationTrackingStore>();
store
.Setup(s => s.GetStatusAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var helper = CreateHelper(store.Object);
var result = await helper.Status(id);
Assert.NotNull(result);
Assert.Equal(expected, result);
}
[Fact]
public async Task Status_NoStoreWired_Throws()
{
var helper = CreateHelper(store: null);
await Assert.ThrowsAsync<InvalidOperationException>(
() => helper.Status(TrackedOperationId.New()));
}
}
@@ -0,0 +1,92 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// SiteRuntime-011: regression tests for the semantic-analysis trust-model validation.
/// The previous implementation was a raw substring scan of the source text — it both
/// missed forbidden APIs (no literal namespace string) and raised false positives on
/// the namespace string appearing in comments, string literals or unrelated identifiers.
/// </summary>
public class TrustModelSemanticTests
{
private readonly ScriptCompilationService _service =
new(NullLogger<ScriptCompilationService>.Instance);
// ── Bypass cases (under-inclusive substring scan would MISS these) ──
[Fact]
public void TrustModel_GlobalQualifiedForbiddenType_IsDetected()
{
// `global::`-prefixed name — the literal "System.IO" substring is still present
// here, but the resolved-symbol approach catches it regardless of spelling.
var violations = _service.ValidateTrustModel(
"global::System.IO.File.ReadAllText(\"/etc/passwd\")");
Assert.NotEmpty(violations);
}
[Fact]
public void TrustModel_ForbiddenTypeViaUsingAlias_IsDetected()
{
// A using-alias hides the forbidden namespace from a substring scan entirely:
// the script body never writes "System.IO". Semantic resolution still sees that
// the alias resolves to System.IO.File.
var code = """
using F = System.IO.File;
F.ReadAllText("/etc/passwd");
""";
var violations = _service.ValidateTrustModel(code);
Assert.NotEmpty(violations);
Assert.Contains(violations, v => v.Contains("System.IO"));
}
// ── False-positive cases (over-inclusive substring scan would WRONGLY flag these) ──
[Fact]
public void TrustModel_ForbiddenNamespaceInStringLiteral_IsNotFlagged()
{
// "System.IO" appears only inside a string literal — not an API reference.
var violations = _service.ValidateTrustModel(
"var label = \"System.IO is blocked\"; return label;");
Assert.Empty(violations);
}
[Fact]
public void TrustModel_ForbiddenNamespaceInComment_IsNotFlagged()
{
var code = """
// This script does not use System.IO or System.Reflection at all.
var x = 1 + 2;
return x;
""";
var violations = _service.ValidateTrustModel(code);
Assert.Empty(violations);
}
[Fact]
public void TrustModel_UnrelatedIdentifierContainingForbiddenSubstring_IsNotFlagged()
{
// A local variable whose name merely contains "Threading" is harmless.
var code = """
var ProcessThreadingCount = 5;
return ProcessThreadingCount + 1;
""";
var violations = _service.ValidateTrustModel(code);
Assert.Empty(violations);
}
// ── Allowed exceptions still resolve correctly ──
[Fact]
public void TrustModel_TaskAndCancellationToken_RemainAllowed()
{
var code = """
var cts = new System.Threading.CancellationTokenSource();
await System.Threading.Tasks.Task.Delay(1, cts.Token);
return 0;
""";
var violations = _service.ValidateTrustModel(code);
Assert.Empty(violations);
}
}