feat(auditlog): per-script-execution correlation id on sync audit rows

This commit is contained in:
Joseph Doherty
2026-05-21 13:46:34 -04:00
parent 53508c79b2
commit 8243f61e96
11 changed files with 188 additions and 6 deletions

View File

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

View File

@@ -48,14 +48,28 @@ public class DatabaseSyncEmissionTests
private const string SourceScript = "ScriptActor:Sync";
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(
IDatabaseGateway gateway,
IAuditWriter? auditWriter)
=> CreateHelper(gateway, auditWriter, TestCorrelationId);
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter? auditWriter,
Guid correlationId)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
correlationId,
auditWriter: auditWriter,
siteId: SiteId,
sourceScript: SourceScript,
@@ -268,10 +282,34 @@ public class DatabaseSyncEmissionTests
Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity.
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);
}
[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]
public async Task DurationMs_NonZero()
{

View File

@@ -49,6 +49,9 @@ public class ExternalSystemCachedCallEmissionTests
client,
InstanceName,
NullLogger.Instance,
// Audit Log #23: execution-wide correlation id. Cached rows keep
// CorrelationId = TrackedOperationId, so any value works here.
Guid.NewGuid(),
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,

View File

@@ -45,14 +45,28 @@ public class ExternalSystemCallAuditEmissionTests
private const string InstanceName = "Plant.Pump42";
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(
IExternalSystemClient client,
IAuditWriter? auditWriter)
=> CreateHelper(client, auditWriter, TestCorrelationId);
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter,
Guid correlationId)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
InstanceName,
NullLogger.Instance,
correlationId,
auditWriter,
SiteId,
SourceScript);
@@ -211,7 +225,47 @@ public class ExternalSystemCallAuditEmissionTests
Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity.
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]