6 Commits

Author SHA1 Message Date
Joseph Doherty
849a011400 fix(auditlog): capture request/response payloads on outbound API audit rows
The outbound ApiCall emitter hard-coded RequestSummary/ResponseSummary to null,
so audited API calls carried no inputs/outputs — contrary to the Audit Log
payload-capture spec. Thread the call arguments into the sync ApiCall emitter
and the cached immediate-completion path (CachedSubmit / ApiCallCached /
CachedResolve), and stamp the response body from ExternalCallResult.ResponseJson.
The writer's payload filter still applies the size cap + redaction downstream.

The S&F retry-loop cached rows are unchanged — request data is not threaded
through the store-and-forward buffer (same boundary as SourceScript).
2026-05-21 10:17:42 -04:00
Joseph Doherty
405de525ca Merge branch 'feature/audit-channel-single-select': single-select Channel filter
The Audit Log Channel filter becomes a single-select — Kind narrows to the
chosen channel, so multi-channel selection is incoherent. Kind, Status and Site
stay multi-select.
2026-05-21 10:03:08 -04:00
Joseph Doherty
77922abb33 feat(centralui): single-select Channel filter on the Audit Log page
Channel narrows the Kind options to the chosen channel, so filtering by more
than one channel at a time is incoherent. Replace the Channel multi-select
dropdown with a native single-select (matching the Time range control); Kind,
Status and Site stay multi-select. The query filter contract is unchanged —
Channels just carries 0 or 1 value.
2026-05-21 10:02:17 -04:00
Joseph Doherty
5f544bfe1e Merge branch 'feature/audit-actor-identity': populate audit Actor column
Stamp the audit Actor column on outbound rows (calling script identity) and
central-dispatch rows (system identity); the original emission code left it
null on every channel except Inbound API.
2026-05-21 09:56:43 -04:00
Joseph Doherty
aaa6df24cf Merge branch 'feature/audit-filter-dropdowns': compact audit filter dropdowns
Replace the four stacked chip-button groups on the Audit Log filter bar with a
reusable MultiSelectDropdown component, collapsing the bar from four full-width
chip blocks to four inline dropdowns in one wrapped filter row.
2026-05-21 09:56:43 -04:00
Joseph Doherty
ae7329034f fix(auditlog): populate the Actor column on outbound and central rows
Per the Audit Log Actor-column spec, Actor should carry the calling script
identity on outbound rows (ApiCall, DbWrite, NotifySend) and a system identity
on central-dispatch rows (NotifyDeliver). The original emission code hard-coded
Actor=null at all four sites, so only Inbound API rows (API key name) ever
filled it. Stamp the script identity and 'system' respectively.
2026-05-21 09:50:55 -04:00
12 changed files with 205 additions and 58 deletions

View File

@@ -6,20 +6,23 @@
<div class="card mb-3" data-test="audit-filter-bar">
<div class="card-body py-2">
@* All filters sit in one wrapped row. The four multi-value dimensions
(Channel / Kind / Status / Site) use compact MultiSelectDropdown
controls so the bar stays a row or two tall instead of four stacked
blocks of chip buttons. *@
@* All filters sit in one wrapped row. Kind / Status / Site use compact
MultiSelectDropdown controls; Channel is a single-select because the
Kind options narrow to the chosen channel — so the bar stays a row or
two tall instead of four stacked blocks of chip buttons. *@
<div class="row g-2 align-items-end">
@* Single-select: one channel at a time, so the Kind options below
narrow cleanly to that channel. "All channels" clears it. *@
<div class="col-auto" data-test="filter-channel">
<label class="form-label small mb-1">Channel</label>
<div>
<MultiSelectDropdown TValue="AuditChannel"
Items="_channels"
Selected="_model.Channels"
SelectionChanged="OnChannelsChanged"
DataTest="filter-channel-ms" />
</div>
<label class="form-label small mb-1" for="audit-channel">Channel</label>
<select id="audit-channel" data-test="filter-channel-select"
class="form-select form-select-sm" @bind="SelectedChannel">
<option value="">All channels</option>
@foreach (var channel in _channels)
{
<option value="@channel">@channel</option>
}
</select>
</div>
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@

View File

@@ -8,13 +8,14 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls
/// — Channel / Kind / Status / Site as compact
/// — Channel as a single-select (one channel at a time, so the Kind options
/// narrow to it cleanly); Kind / Status / Site as compact
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls, plus the time range, free-text searches and the Errors-only
/// controls; plus the time range, free-text searches and the Errors-only
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The four
/// multi-value dimensions map straight through to the filter's list fields;
/// see <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
/// dimensions map through to the filter's list fields; see
/// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
/// </summary>
public partial class AuditFilterBar
{
@@ -82,8 +83,29 @@ public partial class AuditFilterBar
}
/// <summary>
/// Runs after a Channel selection changes. Drops any Kind selections that fell
/// outside the new visible set — without this, removing a channel could leave
/// Single-select Channel binding for the filter bar. The Audit Log filters one
/// channel at a time so the Kind options narrow cleanly to it; the model still
/// stores the selection as a set (0 or 1 entry) so <see cref="AuditQueryModel.ToFilter"/>
/// and <see cref="AuditQueryModel.VisibleKinds"/> are unchanged. <c>null</c> = all channels.
/// </summary>
private AuditChannel? SelectedChannel
{
get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
set
{
_model.Channels.Clear();
if (value is { } channel)
{
_model.Channels.Add(channel);
}
OnChannelsChanged();
}
}
/// <summary>
/// Runs after the Channel selection changes. Drops any Kind selections that fell
/// outside the new visible set — without this, changing the channel could leave
/// stale Kind selections that no longer match any visible option.
/// </summary>
private void OnChannelsChanged()

View File

@@ -30,6 +30,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
private const int FallbackMaxRetries = 10;
private static readonly TimeSpan FallbackRetryDelay = TimeSpan.FromMinutes(1);
/// <summary>
/// Audit <c>Actor</c> stamped on central-dispatch (<c>NotifyDeliver</c>) rows.
/// The Actor-column spec assigns central-originated audit rows a system
/// identity — there is no per-call authenticated user at dispatch time.
/// </summary>
private const string SystemActor = "system";
private readonly IServiceProvider _serviceProvider;
private readonly NotificationOutboxOptions _options;
private readonly ICentralAuditWriter _auditWriter;
@@ -500,9 +507,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifyDeliver,
CorrelationId = correlationId,
// Central dispatch — no authenticated actor (the originating
// script's identity is captured on the upstream NotifySend row).
Actor = null,
// Central dispatch — a system identity per the Actor-column spec;
// there is no per-call authenticated user here. The originating
// script is still captured on SourceScript (and on the upstream
// NotifySend row).
Actor = SystemActor,
SourceSiteId = notification.SourceSiteId,
SourceInstanceId = notification.SourceInstanceId,
SourceScript = notification.SourceScript,

View File

@@ -430,7 +430,10 @@ internal sealed class AuditingDbCommand : DbCommand
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Actor = null,
// Outbound channel: per the Audit Log Actor-column spec the actor is
// the calling script. Null when no single script owns the call
// (e.g. a shared script running inline).
Actor = _sourceScript,
Target = target,
Status = status,
HttpStatus = null,

View File

@@ -420,7 +420,7 @@ public class ScriptRuntimeContext
{
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
* 1000d / Stopwatch.Frequency);
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown);
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters);
}
}
@@ -458,7 +458,7 @@ public class ScriptRuntimeContext
// Submitted row even if the immediate-delivery attempt happens to
// resolve before this method returns.
await EmitCachedSubmitTelemetryAsync(
systemName, methodName, target, trackedId, occurredAtUtc, cancellationToken)
systemName, methodName, target, trackedId, occurredAtUtc, parameters, cancellationToken)
.ConfigureAwait(false);
// Hand off to the existing cached-call path. The TrackedOperationId
@@ -503,7 +503,7 @@ public class ScriptRuntimeContext
if (result is { WasBuffered: false })
{
await EmitImmediateTerminalTelemetryAsync(
systemName, methodName, target, trackedId, result, cancellationToken)
systemName, methodName, target, trackedId, result, parameters, cancellationToken)
.ConfigureAwait(false);
}
@@ -521,6 +521,7 @@ public class ScriptRuntimeContext
string target,
TrackedOperationId trackedId,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken)
{
if (_cachedForwarder == null)
@@ -544,6 +545,8 @@ public class ScriptRuntimeContext
SourceScript = _sourceScript,
Target = target,
Status = AuditStatus.Submitted,
// Submit precedes the call — request args only, no response yet.
RequestSummary = SerializeRequest(parameters),
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
@@ -599,6 +602,7 @@ public class ScriptRuntimeContext
string target,
TrackedOperationId trackedId,
ExternalCallResult result,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken)
{
if (_cachedForwarder == null)
@@ -653,6 +657,8 @@ public class ScriptRuntimeContext
Status = AuditStatus.Attempted,
HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
@@ -712,6 +718,8 @@ public class ScriptRuntimeContext
Status = auditTerminalStatus,
HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
@@ -762,7 +770,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc,
int durationMs,
ExternalCallResult? result,
Exception? thrown)
Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{
if (_auditWriter == null)
{
@@ -772,7 +781,8 @@ public class ScriptRuntimeContext
AuditEvent evt;
try
{
evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown);
evt = BuildCallAuditEvent(
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
}
catch (Exception buildEx)
{
@@ -828,7 +838,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc,
int durationMs,
ExternalCallResult? result,
Exception? thrown)
Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{
// Status: Delivered on a Success result; Failed otherwise (the
// ExternalSystemClient already maps HTTP non-2xx + transient
@@ -875,20 +886,51 @@ public class ScriptRuntimeContext
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Actor = null,
// Outbound channel: per the Audit Log Actor-column spec the actor
// is the calling script. Null when no single script owns the call
// (e.g. a shared script running inline).
Actor = _sourceScript,
Target = $"{systemName}.{methodName}",
Status = status,
HttpStatus = httpStatus,
DurationMs = durationMs,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = null,
ResponseSummary = null,
// Payload capture: the request arguments and the response body.
// The audit writer's payload filter applies the configured size
// cap and header/secret redaction downstream — the emitter just
// hands over the raw values.
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result?.ResponseJson,
PayloadTruncated = false,
Extra = null,
ForwardState = AuditForwardState.Pending,
};
}
/// <summary>
/// Serialises the outbound-call argument dictionary into the JSON
/// <c>RequestSummary</c> stamped on <c>ApiOutbound</c> audit rows.
/// Returns <c>null</c> for a null/empty argument set. Serialization
/// failure is swallowed (returns <c>null</c>) — a payload that cannot be
/// summarised must never abort the best-effort audit emission.
/// </summary>
private static string? SerializeRequest(IReadOnlyDictionary<string, object?>? parameters)
{
if (parameters is null || parameters.Count == 0)
{
return null;
}
try
{
return JsonSerializer.Serialize(parameters);
}
catch (Exception)
{
return null;
}
}
}
/// <summary>
@@ -1355,7 +1397,10 @@ public class ScriptRuntimeContext
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Actor = null,
// Outbound channel: per the Audit Log Actor-column spec the
// actor is the calling script. Null when no single script
// owns the call (e.g. a shared script running inline).
Actor = _sourceScript,
Target = _listName,
Status = AuditStatus.Submitted,
HttpStatus = null,

View File

@@ -91,9 +91,8 @@ public class AuditLogPageTests
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Pre-Apply, both rows are absent because the grid stays empty until
// the user filters. Open the Channel dropdown, tick ApiOutbound, Apply.
await page.Locator("[data-test='filter-channel-ms-toggle']").ClickAsync();
await page.Locator("[data-test='filter-channel-ms-opt-ApiOutbound']").ClickAsync();
// the user filters. Pick the ApiOutbound channel, then Apply.
await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

View File

@@ -13,10 +13,11 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
///
/// The bar carries the 10 spec filter elements plus the Errors-only toggle. The
/// Channel / Kind / Status / Site dimensions are rendered as
/// The bar carries the 10 spec filter elements plus the Errors-only toggle.
/// Channel is a single-select <c>&lt;select data-test="filter-channel-select"&gt;</c>;
/// Kind / Status / Site are
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls; each option is a checkbox tagged
/// controls whose options are checkboxes tagged
/// <c>data-test="filter-&lt;dim&gt;-ms-opt-&lt;value&gt;"</c>. Tests pin:
/// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with
/// the selected values; (3) the Channel→Kind narrowing map drives Kind option
@@ -75,8 +76,8 @@ public class AuditFilterBarTests : BunitContext
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Drive UI: tick a Channel option, type in the Target search box, click Apply.
cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true);
// Drive UI: pick a Channel, type in the Target search box, click Apply.
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
cut.Find("[data-test=\"filter-apply\"]").Click();
@@ -86,23 +87,25 @@ public class AuditFilterBarTests : BunitContext
}
[Fact]
public void Apply_WithMultipleChannelChips_PassesAllSelectedChannels()
public void ChangingChannel_ReplacesTheSelection_SingleSelect()
{
// Task 9: ToFilter no longer collapses the chip multi-select — every
// selected channel chip reaches the filter's Channels list.
// Channel is single-select: picking a second channel replaces the first
// rather than adding to it (the page filters one channel at a time).
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true);
cut.Find("[data-test=\"filter-channel-ms-opt-Notification\"]").Change(true);
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.NotNull(captured!.Channels);
Assert.Equal(2, captured.Channels!.Count);
Assert.Contains(AuditChannel.ApiOutbound, captured.Channels);
Assert.Contains(AuditChannel.Notification, captured.Channels);
Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);
// Selecting "All channels" clears the channel filter entirely.
cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.Channels);
}
[Fact]
@@ -116,8 +119,8 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
}
// Select only ApiOutbound; Kind options outside the channel-kind map drop out.
cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true);
// Select ApiOutbound; Kind options outside the channel-kind map drop out.
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
foreach (var kind in apiKinds)

View File

@@ -155,8 +155,8 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
Assert.Equal("site-alpha", evt.SourceSiteId);
Assert.Equal("instance-42", evt.SourceInstanceId);
Assert.Equal("AlarmScript", evt.SourceScript);
// Central dispatch: actor is null (no authenticated end-user).
Assert.Null(evt.Actor);
// Central dispatch: Actor is the system identity (no per-call user).
Assert.Equal("system", evt.Actor);
// Successful attempt: no error message.
Assert.Null(evt.ErrorMessage);
});

View File

@@ -266,7 +266,8 @@ public class DatabaseSyncEmissionTests
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
Assert.Equal(SourceScript, evt.SourceScript);
Assert.Null(evt.Actor);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
Assert.Null(evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.EventId);
}

View File

@@ -94,6 +94,42 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var args = new Dictionary<string, object?> { ["orderId"] = 42 };
await helper.CachedCall("ERP", "GetOrder", args);
// Immediate completion (WasBuffered=false) emits Submit, Attempted, Resolve.
Assert.Equal(3, forwarder.Telemetry.Count);
var submit = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedSubmit);
var attempted = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.ApiCallCached);
var resolve = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedResolve);
// Every row carries the request args; the two post-call rows also carry
// the response body (Submit precedes the call, so it has no response).
Assert.Equal("{\"orderId\":42}", submit.Audit.RequestSummary);
Assert.Null(submit.Audit.ResponseSummary);
Assert.Equal("{\"orderId\":42}", attempted.Audit.RequestSummary);
Assert.Equal("{\"ok\":true}", attempted.Audit.ResponseSummary);
Assert.Equal("{\"orderId\":42}", resolve.Audit.RequestSummary);
Assert.Equal("{\"ok\":true}", resolve.Audit.ResponseSummary);
}
[Fact]
public async Task CachedCall_ReturnsTrackedOperationId()
{

View File

@@ -81,6 +81,29 @@ public class ExternalSystemCallAuditEmissionTests
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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var args = new Dictionary<string, object?> { ["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]
@@ -186,7 +209,8 @@ public class ExternalSystemCallAuditEmissionTests
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
Assert.Equal(SourceScript, evt.SourceScript);
Assert.Null(evt.Actor);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
Assert.Null(evt.CorrelationId);
}

View File

@@ -127,7 +127,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
Assert.Null(evt.HttpStatus);
Assert.Null(evt.ErrorMessage);
Assert.Null(evt.ErrorDetail);
Assert.Null(evt.Actor);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
}
[Fact]
@@ -199,7 +200,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
Assert.Equal(SourceScript, evt.SourceScript);
Assert.Null(evt.Actor);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
}
[Fact]