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"); } }