Wraps IExternalSystemClient.CallAsync inside ScriptRuntimeContext's
ExternalSystemHelper so every script-initiated ExternalSystem.Call
produces exactly one ApiOutbound/ApiCall AuditEvent via IAuditWriter.
- Captures duration with Stopwatch.GetTimestamp() around the call.
- Builds the audit event with full provenance (SiteId, InstanceId,
SourceScript) and a fresh EventId; ForwardState=Pending.
- Maps Success → AuditStatus.Delivered, Failure (or thrown) → Failed;
parses HTTP {code} out of the ExternalSystemClient's error message
to populate HttpStatus.
- Audit emission is fully best-effort: event-build failures, sync
WriteAsync throws, AND async WriteAsync faults are all logged at
Warning and swallowed so the script's call path is never aborted
by an audit-write failure (alog.md §7).
- Original ExternalCallResult or original exception flows back to the
caller unchanged.
ScriptExecutionActor resolves IAuditWriter from DI and threads it
into ScriptRuntimeContext alongside the existing site identity.
Adds ExternalSystemCallAuditEmissionTests covering: success →
Delivered, HTTP 500 → Failed+httpStatus, HTTP 400 → Failed+httpStatus,
client-thrown network exception → Failed with original exception
re-thrown, audit-writer throw → original result returned, provenance
populated from context, DurationMs recorded.
Refs Audit Log #23 M2 Bundle F.
215 lines
8.4 KiB
C#
215 lines
8.4 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.SiteRuntime.Scripts;
|
|
|
|
namespace ScadaLink.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";
|
|
|
|
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
|
|
IExternalSystemClient client,
|
|
IAuditWriter? auditWriter)
|
|
{
|
|
return new ScriptRuntimeContext.ExternalSystemHelper(
|
|
client,
|
|
InstanceName,
|
|
NullLogger.Instance,
|
|
auditWriter,
|
|
SiteId,
|
|
SourceScript);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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);
|
|
Assert.Null(evt.Actor);
|
|
Assert.Null(evt.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");
|
|
}
|
|
}
|