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"; /// /// Audit Log #23: a fixed execution-wide correlation id used by the /// default /// overload so assertions can compare against a known value. /// 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); } [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); // No call arguments → null request summary; the response body is captured. Assert.Null(evt.RequestSummary); Assert.Equal("{}", evt.ResponseSummary); } [Fact] public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow() { var client = new Mock(); client .Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null)); var writer = new CapturingAuditWriter(); var helper = CreateHelper(client.Object, writer); var args = new Dictionary { ["city"] = "Dublin" }; await helper.Call("Weather", "GetCurrent", args); var evt = Assert.Single(writer.Events); // RequestSummary is the serialized argument dictionary; ResponseSummary // is the verbatim response body. (Cap + redaction are the writer's job.) Assert.Equal("{\"city\":\"Dublin\"}", evt.RequestSummary); Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary); } [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); // 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(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .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(); client .Setup(c => c.CallAsync(It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) .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] 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"); } }