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;
///
/// Audit Log #23 — M2 Bundle F (Task F1): every script-initiated
/// ExternalSystem.Call emits exactly one ApiOutbound/ApiCall
/// audit event via the wrapper inside
/// . The audit emission
/// is best-effort: a thrown must never
/// abort the script's call, and the original
/// (or original exception) must surface to the caller unchanged.
///
public class ExternalSystemCallAuditEmissionTests
{
///
/// In-memory that records every event passed to
/// . Optionally configurable to throw, simulating a
/// catastrophic audit-writer failure that the wrapper must swallow.
///
private sealed class CapturingAuditWriter : IAuditWriter
{
public List 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();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
.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();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
.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();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
.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();
var networkEx = new HttpRequestException("network down");
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
.ThrowsAsync(networkEx);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var thrown = await Assert.ThrowsAsync(() => 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();
var expected = new ExternalCallResult(true, "{\"v\":1}", null);
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
.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();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(true, null, null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var beforeId = Guid.NewGuid();
await helper.Call("ERP", "GetOrder");
var evt = writer.Events[0];
Assert.NotEqual(beforeId, evt.EventId);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
Assert.Null(evt.CorrelationId);
}
[Fact]
public async Task DurationMs_Recorded_NonZero()
{
var client = new Mock();
client
.Setup(c => c.CallAsync("ERP", "Slow", It.IsAny?>(), It.IsAny()))
.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");
}
}