11 Commits

Author SHA1 Message Date
Joseph Doherty
aadb1fd72a refactor(auditlog): rename audit correlation field, add cross-helper tests 2026-05-21 13:57:17 -04:00
Joseph Doherty
8243f61e96 feat(auditlog): per-script-execution correlation id on sync audit rows 2026-05-21 13:46:34 -04:00
Joseph Doherty
53508c79b2 Merge branch 'feature/audit-apicall-payloads': capture API-call payloads
Outbound API audit rows now carry the request arguments and response body
(sync ApiCall + cached immediate-completion path); the emitter previously
hard-coded both summary fields to null.
2026-05-21 10:17:50 -04:00
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
Joseph Doherty
e36f0bf9c8 feat(centralui): compact multi-select dropdowns for the audit filter bar
Replace the four stacked chip-button groups (Channel, Kind, Status, Site) on
the Audit Log filter bar with a reusable MultiSelectDropdown component, so the
bar collapses from four full-width chip blocks to four inline dropdowns sharing
one wrapped filter row. Bootstrap dropdown + checkbox menu (data-bs-auto-close
=outside); no third-party UI libraries.
2026-05-21 09:36:36 -04:00
Joseph Doherty
a3eb659b75 Merge branch 'feature/audit-log-followups': Audit Log #23 deferred follow-ups
Implements the five deferred follow-ups from the Audit Log #23 roadmap:
- Real ClusterClient-based site->central audit push (replaces NoOpSiteStreamAuditClient)
- Consolidated the duplicated AuditEvent/SiteCall DTO mappers
- Site Calls UI page + read-side backend + central->site Retry/Discard relay + Health KPI tiles
- Multi-value AuditLogQueryFilter end-to-end (repository, ManagementService, CLI, Central UI)
- Audit results grid column resize/reorder UX

Full solution build clean; full test suite green including Playwright 60/60.
2026-05-21 09:27:52 -04:00
22 changed files with 850 additions and 179 deletions

View File

@@ -6,78 +6,58 @@
<div class="card mb-3" data-test="audit-filter-bar">
<div class="card-body py-2">
@* Channel chip multi-select. *@
<div class="mb-2" data-test="filter-channel">
<label class="form-label small mb-1">Channel</label>
<div>
@foreach (var channel in Enum.GetValues<AuditChannel>())
@* 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" 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)
{
var selected = _model.Channels.Contains(channel);
<button type="button" data-test="chip-channel-@channel"
class="@ChipClass(selected)"
@onclick="() => ToggleChannel(channel)">
@channel
</button>
<option value="@channel">@channel</option>
}
</div>
</select>
</div>
@* Kind chip multi-select — narrowed by Channel selection. *@
<div class="mb-2" data-test="filter-kind">
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@
<div class="col-auto" data-test="filter-kind">
<label class="form-label small mb-1">Kind</label>
<div>
@foreach (var kind in _model.VisibleKinds())
{
var selected = _model.Kinds.Contains(kind);
<button type="button" data-test="chip-kind-@kind"
class="@ChipClass(selected)"
@onclick="() => ToggleKind(kind)">
@kind
</button>
}
<MultiSelectDropdown TValue="AuditKind"
Items="_model.VisibleKinds()"
Selected="_model.Kinds"
DataTest="filter-kind-ms" />
</div>
</div>
@* Status chip multi-select. *@
<div class="mb-2" data-test="filter-status">
<div class="col-auto" data-test="filter-status">
<label class="form-label small mb-1">Status</label>
<div>
@foreach (var status in Enum.GetValues<AuditStatus>())
{
var selected = _model.Statuses.Contains(status);
<button type="button" data-test="chip-status-@status"
class="@ChipClass(selected)"
@onclick="() => ToggleStatus(status)">
@status
</button>
}
<MultiSelectDropdown TValue="AuditStatus"
Items="_statuses"
Selected="_model.Statuses"
DataTest="filter-status-ms" />
</div>
</div>
@* Site chip multi-select — populated from ISiteRepository. *@
<div class="mb-2" data-test="filter-site">
<div class="col-auto" data-test="filter-site">
<label class="form-label small mb-1">Site</label>
<div>
@if (_sites.Count == 0)
{
<span class="text-muted small">No sites available.</span>
}
else
{
@foreach (var site in _sites)
{
var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier);
<button type="button" data-test="chip-site-@site.SiteIdentifier"
class="@ChipClass(selected)"
@onclick="() => ToggleSite(site.SiteIdentifier)">
@site.Name
</button>
}
}
<MultiSelectDropdown TValue="string"
Items="_siteIds"
Selected="_model.SiteIdentifiers"
Display="SiteName"
EmptyText="No sites available"
DataTest="filter-site-ms" />
</div>
</div>
<div class="row g-2 align-items-end">
<div class="col-auto" data-test="filter-time-range">
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
<select id="audit-time-range" class="form-select form-select-sm"

View File

@@ -7,19 +7,32 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
/// <see cref="AuditQueryModel"/> binding state, renders the 10 filter elements
/// plus the Errors-only toggle, and publishes a collapsed
/// <see cref="AuditLogQueryFilter"/> via <see cref="OnFilterChanged"/> when the
/// user clicks Apply. See <see cref="AuditQueryModel"/> for the multi-select
/// single-value collapse contract.
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls
/// — 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
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
/// <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
{
private readonly AuditQueryModel _model = new();
private List<Site> _sites = new();
/// <summary>Channel options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditChannel> _channels = Enum.GetValues<AuditChannel>();
/// <summary>Status options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditStatus> _statuses = Enum.GetValues<AuditStatus>();
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
/// <summary>
/// Raised when the user clicks Apply. Carries the collapsed
/// Raised when the user clicks Apply. Carries the
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
/// </summary>
@@ -51,10 +64,9 @@ public partial class AuditFilterBar
_model.InstanceSearch = InitialInstanceSearch.Trim();
}
// Populate the Site chips at component init. Failure is non-fatal — the chip
// section just shows "No sites available." Sites are listed by Name to match
// operator expectations from the Notification Report.
// Populate the Site dropdown at component init. Failure is non-fatal — the
// dropdown just shows "No sites available." Sites are listed by Name to
// match operator expectations from the Notification Report.
try
{
var sites = await SiteRepository.GetAllSitesAsync();
@@ -62,48 +74,52 @@ public partial class AuditFilterBar
}
catch
{
// Swallowed: filter bar still renders without the Site chips. The page
// Swallowed: filter bar still renders without the Site options. The page
// surfaces site-load errors elsewhere (the grid query path).
_sites = new();
}
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
}
private void ToggleChannel(AuditChannel channel)
/// <summary>
/// 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
{
if (!_model.Channels.Add(channel))
get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
set
{
_model.Channels.Remove(channel);
_model.Channels.Clear();
if (value is { } channel)
{
_model.Channels.Add(channel);
}
// Drop Kind chips that fall outside the new visible set. Keeps "Channel and
// Kind both picked" coherent — without this, removing a channel could leave
// stale Kind chips selected that no longer match any visible chip.
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()
{
var visible = _model.VisibleKinds().ToHashSet();
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
}
private void ToggleKind(AuditKind kind)
/// <summary>Display label for a site identifier — its friendly Name, id as fallback.</summary>
private string SiteName(string siteIdentifier)
{
if (!_model.Kinds.Add(kind))
{
_model.Kinds.Remove(kind);
}
}
private void ToggleStatus(AuditStatus status)
{
if (!_model.Statuses.Add(status))
{
_model.Statuses.Remove(status);
}
}
private void ToggleSite(string siteIdentifier)
{
if (!_model.SiteIdentifiers.Add(siteIdentifier))
{
_model.SiteIdentifiers.Remove(siteIdentifier);
}
var site = _sites.FirstOrDefault(s =>
string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
return site?.Name ?? siteIdentifier;
}
private void ClearFilters()
@@ -129,11 +145,6 @@ public partial class AuditFilterBar
await OnFilterChanged.InvokeAsync(filter);
}
private static string ChipClass(bool selected) =>
selected
? "btn btn-sm btn-primary me-1 mb-1"
: "btn btn-sm btn-outline-secondary me-1 mb-1";
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
{
AuditTimeRangePreset.Last5Minutes => "now 5 min → now",

View File

@@ -0,0 +1,40 @@
@typeparam TValue
@*
Compact multi-select control: a Bootstrap dropdown whose toggle button
summarises the current selection over a checkbox menu. Replaces a wrapped
block of chip buttons with a single control of one row's height.
*@
<div class="dropdown msd" data-test="@DataTest">
<button type="button"
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
disabled="@(Items.Count == 0)"
data-test="@($"{DataTest}-toggle")">
<span class="msd-summary">@Summary()</span>
</button>
<ul class="dropdown-menu msd-menu">
@if (Items.Count == 0)
{
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
}
else
{
@foreach (var item in Items)
{
var isSelected = Selected.Contains(item);
<li>
<label class="dropdown-item msd-item">
<input type="checkbox"
class="form-check-input msd-check"
checked="@isSelected"
@onchange="() => Toggle(item)"
data-test="@($"{DataTest}-opt-{item}")" />
<span>@Display(item)</span>
</label>
</li>
}
}
</ul>
</div>

View File

@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Components;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
/// summarises the current selection ("All" when empty, the single item's label
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
///
/// <para>
/// It exists to keep multi-value filter controls one row tall instead of a
/// wrapped block of chip buttons. The component mutates the caller-owned
/// <see cref="Selected"/> collection in place and raises
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
/// (re-render, prune dependent selections, …).
/// </para>
///
/// <para>
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
/// while the operator ticks several boxes.
/// </para>
/// </summary>
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
public partial class MultiSelectDropdown<TValue> where TValue : notnull
{
/// <summary>The options shown in the menu, in display order.</summary>
[Parameter, EditorRequired]
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
/// <summary>
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
/// </summary>
[Parameter, EditorRequired]
public ICollection<TValue> Selected { get; set; } = default!;
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
[Parameter]
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
[Parameter]
public EventCallback SelectionChanged { get; set; }
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
[Parameter]
public string AllLabel { get; set; } = "All";
/// <summary>Text shown in the menu when there are no options.</summary>
[Parameter]
public string EmptyText { get; set; } = "None available";
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
[Parameter]
public string DataTest { get; set; } = "multi-select";
private async Task Toggle(TValue item)
{
// ICollection.Remove returns false when the item was absent — that is the
// "not currently selected" case, so add it. This is a plain toggle.
if (!Selected.Remove(item))
{
Selected.Add(item);
}
await SelectionChanged.InvokeAsync();
}
private string Summary()
{
var count = Selected.Count;
if (count == 0)
{
return AllLabel;
}
if (count == 1)
{
// Prefer the single selection's label over a bare "1 selected".
foreach (var item in Items)
{
if (Selected.Contains(item))
{
return Display(item);
}
}
// The one selected value is not in the current Items list (e.g. a Kind
// narrowed out by a Channel change before the parent pruned it).
return "1 selected";
}
return $"{count} selected";
}
}

View File

@@ -0,0 +1,32 @@
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
form-control-sm controls in a filter row. */
.msd-toggle {
min-width: 9rem;
max-width: 15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Keep a long option list from running off-screen — scroll within the menu. */
.msd-menu {
max-height: 16rem;
overflow-y: auto;
}
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
menu stays open thanks to data-bs-auto-close="outside". */
.msd-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Neutralise the default form-check-input top margin so the box lines up with
the option text inside the dropdown-item. */
.msd-check {
flex: 0 0 auto;
margin: 0;
}

View File

@@ -145,6 +145,17 @@ public sealed class AuditWriteMiddleware
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
Kind = kind,
// Audit Log #23: a fresh per-request correlation id so the
// inbound row carries a request identifier (closes the design
// gap that inbound rows should be correlatable).
//
// This id is intentionally request-local: it is NOT bridged to
// RouteHelper's routed-call correlation id or to
// HttpContext.TraceIdentifier. Threading an inbound request's
// correlation id through to the routed script execution (so an
// inbound call and the outbound API/DB rows it triggers share
// one id) is a deliberate future follow-up, out of scope here.
CorrelationId = Guid.NewGuid(),
Actor = actor,
Target = methodName,
Status = status,

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

@@ -37,9 +37,13 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly string _siteId;
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _auditCorrelationId;
private readonly ILogger _logger;
private DbConnection? _wrappingConnection;
// Parameter ordering: auditCorrelationId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection).
public AuditingDbCommand(
DbCommand inner,
IAuditWriter auditWriter,
@@ -47,7 +51,8 @@ internal sealed class AuditingDbCommand : DbCommand
string siteId,
string instanceName,
string? sourceScript,
ILogger logger)
ILogger logger,
Guid auditCorrelationId)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -56,6 +61,7 @@ internal sealed class AuditingDbCommand : DbCommand
_instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_auditCorrelationId = auditCorrelationId;
}
// -- Forwarded surface ------------------------------------------------
@@ -426,11 +432,17 @@ internal sealed class AuditingDbCommand : DbCommand
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.DbWrite,
CorrelationId = null,
// Audit Log #23: the execution-wide correlation id, so this sync
// DbWrite row shares an id with the other sync trust-boundary rows
// from the same script run.
CorrelationId = _auditCorrelationId,
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

@@ -36,8 +36,12 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly string _siteId;
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _auditCorrelationId;
private readonly ILogger _logger;
// Parameter ordering: auditCorrelationId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbCommand).
public AuditingDbConnection(
DbConnection inner,
IAuditWriter auditWriter,
@@ -45,7 +49,8 @@ internal sealed class AuditingDbConnection : DbConnection
string siteId,
string instanceName,
string? sourceScript,
ILogger logger)
ILogger logger,
Guid auditCorrelationId)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -54,6 +59,7 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_auditCorrelationId = auditCorrelationId;
}
// ConnectionString is settable on DbConnection — forward both halves.
@@ -92,7 +98,8 @@ internal sealed class AuditingDbConnection : DbConnection
_siteId,
_instanceName,
_sourceScript,
_logger);
_logger,
_auditCorrelationId);
}
protected override void Dispose(bool disposing)

View File

@@ -105,6 +105,21 @@ public class ScriptRuntimeContext
/// </summary>
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary>
/// Audit Log #23: the execution-wide audit correlation id. Every sync
/// trust-boundary audit row emitted by this script execution
/// (<c>ApiCall</c>, <c>DbWrite</c>) is stamped with this id so all the
/// rows from one script run can be correlated together.
/// </summary>
private readonly Guid _auditCorrelationId;
/// <param name="auditCorrelationId">
/// Audit Log #23: the execution-wide audit correlation id. When omitted
/// (tag-change / timer-triggered executions) a fresh id is generated; an
/// inbound caller may supply one to tie the execution to an upstream
/// request. Stamped on the sync <c>ApiCall</c>/<c>DbWrite</c> audit rows
/// this execution emits.
/// </param>
public ScriptRuntimeContext(
IActorRef instanceActor,
IActorRef self,
@@ -122,7 +137,8 @@ public class ScriptRuntimeContext
string? sourceScript = null,
IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null)
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? auditCorrelationId = null)
{
_instanceActor = instanceActor;
_self = self;
@@ -141,6 +157,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter;
_operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder;
_auditCorrelationId = auditCorrelationId ?? Guid.NewGuid();
}
/// <summary>
@@ -241,7 +258,7 @@ public class ScriptRuntimeContext
/// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary>
public ExternalSystemHelper ExternalSystem => new(
_externalSystemClient, _instanceName, _logger, _auditWriter, _siteId, _sourceScript,
_externalSystemClient, _instanceName, _logger, _auditCorrelationId, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue.
_cachedForwarder);
@@ -255,6 +272,7 @@ public class ScriptRuntimeContext
_databaseGateway,
_instanceName,
_logger,
_auditCorrelationId,
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
// Database.Connection(name) returns an auditing decorator that
// emits one DbOutbound/DbWrite row per script-initiated
@@ -362,6 +380,7 @@ public class ScriptRuntimeContext
private readonly IExternalSystemClient? _client;
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly Guid _auditCorrelationId;
private readonly IAuditWriter? _auditWriter;
private readonly string _siteId;
private readonly string? _sourceScript;
@@ -370,10 +389,18 @@ public class ScriptRuntimeContext
// Internal constructor for tests living in ScadaLink.SiteRuntime.Tests
// (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem.
//
// Parameter ordering: auditCorrelationId sits immediately after the
// ILogger across all four audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required
// Guid cannot follow the optional provenance params without a
// required-after-optional compile error, so the post-logger slot is the
// one consistent position that compiles cleanly everywhere.
internal ExternalSystemHelper(
IExternalSystemClient? client,
string instanceName,
ILogger logger,
Guid auditCorrelationId,
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
@@ -382,6 +409,7 @@ public class ScriptRuntimeContext
_client = client;
_instanceName = instanceName;
_logger = logger;
_auditCorrelationId = auditCorrelationId;
_auditWriter = auditWriter;
_siteId = siteId;
_sourceScript = sourceScript;
@@ -420,7 +448,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 +486,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 +531,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 +549,7 @@ public class ScriptRuntimeContext
string target,
TrackedOperationId trackedId,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken)
{
if (_cachedForwarder == null)
@@ -544,6 +573,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 +630,7 @@ public class ScriptRuntimeContext
string target,
TrackedOperationId trackedId,
ExternalCallResult result,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken)
{
if (_cachedForwarder == null)
@@ -653,6 +685,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 +746,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 +798,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc,
int durationMs,
ExternalCallResult? result,
Exception? thrown)
Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{
if (_auditWriter == null)
{
@@ -772,7 +809,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 +866,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
@@ -871,24 +910,57 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
// Audit Log #23: the execution-wide correlation id, so all the
// sync ApiCall/DbWrite rows from one script run share an id.
CorrelationId = _auditCorrelationId,
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>
@@ -907,6 +979,7 @@ public class ScriptRuntimeContext
private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly Guid _auditCorrelationId;
private readonly string _siteId;
private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -923,10 +996,15 @@ public class ScriptRuntimeContext
/// </summary>
private readonly IAuditWriter? _auditWriter;
// Parameter ordering: auditCorrelationId sits immediately after the
// ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four
// audit-threaded ctors.
internal DatabaseHelper(
IDatabaseGateway? gateway,
string instanceName,
ILogger logger,
Guid auditCorrelationId,
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
@@ -935,6 +1013,7 @@ public class ScriptRuntimeContext
_gateway = gateway;
_instanceName = instanceName;
_logger = logger;
_auditCorrelationId = auditCorrelationId;
_auditWriter = auditWriter;
_siteId = siteId;
_sourceScript = sourceScript;
@@ -969,7 +1048,8 @@ public class ScriptRuntimeContext
siteId: _siteId,
instanceName: _instanceName,
sourceScript: _sourceScript,
logger: _logger);
logger: _logger,
auditCorrelationId: _auditCorrelationId);
}
/// <summary>
@@ -1355,7 +1435,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

@@ -150,6 +150,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
client,
instanceName: "Plant.Pump42",
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: "site-77",
sourceScript: "ScriptActor:Sync",
@@ -193,6 +194,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
client,
instanceName: "Plant.Pump42",
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: "site-77",
sourceScript: "ScriptActor:Cached",
@@ -243,6 +245,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
gateway,
instanceName,
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: "site-77",
sourceScript: "ScriptActor:Db",

View File

@@ -157,6 +157,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
gateway,
InstanceName,
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: siteId,
sourceScript: SourceScript,

View File

@@ -16,7 +16,7 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// <para>
/// Scenarios covered (per the M7-T16 brief):
/// <list type="bullet">
/// <item><c>FilterNarrowing</c> — channel chip narrows the results grid.</item>
/// <item><c>FilterNarrowing</c> — the channel filter narrows the results grid.</item>
/// <item><c>DrilldownDrawer_JsonPrettyPrint</c> — JSON request bodies pretty-print.</item>
/// <item><c>CopyAsCurlButton_VisibleOnApiInbound</c> — cURL action visible for API rows.</item>
/// <item><c>DrillInFromCorrelationId_AutoLoadsAuditLog</c> — query-string drill-in
@@ -45,7 +45,7 @@ public class AuditLogPageTests
}
[Fact]
public async Task FilterNarrowing_ChannelChipShrinksGrid()
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
{
// Skip with a clear message when MSSQL is not reachable — the rest of
// the Playwright suite is UI-only and does not need the DB, so this
@@ -91,13 +91,13 @@ public class AuditLogPageTests
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Pre-Apply, both rows are absent because the grid stays empty until
// the user filters. Click the ApiOutbound chip then Apply.
await page.Locator("[data-test='chip-channel-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);
// The seeded ApiOutbound row is visible; the DbOutbound row is not
// (it was filtered out by the channel chip).
// (it was filtered out by the channel filter).
var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']");
var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
await Assertions.Expect(apiRow).ToBeVisibleAsync();

View File

@@ -13,13 +13,18 @@ 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. Tests
/// pin: (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c>
/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip
/// visibility; (4) the Errors-only toggle ORs <c>Failed</c> into Status when
/// Status is otherwise empty; (5) the "Last hour" preset populates
/// <c>FromUtc</c> to roughly an hour before "now" — proves the time-window
/// collapse without freezing the clock.
/// 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 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
/// visibility; (4) the Errors-only toggle ORs the error statuses into Status when
/// Status is otherwise empty; (5) the "Last hour" preset populates <c>FromUtc</c>
/// to roughly an hour before "now" — proves the time-window collapse without
/// freezing the clock.
/// </summary>
public class AuditFilterBarTests : BunitContext
{
@@ -71,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: toggle a Channel chip, type in the Target search box, click Apply.
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
// 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();
@@ -82,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=\"chip-channel-ApiOutbound\"]").Click();
cut.Find("[data-test=\"chip-channel-Notification\"]").Click();
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]
@@ -106,23 +113,23 @@ public class AuditFilterBarTests : BunitContext
{
var cut = Render<AuditFilterBar>();
// With no Channel selected, every kind chip is in the DOM.
// With no Channel selected, every kind option is in the DOM.
foreach (var kind in Enum.GetValues<AuditKind>())
{
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
}
// Select only ApiOutbound; Kind chips outside the channel-kind map drop out.
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
// 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)
{
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
}
// Sanity: an unrelated kind is gone.
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.NotifySend}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.InboundRequest}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.NotifySend}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
}
[Fact]
@@ -144,8 +151,8 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains(AuditStatus.Parked, captured.Statuses);
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
// Now pin an explicit Status option — Errors-only must yield (explicit wins).
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
@@ -160,8 +167,8 @@ public class AuditFilterBarTests : BunitContext
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
cut.Find("[data-test=\"chip-status-Failed\"]").Click();
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);

View File

@@ -350,6 +350,46 @@ public class AuditWriteMiddlewareTests
Assert.Equal(requestJson, evt.RequestSummary);
}
// ---------------------------------------------------------------------
// Correlation id — Audit Log #23: each inbound row carries a fresh
// per-request correlation id so inbound rows are correlatable.
// ---------------------------------------------------------------------
[Fact]
public async Task InboundRow_CarriesNonNull_CorrelationId()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value);
}
[Fact]
public async Task SeparateRequests_GetDistinct_CorrelationIds()
{
var writer = new RecordingAuditWriter();
var mw = CreateMiddleware(hc =>
{
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(BuildContext());
await mw.InvokeAsync(BuildContext());
Assert.Equal(2, writer.Events.Count);
Assert.NotEqual(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId);
}
[Fact]
public async Task DurationMs_IsRecorded()
{

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

@@ -47,6 +47,9 @@ public class DatabaseCachedWriteEmissionTests
gateway,
InstanceName,
NullLogger.Instance,
// Audit Log #23: execution-wide correlation id. Cached rows keep
// CorrelationId = TrackedOperationId, so any value works here.
Guid.NewGuid(),
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder);

View File

@@ -48,14 +48,28 @@ public class DatabaseSyncEmissionTests
private const string SourceScript = "ScriptActor:Sync";
private const string ConnectionName = "machineData";
/// <summary>
/// Audit Log #23: a fixed execution-wide correlation id used by the
/// default <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
private static readonly Guid TestCorrelationId = Guid.NewGuid();
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter? auditWriter)
=> CreateHelper(gateway, auditWriter, TestCorrelationId);
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter? auditWriter,
Guid correlationId)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
correlationId,
auditWriter: auditWriter,
siteId: SiteId,
sourceScript: SourceScript,
@@ -266,11 +280,36 @@ public class DatabaseSyncEmissionTests
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
Assert.Equal(SourceScript, evt.SourceScript);
Assert.Null(evt.Actor);
Assert.Null(evt.CorrelationId);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
// Audit Log #23: the sync DbWrite row now carries the execution-wide
// correlation id the helper was constructed with.
Assert.Equal(TestCorrelationId, evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.EventId);
}
[Fact]
public async Task SyncDbWrite_StampsExecutionCorrelationId()
{
using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var correlationId = Guid.NewGuid();
var helper = CreateHelper(gateway.Object, writer, correlationId);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')";
await cmd.ExecuteNonQueryAsync();
var evt = Assert.Single(writer.Events);
Assert.Equal(correlationId, evt.CorrelationId);
}
[Fact]
public async Task DurationMs_NonZero()
{

View File

@@ -0,0 +1,179 @@
using Akka.Actor;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — execution-correlation tests exercised through a full
/// <see cref="ScriptRuntimeContext"/>:
///
/// <list type="bullet">
/// <item><description>
/// The <c>?? Guid.NewGuid()</c> fallback in the <see cref="ScriptRuntimeContext"/>
/// ctor: when no audit correlation id is supplied (tag-change / timer-triggered
/// executions) a fresh, non-empty id is minted and stamped on the emitted rows.
/// </description></item>
/// <item><description>
/// The execution-wide contract: an <c>ExternalSystem.Call</c> and a sync
/// <c>Database</c> write performed through ONE context share a single
/// <see cref="AuditEvent.CorrelationId"/>.
/// </description></item>
/// </list>
/// </summary>
public class ExecutionCorrelationContextTests
{
/// <summary>
/// In-memory <see cref="IAuditWriter"/> capturing every emitted event
/// (mirrors the <c>CapturingAuditWriter</c> stubs in
/// <see cref="ExternalSystemCallAuditEmissionTests"/> /
/// <see cref="DatabaseSyncEmissionTests"/>).
/// </summary>
private sealed class CapturingAuditWriter : IAuditWriter
{
public List<AuditEvent> Events { get; } = new();
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
Events.Add(evt);
return Task.CompletedTask;
}
}
private const string InstanceName = "Plant.Pump42";
private const string ConnectionName = "machineData";
/// <summary>
/// Builds a full <see cref="ScriptRuntimeContext"/> wired with the external
/// system client, database gateway and audit writer the cross-helper test
/// needs. The actor refs are <see cref="ActorRefs.Nobody"/> — the
/// integration helpers (ExternalSystem / Database) never touch them — and
/// <paramref name="auditCorrelationId"/> defaults to null so the ctor's
/// <c>?? Guid.NewGuid()</c> fallback is exercised unless a test supplies one.
/// </summary>
private static ScriptRuntimeContext CreateContext(
IExternalSystemClient? externalSystemClient,
IDatabaseGateway? databaseGateway,
IAuditWriter? auditWriter,
Guid? auditCorrelationId = null)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
return new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: InstanceName,
logger: NullLogger.Instance,
externalSystemClient: externalSystemClient,
databaseGateway: databaseGateway,
storeAndForward: null,
siteCommunicationActor: null,
siteId: "site-77",
sourceScript: "ScriptActor:OnTick",
auditWriter: auditWriter,
operationTrackingStore: null,
cachedForwarder: null,
auditCorrelationId: auditCorrelationId);
}
/// <summary>
/// Spin up a fresh in-memory SQLite database with a tiny single-table
/// schema. The keep-alive root must outlive any auditing wrapper the test
/// exercises (mirrors <c>DatabaseSyncEmissionTests.NewInMemoryDb</c>).
/// </summary>
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
{
var dbName = $"db-{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
using (var seed = keepAlive.CreateCommand())
{
seed.CommandText =
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
seed.ExecuteNonQuery();
}
var live = new SqliteConnection(connStr);
live.Open();
return live;
}
[Fact]
public async Task NoCorrelationIdSupplied_SyncCall_StampsFreshNonEmptyCorrelationId()
{
// No auditCorrelationId argument — the ScriptRuntimeContext ctor's
// `?? Guid.NewGuid()` fallback must mint one (this is the unsupplied-id
// branch every other audit test bypasses by passing an explicit id).
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var context = CreateContext(client.Object, databaseGateway: null, writer);
await context.ExternalSystem.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value);
}
[Fact]
public async Task SameContext_ApiCallAndDbWrite_ShareTheSameCorrelationId()
{
// The execution-wide contract: an ExternalSystem.Call AND a sync
// Database write performed through ONE ScriptRuntimeContext must both
// carry the same execution correlation id, so an audit reader can tie
// every trust-boundary action from one script run together.
using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared");
var innerDb = NewInMemoryDb(out var _);
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(innerDb);
var writer = new CapturingAuditWriter();
var context = CreateContext(client.Object, gateway.Object, writer);
// 1) outbound API call through the context's ExternalSystem helper.
await context.ExternalSystem.Call("ERP", "GetOrder");
// 2) sync DB write through the SAME context's Database helper.
await using (var conn = await context.Database.Connection(ConnectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
await cmd.ExecuteNonQueryAsync();
}
Assert.Equal(2, writer.Events.Count);
var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound);
var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound);
Assert.NotNull(apiRow.CorrelationId);
Assert.NotEqual(Guid.Empty, apiRow.CorrelationId!.Value);
// The ApiCall row and the DbWrite row, emitted by two different helpers
// resolved off one context, carry the identical execution correlation id.
Assert.Equal(apiRow.CorrelationId, dbRow.CorrelationId);
}
}

View File

@@ -49,6 +49,9 @@ public class ExternalSystemCachedCallEmissionTests
client,
InstanceName,
NullLogger.Instance,
// Audit Log #23: execution-wide correlation id. Cached rows keep
// CorrelationId = TrackedOperationId, so any value works here.
Guid.NewGuid(),
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
@@ -94,6 +97,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

@@ -45,14 +45,28 @@ public class ExternalSystemCallAuditEmissionTests
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary>
/// Audit Log #23: a fixed execution-wide correlation id used by the
/// default <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
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);
@@ -81,6 +95,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,8 +223,49 @@ public class ExternalSystemCallAuditEmissionTests
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
Assert.Equal(SourceScript, evt.SourceScript);
Assert.Null(evt.Actor);
Assert.Null(evt.CorrelationId);
// 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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.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]

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]