Compare commits
3 Commits
feature/au
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aadb1fd72a | ||
|
|
8243f61e96 | ||
|
|
53508c79b2 |
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -882,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,
|
||||||
@@ -949,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;
|
||||||
@@ -965,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,
|
||||||
@@ -977,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;
|
||||||
@@ -1011,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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -211,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]
|
||||||
|
|||||||
Reference in New Issue
Block a user