using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Enums; using ScadaLink.SiteRuntime.Scripts; using ScadaLink.StoreAndForward; namespace ScadaLink.SiteRuntime.Tests.Scripts; /// /// Audit Log #23 — M4 Bundle C (Task C1): every script-initiated /// Notify.To("list").Send(...) emits exactly one /// Notification/NotifySend audit event via the wrapper inside /// . The audit emission is /// best-effort: a thrown must never /// abort the script's Send — the original NotificationId must /// still flow back to the caller and the underlying S&F enqueue must still /// have happened. /// public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable { /// /// In-memory that records every event passed to /// . Optionally configurable to throw, simulating a /// catastrophic audit-writer failure that the wrapper must swallow per /// alog.md §7. /// 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-7"; private const string InstanceName = "Plant.Pump3"; private const string SourceScript = "ScriptActor:CheckPressure"; private const string ListName = "Operators"; private const string Subject = "Pump alarm"; private const string Body = "Pump 3 tripped"; /// /// Audit Log #23: a fixed per-execution id so the NotifySend test can /// assert against a known value. /// private static readonly Guid TestExecutionId = Guid.NewGuid(); private readonly SqliteConnection _keepAlive; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _saf; public NotifySendAuditEmissionTests() { var dbName = $"NotifySendAudit_{Guid.NewGuid():N}"; var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; _keepAlive = new SqliteConnection(connStr); _keepAlive.Open(); _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); var options = new StoreAndForwardOptions { DefaultRetryInterval = TimeSpan.Zero, DefaultMaxRetries = 3, RetryTimerInterval = TimeSpan.FromMinutes(10) }; _saf = new StoreAndForwardService(_storage, options, NullLogger.Instance); } public async Task InitializeAsync() => await _storage.InitializeAsync(); public Task DisposeAsync() => Task.CompletedTask; protected override void Dispose(bool disposing) { if (disposing) { _keepAlive.Dispose(); } base.Dispose(disposing); } private ScriptRuntimeContext.NotifyHelper CreateHelper( IAuditWriter? auditWriter, string? sourceScript = SourceScript) { // siteCommunicationActor is unused by Send — pass a probe so the helper // is fully constructed. var probe = CreateTestProbe(); return new ScriptRuntimeContext.NotifyHelper( _saf, probe.Ref, SiteId, InstanceName, sourceScript, TimeSpan.FromSeconds(3), NullLogger.Instance, TestExecutionId, auditWriter); } [Fact] public async Task Send_Success_EmitsOneEvent_KindNotifySend_StatusSubmitted() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); var notificationId = await notify.To(ListName).Send(Subject, Body); Assert.False(string.IsNullOrEmpty(notificationId)); Assert.Single(writer.Events); var evt = writer.Events[0]; Assert.Equal(AuditChannel.Notification, evt.Channel); Assert.Equal(AuditKind.NotifySend, evt.Kind); Assert.Equal(AuditStatus.Submitted, evt.Status); Assert.Equal(AuditForwardState.Pending, evt.ForwardState); Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind); Assert.NotEqual(Guid.Empty, evt.EventId); Assert.False(evt.PayloadTruncated); Assert.Null(evt.DurationMs); Assert.Null(evt.HttpStatus); Assert.Null(evt.ErrorMessage); Assert.Null(evt.ErrorDetail); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); } [Fact] public async Task Send_PopulatesTarget_AsListName() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); await notify.To(ListName).Send(Subject, Body); var evt = writer.Events[0]; Assert.Equal(ListName, evt.Target); } [Fact] public async Task Send_PopulatesRequestSummary_AsSubjectBodyJson() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); await notify.To(ListName).Send(Subject, Body); var evt = writer.Events[0]; Assert.NotNull(evt.RequestSummary); // Round-trip the JSON to assert the exact shape, not raw text — the // contract is "JSON of {subject, body}", which downstream redaction // (M5) can reshape; M4 captures verbatim. using var doc = JsonDocument.Parse(evt.RequestSummary!); var root = doc.RootElement; Assert.Equal(JsonValueKind.Object, root.ValueKind); Assert.Equal(Subject, root.GetProperty("subject").GetString()); Assert.Equal(Body, root.GetProperty("body").GetString()); } [Fact] public async Task Send_AuditWriter_Throws_OriginalSendStillReturns() { var writer = new CapturingAuditWriter { ThrowOnWrite = new InvalidOperationException("audit writer down") }; var notify = CreateHelper(writer); // The Send call must NOT bubble the audit-writer failure: the script // contract is that the notification is buffered and the id is returned // even when the audit pipeline is sick. var notificationId = await notify.To(ListName).Send(Subject, Body); Assert.False(string.IsNullOrEmpty(notificationId)); // And the underlying S&F enqueue must still have happened — audit is // purely additive, never aborts the user-facing action. var buffered = await _saf.GetMessageByIdAsync(notificationId); Assert.NotNull(buffered); Assert.Equal(notificationId, buffered!.Id); Assert.Empty(writer.Events); } [Fact] public async Task Send_Provenance_PopulatedFromContext() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); await notify.To(ListName).Send(Subject, Body); var evt = writer.Events[0]; 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); } [Fact] public async Task Send_NotificationIdParsed_AsCorrelationId() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); var notificationId = await notify.To(ListName).Send(Subject, Body); // NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char // hex form, which Guid.TryParse accepts. The audit row's CorrelationId // must round-trip back to the same Guid value (the per-operation // lifecycle id). ExecutionId carries the per-execution id instead. Assert.True(Guid.TryParse(notificationId, out var expected), $"NotificationId '{notificationId}' should be a parseable Guid"); var evt = writer.Events[0]; Assert.NotNull(evt.CorrelationId); Assert.Equal(expected, evt.CorrelationId); Assert.Equal(TestExecutionId, evt.ExecutionId); } [Fact] public async Task Send_WithoutAuditWriter_StillReturnsNotificationId_AndEnqueues() { // Audit is opt-in (mirrors M2 Bundle F behaviour): a null writer must // degrade to a no-op audit path so tests / minimal hosts that don't // wire AddAuditLog still work. var notify = CreateHelper(auditWriter: null); var notificationId = await notify.To(ListName).Send(Subject, Body); Assert.False(string.IsNullOrEmpty(notificationId)); var buffered = await _saf.GetMessageByIdAsync(notificationId); Assert.NotNull(buffered); } }