5 Commits

Author SHA1 Message Date
Joseph Doherty
aadb1fd72a refactor(auditlog): rename audit correlation field, add cross-helper tests 2026-05-21 13:57:17 -04:00
Joseph Doherty
8243f61e96 feat(auditlog): per-script-execution correlation id on sync audit rows 2026-05-21 13:46:34 -04:00
Joseph Doherty
53508c79b2 Merge branch 'feature/audit-apicall-payloads': capture API-call payloads
Outbound API audit rows now carry the request arguments and response body
(sync ApiCall + cached immediate-completion path); the emitter previously
hard-coded both summary fields to null.
2026-05-21 10:17:50 -04:00
Joseph Doherty
849a011400 fix(auditlog): capture request/response payloads on outbound API audit rows
The outbound ApiCall emitter hard-coded RequestSummary/ResponseSummary to null,
so audited API calls carried no inputs/outputs — contrary to the Audit Log
payload-capture spec. Thread the call arguments into the sync ApiCall emitter
and the cached immediate-completion path (CachedSubmit / ApiCallCached /
CachedResolve), and stamp the response body from ExternalCallResult.ResponseJson.
The writer's payload filter still applies the size cap + redaction downstream.

The S&F retry-loop cached rows are unchanged — request data is not threaded
through the store-and-forward buffer (same boundary as SourceScript).
2026-05-21 10:17:42 -04:00
Joseph Doherty
405de525ca Merge branch 'feature/audit-channel-single-select': single-select Channel filter
The Audit Log Channel filter becomes a single-select — Kind narrows to the
chosen channel, so multi-channel selection is incoherent. Kind, Status and Site
stay multi-select.
2026-05-21 10:03:08 -04:00
12 changed files with 502 additions and 18 deletions

View File

@@ -145,6 +145,17 @@ public sealed class AuditWriteMiddleware
OccurredAtUtc = DateTime.UtcNow, OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound, Channel = AuditChannel.ApiInbound,
Kind = kind, Kind = kind,
// Audit Log #23: a fresh per-request correlation id so the
// inbound row carries a request identifier (closes the design
// gap that inbound rows should be correlatable).
//
// This id is intentionally request-local: it is NOT bridged to
// RouteHelper's routed-call correlation id or to
// HttpContext.TraceIdentifier. Threading an inbound request's
// correlation id through to the routed script execution (so an
// inbound call and the outbound API/DB rows it triggers share
// one id) is a deliberate future follow-up, out of scope here.
CorrelationId = Guid.NewGuid(),
Actor = actor, Actor = actor,
Target = methodName, Target = methodName,
Status = status, Status = status,

View File

@@ -37,9 +37,13 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly Guid _auditCorrelationId;
private readonly ILogger _logger; private readonly ILogger _logger;
private DbConnection? _wrappingConnection; private DbConnection? _wrappingConnection;
// Parameter ordering: auditCorrelationId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection).
public AuditingDbCommand( public AuditingDbCommand(
DbCommand inner, DbCommand inner,
IAuditWriter auditWriter, IAuditWriter auditWriter,
@@ -47,7 +51,8 @@ internal sealed class AuditingDbCommand : DbCommand
string siteId, string siteId,
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger) ILogger logger,
Guid auditCorrelationId)
{ {
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); _inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -56,6 +61,7 @@ internal sealed class AuditingDbCommand : DbCommand
_instanceName = instanceName ?? string.Empty; _instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_auditCorrelationId = auditCorrelationId;
} }
// -- Forwarded surface ------------------------------------------------ // -- Forwarded surface ------------------------------------------------
@@ -426,7 +432,10 @@ internal sealed class AuditingDbCommand : DbCommand
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound, Channel = AuditChannel.DbOutbound,
Kind = AuditKind.DbWrite, Kind = AuditKind.DbWrite,
CorrelationId = null, // Audit Log #23: the execution-wide correlation id, so this sync
// DbWrite row shares an id with the other sync trust-boundary rows
// from the same script run.
CorrelationId = _auditCorrelationId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,

View File

@@ -36,8 +36,12 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly Guid _auditCorrelationId;
private readonly ILogger _logger; private readonly ILogger _logger;
// Parameter ordering: auditCorrelationId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbCommand).
public AuditingDbConnection( public AuditingDbConnection(
DbConnection inner, DbConnection inner,
IAuditWriter auditWriter, IAuditWriter auditWriter,
@@ -45,7 +49,8 @@ internal sealed class AuditingDbConnection : DbConnection
string siteId, string siteId,
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger) ILogger logger,
Guid auditCorrelationId)
{ {
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); _inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -54,6 +59,7 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName = instanceName ?? string.Empty; _instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_auditCorrelationId = auditCorrelationId;
} }
// ConnectionString is settable on DbConnection — forward both halves. // ConnectionString is settable on DbConnection — forward both halves.
@@ -92,7 +98,8 @@ internal sealed class AuditingDbConnection : DbConnection
_siteId, _siteId,
_instanceName, _instanceName,
_sourceScript, _sourceScript,
_logger); _logger,
_auditCorrelationId);
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)

View File

@@ -105,6 +105,21 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly ICachedCallTelemetryForwarder? _cachedForwarder; private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary>
/// Audit Log #23: the execution-wide audit correlation id. Every sync
/// trust-boundary audit row emitted by this script execution
/// (<c>ApiCall</c>, <c>DbWrite</c>) is stamped with this id so all the
/// rows from one script run can be correlated together.
/// </summary>
private readonly Guid _auditCorrelationId;
/// <param name="auditCorrelationId">
/// Audit Log #23: the execution-wide audit correlation id. When omitted
/// (tag-change / timer-triggered executions) a fresh id is generated; an
/// inbound caller may supply one to tie the execution to an upstream
/// request. Stamped on the sync <c>ApiCall</c>/<c>DbWrite</c> audit rows
/// this execution emits.
/// </param>
public ScriptRuntimeContext( public ScriptRuntimeContext(
IActorRef instanceActor, IActorRef instanceActor,
IActorRef self, IActorRef self,
@@ -122,7 +137,8 @@ public class ScriptRuntimeContext
string? sourceScript = null, string? sourceScript = null,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null, IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null) ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? auditCorrelationId = null)
{ {
_instanceActor = instanceActor; _instanceActor = instanceActor;
_self = self; _self = self;
@@ -141,6 +157,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter; _auditWriter = auditWriter;
_operationTrackingStore = operationTrackingStore; _operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder; _cachedForwarder = cachedForwarder;
_auditCorrelationId = auditCorrelationId ?? Guid.NewGuid();
} }
/// <summary> /// <summary>
@@ -241,7 +258,7 @@ public class ScriptRuntimeContext
/// ExternalSystem.CachedCall("systemName", "methodName", params) /// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary> /// </summary>
public ExternalSystemHelper ExternalSystem => new( public ExternalSystemHelper ExternalSystem => new(
_externalSystemClient, _instanceName, _logger, _auditWriter, _siteId, _sourceScript, _externalSystemClient, _instanceName, _logger, _auditCorrelationId, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry // Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue. // on every ExternalSystem.CachedCall enqueue.
_cachedForwarder); _cachedForwarder);
@@ -255,6 +272,7 @@ public class ScriptRuntimeContext
_databaseGateway, _databaseGateway,
_instanceName, _instanceName,
_logger, _logger,
_auditCorrelationId,
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so // Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
// Database.Connection(name) returns an auditing decorator that // Database.Connection(name) returns an auditing decorator that
// emits one DbOutbound/DbWrite row per script-initiated // emits one DbOutbound/DbWrite row per script-initiated
@@ -362,6 +380,7 @@ public class ScriptRuntimeContext
private readonly IExternalSystemClient? _client; private readonly IExternalSystemClient? _client;
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Guid _auditCorrelationId;
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
private readonly string _siteId; private readonly string _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
@@ -370,10 +389,18 @@ public class ScriptRuntimeContext
// Internal constructor for tests living in ScadaLink.SiteRuntime.Tests // Internal constructor for tests living in ScadaLink.SiteRuntime.Tests
// (via InternalsVisibleTo). Production sites resolve the helper through // (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem. // ScriptRuntimeContext.ExternalSystem.
//
// Parameter ordering: auditCorrelationId sits immediately after the
// ILogger across all four audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required
// Guid cannot follow the optional provenance params without a
// required-after-optional compile error, so the post-logger slot is the
// one consistent position that compiles cleanly everywhere.
internal ExternalSystemHelper( internal ExternalSystemHelper(
IExternalSystemClient? client, IExternalSystemClient? client,
string instanceName, string instanceName,
ILogger logger, ILogger logger,
Guid auditCorrelationId,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
@@ -382,6 +409,7 @@ public class ScriptRuntimeContext
_client = client; _client = client;
_instanceName = instanceName; _instanceName = instanceName;
_logger = logger; _logger = logger;
_auditCorrelationId = auditCorrelationId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
@@ -420,7 +448,7 @@ public class ScriptRuntimeContext
{ {
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks) var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
* 1000d / Stopwatch.Frequency); * 1000d / Stopwatch.Frequency);
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown); EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters);
} }
} }
@@ -458,7 +486,7 @@ public class ScriptRuntimeContext
// Submitted row even if the immediate-delivery attempt happens to // Submitted row even if the immediate-delivery attempt happens to
// resolve before this method returns. // resolve before this method returns.
await EmitCachedSubmitTelemetryAsync( await EmitCachedSubmitTelemetryAsync(
systemName, methodName, target, trackedId, occurredAtUtc, cancellationToken) systemName, methodName, target, trackedId, occurredAtUtc, parameters, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// Hand off to the existing cached-call path. The TrackedOperationId // Hand off to the existing cached-call path. The TrackedOperationId
@@ -503,7 +531,7 @@ public class ScriptRuntimeContext
if (result is { WasBuffered: false }) if (result is { WasBuffered: false })
{ {
await EmitImmediateTerminalTelemetryAsync( await EmitImmediateTerminalTelemetryAsync(
systemName, methodName, target, trackedId, result, cancellationToken) systemName, methodName, target, trackedId, result, parameters, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@@ -521,6 +549,7 @@ public class ScriptRuntimeContext
string target, string target,
TrackedOperationId trackedId, TrackedOperationId trackedId,
DateTime occurredAtUtc, DateTime occurredAtUtc,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (_cachedForwarder == null) if (_cachedForwarder == null)
@@ -544,6 +573,8 @@ public class ScriptRuntimeContext
SourceScript = _sourceScript, SourceScript = _sourceScript,
Target = target, Target = target,
Status = AuditStatus.Submitted, Status = AuditStatus.Submitted,
// Submit precedes the call — request args only, no response yet.
RequestSummary = SerializeRequest(parameters),
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}, },
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
@@ -599,6 +630,7 @@ public class ScriptRuntimeContext
string target, string target,
TrackedOperationId trackedId, TrackedOperationId trackedId,
ExternalCallResult result, ExternalCallResult result,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (_cachedForwarder == null) if (_cachedForwarder == null)
@@ -653,6 +685,8 @@ public class ScriptRuntimeContext
Status = AuditStatus.Attempted, Status = AuditStatus.Attempted,
HttpStatus = httpStatus, HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage, ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}, },
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
@@ -712,6 +746,8 @@ public class ScriptRuntimeContext
Status = auditTerminalStatus, Status = auditTerminalStatus,
HttpStatus = httpStatus, HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage, ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}, },
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
@@ -762,7 +798,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc, DateTime occurredAtUtc,
int durationMs, int durationMs,
ExternalCallResult? result, ExternalCallResult? result,
Exception? thrown) Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{ {
if (_auditWriter == null) if (_auditWriter == null)
{ {
@@ -772,7 +809,8 @@ public class ScriptRuntimeContext
AuditEvent evt; AuditEvent evt;
try try
{ {
evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown); evt = BuildCallAuditEvent(
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
} }
catch (Exception buildEx) catch (Exception buildEx)
{ {
@@ -828,7 +866,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc, DateTime occurredAtUtc,
int durationMs, int durationMs,
ExternalCallResult? result, ExternalCallResult? result,
Exception? thrown) Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{ {
// Status: Delivered on a Success result; Failed otherwise (the // Status: Delivered on a Success result; Failed otherwise (the
// ExternalSystemClient already maps HTTP non-2xx + transient // ExternalSystemClient already maps HTTP non-2xx + transient
@@ -871,7 +910,9 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall, Kind = AuditKind.ApiCall,
CorrelationId = null, // Audit Log #23: the execution-wide correlation id, so all the
// sync ApiCall/DbWrite rows from one script run share an id.
CorrelationId = _auditCorrelationId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -885,13 +926,41 @@ public class ScriptRuntimeContext
DurationMs = durationMs, DurationMs = durationMs,
ErrorMessage = errorMessage, ErrorMessage = errorMessage,
ErrorDetail = errorDetail, ErrorDetail = errorDetail,
RequestSummary = null, // Payload capture: the request arguments and the response body.
ResponseSummary = null, // The audit writer's payload filter applies the configured size
// cap and header/secret redaction downstream — the emitter just
// hands over the raw values.
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result?.ResponseJson,
PayloadTruncated = false, PayloadTruncated = false,
Extra = null, Extra = null,
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}; };
} }
/// <summary>
/// Serialises the outbound-call argument dictionary into the JSON
/// <c>RequestSummary</c> stamped on <c>ApiOutbound</c> audit rows.
/// Returns <c>null</c> for a null/empty argument set. Serialization
/// failure is swallowed (returns <c>null</c>) — a payload that cannot be
/// summarised must never abort the best-effort audit emission.
/// </summary>
private static string? SerializeRequest(IReadOnlyDictionary<string, object?>? parameters)
{
if (parameters is null || parameters.Count == 0)
{
return null;
}
try
{
return JsonSerializer.Serialize(parameters);
}
catch (Exception)
{
return null;
}
}
} }
/// <summary> /// <summary>
@@ -910,6 +979,7 @@ public class ScriptRuntimeContext
private readonly IDatabaseGateway? _gateway; private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Guid _auditCorrelationId;
private readonly string _siteId; private readonly string _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder; private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -926,10 +996,15 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
// Parameter ordering: auditCorrelationId sits immediately after the
// ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four
// audit-threaded ctors.
internal DatabaseHelper( internal DatabaseHelper(
IDatabaseGateway? gateway, IDatabaseGateway? gateway,
string instanceName, string instanceName,
ILogger logger, ILogger logger,
Guid auditCorrelationId,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
@@ -938,6 +1013,7 @@ public class ScriptRuntimeContext
_gateway = gateway; _gateway = gateway;
_instanceName = instanceName; _instanceName = instanceName;
_logger = logger; _logger = logger;
_auditCorrelationId = auditCorrelationId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
@@ -972,7 +1048,8 @@ public class ScriptRuntimeContext
siteId: _siteId, siteId: _siteId,
instanceName: _instanceName, instanceName: _instanceName,
sourceScript: _sourceScript, sourceScript: _sourceScript,
logger: _logger); logger: _logger,
auditCorrelationId: _auditCorrelationId);
} }
/// <summary> /// <summary>

View File

@@ -150,6 +150,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
client, client,
instanceName: "Plant.Pump42", instanceName: "Plant.Pump42",
NullLogger.Instance, NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer, auditWriter: writer,
siteId: "site-77", siteId: "site-77",
sourceScript: "ScriptActor:Sync", sourceScript: "ScriptActor:Sync",
@@ -193,6 +194,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
client, client,
instanceName: "Plant.Pump42", instanceName: "Plant.Pump42",
NullLogger.Instance, NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer, auditWriter: writer,
siteId: "site-77", siteId: "site-77",
sourceScript: "ScriptActor:Cached", sourceScript: "ScriptActor:Cached",
@@ -243,6 +245,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
gateway, gateway,
instanceName, instanceName,
NullLogger.Instance, NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer, auditWriter: writer,
siteId: "site-77", siteId: "site-77",
sourceScript: "ScriptActor:Db", sourceScript: "ScriptActor:Db",

View File

@@ -157,6 +157,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
gateway, gateway,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer, auditWriter: writer,
siteId: siteId, siteId: siteId,
sourceScript: SourceScript, sourceScript: SourceScript,

View File

@@ -350,6 +350,46 @@ public class AuditWriteMiddlewareTests
Assert.Equal(requestJson, evt.RequestSummary); Assert.Equal(requestJson, evt.RequestSummary);
} }
// ---------------------------------------------------------------------
// Correlation id — Audit Log #23: each inbound row carries a fresh
// per-request correlation id so inbound rows are correlatable.
// ---------------------------------------------------------------------
[Fact]
public async Task InboundRow_CarriesNonNull_CorrelationId()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value);
}
[Fact]
public async Task SeparateRequests_GetDistinct_CorrelationIds()
{
var writer = new RecordingAuditWriter();
var mw = CreateMiddleware(hc =>
{
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(BuildContext());
await mw.InvokeAsync(BuildContext());
Assert.Equal(2, writer.Events.Count);
Assert.NotEqual(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId);
}
[Fact] [Fact]
public async Task DurationMs_IsRecorded() public async Task DurationMs_IsRecorded()
{ {

View File

@@ -47,6 +47,9 @@ public class DatabaseCachedWriteEmissionTests
gateway, gateway,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
// Audit Log #23: execution-wide correlation id. Cached rows keep
// CorrelationId = TrackedOperationId, so any value works here.
Guid.NewGuid(),
siteId: SiteId, siteId: SiteId,
sourceScript: SourceScript, sourceScript: SourceScript,
cachedForwarder: forwarder); cachedForwarder: forwarder);

View File

@@ -48,14 +48,28 @@ public class DatabaseSyncEmissionTests
private const string SourceScript = "ScriptActor:Sync"; private const string SourceScript = "ScriptActor:Sync";
private const string ConnectionName = "machineData"; private const string ConnectionName = "machineData";
/// <summary>
/// Audit Log #23: a fixed execution-wide correlation id used by the
/// default <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
private static readonly Guid TestCorrelationId = Guid.NewGuid();
private static ScriptRuntimeContext.DatabaseHelper CreateHelper( private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway, IDatabaseGateway gateway,
IAuditWriter? auditWriter) IAuditWriter? auditWriter)
=> CreateHelper(gateway, auditWriter, TestCorrelationId);
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter? auditWriter,
Guid correlationId)
{ {
return new ScriptRuntimeContext.DatabaseHelper( return new ScriptRuntimeContext.DatabaseHelper(
gateway, gateway,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
correlationId,
auditWriter: auditWriter, auditWriter: auditWriter,
siteId: SiteId, siteId: SiteId,
sourceScript: SourceScript, sourceScript: SourceScript,
@@ -268,10 +282,34 @@ public class DatabaseSyncEmissionTests
Assert.Equal(SourceScript, evt.SourceScript); Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity. // Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor); Assert.Equal(SourceScript, evt.Actor);
Assert.Null(evt.CorrelationId); // Audit Log #23: the sync DbWrite row now carries the execution-wide
// correlation id the helper was constructed with.
Assert.Equal(TestCorrelationId, evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.EventId); Assert.NotEqual(Guid.Empty, evt.EventId);
} }
[Fact]
public async Task SyncDbWrite_StampsExecutionCorrelationId()
{
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 correlationId = Guid.NewGuid();
var helper = CreateHelper(gateway.Object, writer, correlationId);
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(correlationId, evt.CorrelationId);
}
[Fact] [Fact]
public async Task DurationMs_NonZero() public async Task DurationMs_NonZero()
{ {

View File

@@ -0,0 +1,179 @@
using Akka.Actor;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Tests.Scripts;
/// <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 audit correlation 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.CorrelationId"/>.
/// </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="auditCorrelationId"/> 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? auditCorrelationId = 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,
auditCorrelationId: auditCorrelationId);
}
/// <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 NoCorrelationIdSupplied_SyncCall_StampsFreshNonEmptyCorrelationId()
{
// No auditCorrelationId 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.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value);
}
[Fact]
public async Task SameContext_ApiCallAndDbWrite_ShareTheSameCorrelationId()
{
// The execution-wide contract: an ExternalSystem.Call AND a sync
// Database write performed through ONE ScriptRuntimeContext must both
// carry the same execution correlation id, 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.CorrelationId);
Assert.NotEqual(Guid.Empty, apiRow.CorrelationId!.Value);
// The ApiCall row and the DbWrite row, emitted by two different helpers
// resolved off one context, carry the identical execution correlation id.
Assert.Equal(apiRow.CorrelationId, dbRow.CorrelationId);
}
}

View File

@@ -49,6 +49,9 @@ public class ExternalSystemCachedCallEmissionTests
client, client,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
// Audit Log #23: execution-wide correlation id. Cached rows keep
// CorrelationId = TrackedOperationId, so any value works here.
Guid.NewGuid(),
auditWriter: null, auditWriter: null,
siteId: SiteId, siteId: SiteId,
sourceScript: SourceScript, sourceScript: SourceScript,
@@ -94,6 +97,42 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Null(packet.Operational.TerminalAtUtc); 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?>()))
.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] [Fact]
public async Task CachedCall_ReturnsTrackedOperationId() public async Task CachedCall_ReturnsTrackedOperationId()
{ {

View File

@@ -45,14 +45,28 @@ public class ExternalSystemCallAuditEmissionTests
private const string InstanceName = "Plant.Pump42"; private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:CheckPressure"; private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary>
/// Audit Log #23: a fixed execution-wide correlation id used by the
/// default <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
private static readonly Guid TestCorrelationId = Guid.NewGuid();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client, IExternalSystemClient client,
IAuditWriter? auditWriter) IAuditWriter? auditWriter)
=> CreateHelper(client, auditWriter, TestCorrelationId);
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter,
Guid correlationId)
{ {
return new ScriptRuntimeContext.ExternalSystemHelper( return new ScriptRuntimeContext.ExternalSystemHelper(
client, client,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
correlationId,
auditWriter, auditWriter,
SiteId, SiteId,
SourceScript); SourceScript);
@@ -81,6 +95,29 @@ public class ExternalSystemCallAuditEmissionTests
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind); Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId); Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.False(evt.PayloadTruncated); 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] [Fact]
@@ -188,7 +225,47 @@ public class ExternalSystemCallAuditEmissionTests
Assert.Equal(SourceScript, evt.SourceScript); Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity. // Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor); Assert.Equal(SourceScript, evt.Actor);
Assert.Null(evt.CorrelationId); // Audit Log #23: the sync ApiCall row now carries the execution-wide
// correlation id the helper was constructed with.
Assert.Equal(TestCorrelationId, evt.CorrelationId);
}
[Fact]
public async Task Call_SyncApiCall_StampsExecutionCorrelationId()
{
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 correlationId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, correlationId);
await helper.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.Equal(correlationId, evt.CorrelationId);
}
[Fact]
public async Task Call_TwoCallsOnSameHelper_ShareTheSameCorrelationId()
{
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 correlationId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, correlationId);
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 id.
Assert.Equal(correlationId, writer.Events[0].CorrelationId);
Assert.Equal(correlationId, writer.Events[1].CorrelationId);
Assert.Equal(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId);
} }
[Fact] [Fact]