feat(centralui): ParentExecutionId column, filter and parent drill-in on the Audit Log page
This commit is contained in:
@@ -112,6 +112,13 @@ public static class AuditExportEndpoints
|
|||||||
executionId = parsedExec;
|
executionId = parsedExec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Guid? parentExecutionId = null;
|
||||||
|
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
|
||||||
|
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
|
||||||
|
{
|
||||||
|
parentExecutionId = parsedParentExec;
|
||||||
|
}
|
||||||
|
|
||||||
DateTime? fromUtc = ParseUtcDate(query, "from");
|
DateTime? fromUtc = ParseUtcDate(query, "from");
|
||||||
DateTime? toUtc = ParseUtcDate(query, "to");
|
DateTime? toUtc = ParseUtcDate(query, "to");
|
||||||
|
|
||||||
@@ -124,6 +131,7 @@ public static class AuditExportEndpoints
|
|||||||
Actor: actor,
|
Actor: actor,
|
||||||
CorrelationId: correlationId,
|
CorrelationId: correlationId,
|
||||||
ExecutionId: executionId,
|
ExecutionId: executionId,
|
||||||
|
ParentExecutionId: parentExecutionId,
|
||||||
FromUtc: fromUtc,
|
FromUtc: fromUtc,
|
||||||
ToUtc: toUtc);
|
ToUtc: toUtc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@
|
|||||||
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
|
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
|
||||||
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
|
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">ParentExecutionId</dt>
|
||||||
|
<dd class="col-8 font-monospace" data-test="field-ParentExecutionId">@(Event.ParentExecutionId?.ToString() ?? "—")</dd>
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
|
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
|
||||||
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
|
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
|
||||||
|
|
||||||
@@ -162,6 +165,14 @@
|
|||||||
View this execution
|
View this execution
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (Event.ParentExecutionId is not null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-test="view-parent-execution"
|
||||||
|
@onclick="ViewParentExecution">
|
||||||
|
View parent execution
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<button class="btn btn-primary btn-sm ms-auto"
|
<button class="btn btn-primary btn-sm ms-auto"
|
||||||
data-test="drawer-close-footer"
|
data-test="drawer-close-footer"
|
||||||
@onclick="HandleClose">
|
@onclick="HandleClose">
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
/// the "Show all events" button navigates to
|
/// the "Show all events" button navigates to
|
||||||
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
|
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
|
||||||
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
|
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
|
||||||
/// button navigates to <c>/audit/log?executionId={id}</c>. Both are deep
|
/// button navigates to <c>/audit/log?executionId={id}</c>. Likewise, when
|
||||||
|
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
|
||||||
|
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
|
||||||
|
/// — the spawner's id used as the per-run drill-in target. All are deep
|
||||||
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
|
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -291,6 +294,21 @@ public partial class AuditDrilldownDrawer
|
|||||||
Navigation.NavigateTo(uri);
|
Navigation.NavigateTo(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drill-in to the spawner execution: a routed (child) row carries a non-null
|
||||||
|
/// <see cref="AuditEvent.ParentExecutionId"/>. Navigates to
|
||||||
|
/// <c>/audit/log?executionId={ParentExecutionId}</c> so the user sees the
|
||||||
|
/// spawner execution's own rows — the parent's id becomes the <c>?executionId=</c>
|
||||||
|
/// drill-in target. The button is only rendered when
|
||||||
|
/// <see cref="AuditEvent.ParentExecutionId"/> is non-null, so this is total.
|
||||||
|
/// </summary>
|
||||||
|
private void ViewParentExecution()
|
||||||
|
{
|
||||||
|
if (Event?.ParentExecutionId is not { } parentExec) return;
|
||||||
|
var uri = $"/audit/log?executionId={parentExec}";
|
||||||
|
Navigation.NavigateTo(uri);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build a cURL command from an audit event. The URL comes from
|
/// Build a cURL command from an audit event. The URL comes from
|
||||||
/// <c>Target</c>; when the RequestSummary parses as
|
/// <c>Target</c>; when the RequestSummary parses as
|
||||||
|
|||||||
@@ -127,6 +127,16 @@
|
|||||||
placeholder="paste GUID…" @bind="_model.ExecutionId" />
|
placeholder="paste GUID…" @bind="_model.ExecutionId" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* ParentExecutionId is an exact-match Guid filter — the operator pastes
|
||||||
|
the spawner execution's id to find every run it spawned. Lax-parsed
|
||||||
|
in ToFilter, exactly like ExecutionId above. *@
|
||||||
|
<div class="col-auto" data-test="filter-parent-execution-id">
|
||||||
|
<label class="form-label small mb-1" for="audit-parent-execution-id">Parent execution ID</label>
|
||||||
|
<input id="audit-parent-execution-id" type="text"
|
||||||
|
class="form-control form-control-sm font-monospace"
|
||||||
|
placeholder="paste GUID…" @bind="_model.ParentExecutionId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-auto" data-test="filter-errors-only">
|
<div class="col-auto" data-test="filter-errors-only">
|
||||||
<div class="form-check mb-1">
|
<div class="form-check mb-1">
|
||||||
<input class="form-check-input" type="checkbox" id="audit-errors-only"
|
<input class="form-check-input" type="checkbox" id="audit-errors-only"
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ public partial class AuditFilterBar
|
|||||||
_model.TargetSearch = string.Empty;
|
_model.TargetSearch = string.Empty;
|
||||||
_model.ActorSearch = string.Empty;
|
_model.ActorSearch = string.Empty;
|
||||||
_model.ExecutionId = string.Empty;
|
_model.ExecutionId = string.Empty;
|
||||||
|
_model.ParentExecutionId = string.Empty;
|
||||||
_model.ErrorsOnly = false;
|
_model.ErrorsOnly = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ public sealed class AuditQueryModel
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string ExecutionId { get; set; } = string.Empty;
|
public string ExecutionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paste-in ParentExecutionId filter — the operator pastes the spawner
|
||||||
|
/// execution's Guid to find every run it spawned. Stored as free text;
|
||||||
|
/// <see cref="ToFilter"/> lax-parses it through
|
||||||
|
/// <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or unparseable
|
||||||
|
/// value simply yields no constraint, mirroring <see cref="ExecutionId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string ParentExecutionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
public bool ErrorsOnly { get; set; }
|
public bool ErrorsOnly { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -128,6 +137,11 @@ public sealed class AuditQueryModel
|
|||||||
? parsedExecutionId
|
? parsedExecutionId
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Same lax-parse contract for the pasted ParentExecutionId.
|
||||||
|
Guid? parentExecutionId = Guid.TryParse(ParentExecutionId, out var parsedParentExecutionId)
|
||||||
|
? parsedParentExecutionId
|
||||||
|
: null;
|
||||||
|
|
||||||
return new AuditLogQueryFilter(
|
return new AuditLogQueryFilter(
|
||||||
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
|
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
|
||||||
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
|
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
|
||||||
@@ -137,6 +151,7 @@ public sealed class AuditQueryModel
|
|||||||
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
|
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
|
||||||
CorrelationId: null,
|
CorrelationId: null,
|
||||||
ExecutionId: executionId,
|
ExecutionId: executionId,
|
||||||
|
ParentExecutionId: parentExecutionId,
|
||||||
FromUtc: fromUtc,
|
FromUtc: fromUtc,
|
||||||
ToUtc: toUtc);
|
ToUtc: toUtc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,18 @@
|
|||||||
<span class="small text-muted">—</span>
|
<span class="small text-muted">—</span>
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "ParentExecutionId":
|
||||||
|
@if (row.ParentExecutionId is { } parentExecutionId)
|
||||||
|
{
|
||||||
|
<span class="small font-monospace"
|
||||||
|
data-test="parent-execution-id-@row.EventId"
|
||||||
|
title="@parentExecutionId">@ShortGuid(parentExecutionId)</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="small text-muted">—</span>
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "DurationMs":
|
case "DurationMs":
|
||||||
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
|
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
|
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
|
||||||
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
|
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
|
||||||
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
|
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
|
||||||
/// ErrorMessage — plus the ExecutionId per-run correlation column. Talks to
|
/// ErrorMessage — plus the ExecutionId per-run correlation column and the
|
||||||
|
/// ParentExecutionId spawner-correlation column. Talks to
|
||||||
/// <see cref="Services.IAuditLogQueryService"/>
|
/// <see cref="Services.IAuditLogQueryService"/>
|
||||||
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
|
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
|
||||||
/// source without standing up EF Core.
|
/// source without standing up EF Core.
|
||||||
@@ -123,6 +124,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
|||||||
("Target", "Target"),
|
("Target", "Target"),
|
||||||
("Actor", "Actor"),
|
("Actor", "Actor"),
|
||||||
("ExecutionId", "ExecutionId"),
|
("ExecutionId", "ExecutionId"),
|
||||||
|
("ParentExecutionId", "ParentExecutionId"),
|
||||||
("DurationMs", "DurationMs"),
|
("DurationMs", "DurationMs"),
|
||||||
("HttpStatus", "HttpStatus"),
|
("HttpStatus", "HttpStatus"),
|
||||||
("ErrorMessage", "ErrorMessage"),
|
("ErrorMessage", "ErrorMessage"),
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
|||||||
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||||
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
||||||
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
|
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
|
||||||
/// <c>?executionId=</c> for the "View this execution" drill-in. When any param is present we allocate a
|
/// <c>?executionId=</c> for the "View this execution" drill-in, and the
|
||||||
|
/// ParentExecutionId follow-up adds <c>?parentExecutionId=</c> for the
|
||||||
|
/// "View parent execution" drill-in. When any param is present we allocate a
|
||||||
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
||||||
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
|
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
|
||||||
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
|
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
|
||||||
@@ -71,6 +73,15 @@ public partial class AuditLogPage
|
|||||||
executionId = parsedExec;
|
executionId = parsedExec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ?parentExecutionId= constrains to runs spawned by a given execution.
|
||||||
|
// Lax-parsed like ?executionId=: an unparseable value is silently dropped.
|
||||||
|
Guid? parentExecutionId = null;
|
||||||
|
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
|
||||||
|
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
|
||||||
|
{
|
||||||
|
parentExecutionId = parsedParentExec;
|
||||||
|
}
|
||||||
|
|
||||||
string? target = null;
|
string? target = null;
|
||||||
if (query.TryGetValue("target", out var targetValues))
|
if (query.TryGetValue("target", out var targetValues))
|
||||||
{
|
{
|
||||||
@@ -128,7 +139,8 @@ public partial class AuditLogPage
|
|||||||
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
|
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
|
||||||
// because the filter contract has no instance column — the user still needs
|
// because the filter contract has no instance column — the user still needs
|
||||||
// to refine + Apply for those.
|
// to refine + Apply for those.
|
||||||
if (correlationId is null && executionId is null && target is null && actor is null
|
if (correlationId is null && executionId is null && parentExecutionId is null
|
||||||
|
&& target is null && actor is null
|
||||||
&& sites is null && channels is null && kinds is null && statuses is null)
|
&& sites is null && channels is null && kinds is null && statuses is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -142,7 +154,8 @@ public partial class AuditLogPage
|
|||||||
Target: target,
|
Target: target,
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
CorrelationId: correlationId,
|
CorrelationId: correlationId,
|
||||||
ExecutionId: executionId);
|
ExecutionId: executionId,
|
||||||
|
ParentExecutionId: parentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -252,6 +265,10 @@ public partial class AuditLogPage
|
|||||||
{
|
{
|
||||||
parts.Add(new("executionId", exec.ToString()));
|
parts.Add(new("executionId", exec.ToString()));
|
||||||
}
|
}
|
||||||
|
if (filter.ParentExecutionId is { } parentExec)
|
||||||
|
{
|
||||||
|
parts.Add(new("parentExecutionId", parentExec.ToString()));
|
||||||
|
}
|
||||||
if (filter.FromUtc is { } from)
|
if (filter.FromUtc is { } from)
|
||||||
{
|
{
|
||||||
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
|
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ internal static class AuditDataSeeder
|
|||||||
string? actor = null,
|
string? actor = null,
|
||||||
Guid? correlationId = null,
|
Guid? correlationId = null,
|
||||||
Guid? executionId = null,
|
Guid? executionId = null,
|
||||||
|
Guid? parentExecutionId = null,
|
||||||
int? httpStatus = null,
|
int? httpStatus = null,
|
||||||
int? durationMs = null,
|
int? durationMs = null,
|
||||||
string? errorMessage = null,
|
string? errorMessage = null,
|
||||||
@@ -77,12 +78,12 @@ internal static class AuditDataSeeder
|
|||||||
const string sql = @"
|
const string sql = @"
|
||||||
INSERT INTO [AuditLog]
|
INSERT INTO [AuditLog]
|
||||||
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
|
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
|
||||||
[ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
|
[ExecutionId], [ParentExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
|
||||||
[Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
|
[Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
|
||||||
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
|
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
|
||||||
VALUES
|
VALUES
|
||||||
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
|
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
|
||||||
@executionId, @sourceSiteId, NULL, NULL, @actor, @target,
|
@executionId, @parentExecutionId, @sourceSiteId, NULL, NULL, @actor, @target,
|
||||||
@status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
|
@status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
|
||||||
@responseSummary, 0, @extra, NULL);";
|
@responseSummary, 0, @extra, NULL);";
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ VALUES
|
|||||||
cmd.Parameters.AddWithValue("@kind", kind);
|
cmd.Parameters.AddWithValue("@kind", kind);
|
||||||
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
|
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
|
||||||
cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value);
|
cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@parentExecutionId", (object?)parentExecutionId ?? DBNull.Value);
|
||||||
cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value);
|
cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value);
|
||||||
cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
|
cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
|
||||||
cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);
|
cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
|||||||
/// <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
|
/// <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
|
||||||
/// <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
|
/// <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
|
||||||
/// auto-loads the grid filtered by ExecutionId.</item>
|
/// auto-loads the grid filtered by ExecutionId.</item>
|
||||||
|
/// <item><c>DrillInFromParentExecution_FiltersGridToSpawnerExecution</c> — the
|
||||||
|
/// drawer's "View parent execution" action on a spawned (child) row drills in
|
||||||
|
/// to <c>?executionId={ParentExecutionId}</c>, auto-loading the spawner's
|
||||||
|
/// rows.</item>
|
||||||
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
|
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
|
||||||
/// the report page wires drill-in links when notifications are present.</item>
|
/// the report page wires drill-in links when notifications are present.</item>
|
||||||
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
|
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
|
||||||
@@ -350,6 +354,81 @@ public class AuditLogPageTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DrillInFromParentExecution_FiltersGridToSpawnerExecution()
|
||||||
|
{
|
||||||
|
// The drawer's "View parent execution" action navigates a routed (child)
|
||||||
|
// row to /audit/log?executionId={ParentExecutionId}. We seed a spawner row
|
||||||
|
// (its ExecutionId == the parent id) and a child row (ParentExecutionId
|
||||||
|
// pointing at the spawner), open the child's drawer, click the action, and
|
||||||
|
// assert the grid auto-loads filtered to the spawner's own rows.
|
||||||
|
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var runId = Guid.NewGuid().ToString("N");
|
||||||
|
var targetPrefix = $"playwright-test/parent-exec-drill-in/{runId}/";
|
||||||
|
var parentExecutionId = Guid.NewGuid();
|
||||||
|
var spawnerEventId = Guid.NewGuid();
|
||||||
|
var childEventId = Guid.NewGuid();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The spawner execution's own row — carries ExecutionId == parentExecutionId.
|
||||||
|
await AuditDataSeeder.InsertAuditEventAsync(
|
||||||
|
eventId: spawnerEventId,
|
||||||
|
occurredAtUtc: now,
|
||||||
|
channel: "ApiInbound",
|
||||||
|
kind: "InboundRequest",
|
||||||
|
status: "Delivered",
|
||||||
|
target: targetPrefix + "spawner",
|
||||||
|
executionId: parentExecutionId,
|
||||||
|
httpStatus: 200,
|
||||||
|
durationMs: 7);
|
||||||
|
|
||||||
|
// The child (spawned) row — ParentExecutionId points at the spawner.
|
||||||
|
await AuditDataSeeder.InsertAuditEventAsync(
|
||||||
|
eventId: childEventId,
|
||||||
|
occurredAtUtc: now,
|
||||||
|
channel: "ApiOutbound",
|
||||||
|
kind: "ApiCall",
|
||||||
|
status: "Delivered",
|
||||||
|
target: targetPrefix + "child",
|
||||||
|
executionId: Guid.NewGuid(),
|
||||||
|
parentExecutionId: parentExecutionId,
|
||||||
|
httpStatus: 200,
|
||||||
|
durationMs: 13);
|
||||||
|
|
||||||
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||||
|
|
||||||
|
// Land on the child row via its ParentExecutionId filter, open the drawer.
|
||||||
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?parentExecutionId={parentExecutionId}");
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
|
var childRow = page.Locator($"[data-test='grid-row-{childEventId}']");
|
||||||
|
await Assertions.Expect(childRow).ToBeVisibleAsync();
|
||||||
|
await childRow.ClickAsync();
|
||||||
|
|
||||||
|
// The "View parent execution" action drills in to the spawner.
|
||||||
|
var viewParent = page.Locator("[data-test='view-parent-execution']");
|
||||||
|
await Assertions.Expect(viewParent).ToBeVisibleAsync();
|
||||||
|
await viewParent.ClickAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
|
// The drill-in lands on ?executionId={parentExecutionId} and auto-loads
|
||||||
|
// the spawner's own row.
|
||||||
|
Assert.Contains($"executionId={parentExecutionId}", page.Url);
|
||||||
|
var spawnerRow = page.Locator($"[data-test='grid-row-{spawnerEventId}']");
|
||||||
|
await Assertions.Expect(spawnerRow).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
|
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ public class AuditExportEndpointsTests
|
|||||||
{
|
{
|
||||||
var correlationId = Guid.NewGuid().ToString();
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
var executionId = Guid.NewGuid().ToString();
|
var executionId = Guid.NewGuid().ToString();
|
||||||
|
var parentExecutionId = Guid.NewGuid().ToString();
|
||||||
var url =
|
var url =
|
||||||
"/api/centralui/audit/export?" +
|
"/api/centralui/audit/export?" +
|
||||||
"channel=ApiOutbound&" +
|
"channel=ApiOutbound&" +
|
||||||
@@ -149,6 +150,7 @@ public class AuditExportEndpointsTests
|
|||||||
"actor=apikey-1&" +
|
"actor=apikey-1&" +
|
||||||
$"correlationId={correlationId}&" +
|
$"correlationId={correlationId}&" +
|
||||||
$"executionId={executionId}&" +
|
$"executionId={executionId}&" +
|
||||||
|
$"parentExecutionId={parentExecutionId}&" +
|
||||||
"from=2026-05-20T00:00:00Z&" +
|
"from=2026-05-20T00:00:00Z&" +
|
||||||
"to=2026-05-20T23:59:59Z";
|
"to=2026-05-20T23:59:59Z";
|
||||||
|
|
||||||
@@ -170,6 +172,7 @@ public class AuditExportEndpointsTests
|
|||||||
f.Actor == "apikey-1" &&
|
f.Actor == "apikey-1" &&
|
||||||
f.CorrelationId == Guid.Parse(correlationId) &&
|
f.CorrelationId == Guid.Parse(correlationId) &&
|
||||||
f.ExecutionId == Guid.Parse(executionId) &&
|
f.ExecutionId == Guid.Parse(executionId) &&
|
||||||
|
f.ParentExecutionId == Guid.Parse(parentExecutionId) &&
|
||||||
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
|
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
|
||||||
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
|
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
|
||||||
Arg.Any<AuditLogPaging>(),
|
Arg.Any<AuditLogPaging>(),
|
||||||
@@ -199,6 +202,7 @@ public class AuditExportEndpointsTests
|
|||||||
f.Actor == null &&
|
f.Actor == null &&
|
||||||
f.CorrelationId == null &&
|
f.CorrelationId == null &&
|
||||||
f.ExecutionId == null &&
|
f.ExecutionId == null &&
|
||||||
|
f.ParentExecutionId == null &&
|
||||||
f.FromUtc == null &&
|
f.FromUtc == null &&
|
||||||
f.ToUtc == null),
|
f.ToUtc == null),
|
||||||
Arg.Any<AuditLogPaging>(),
|
Arg.Any<AuditLogPaging>(),
|
||||||
@@ -245,6 +249,25 @@ public class AuditExportEndpointsTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExportEndpoint_UnparseableParentExecutionId_SilentlyDropped()
|
||||||
|
{
|
||||||
|
// Lax-parse contract: an unparseable parentExecutionId is dropped (no 400)
|
||||||
|
// — mirrors the executionId / correlationId parse.
|
||||||
|
var (client, repo, host) = await BuildHostAsync();
|
||||||
|
using (host)
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/api/centralui/audit/export?parentExecutionId=not-a-guid");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
_ = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
await repo.Received().QueryAsync(
|
||||||
|
Arg.Is<AuditLogQueryFilter>(f => f.ParentExecutionId == null),
|
||||||
|
Arg.Any<AuditLogPaging>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test-only authentication handler that signs every request in as an Admin.
|
/// Test-only authentication handler that signs every request in as an Admin.
|
||||||
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
|
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class AuditDrilldownDrawerTests : BunitContext
|
|||||||
string? extra = null,
|
string? extra = null,
|
||||||
Guid? correlationId = null,
|
Guid? correlationId = null,
|
||||||
Guid? executionId = null,
|
Guid? executionId = null,
|
||||||
|
Guid? parentExecutionId = null,
|
||||||
string? errorMessage = null,
|
string? errorMessage = null,
|
||||||
string? errorDetail = null,
|
string? errorDetail = null,
|
||||||
string? target = "demo-target")
|
string? target = "demo-target")
|
||||||
@@ -53,6 +54,7 @@ public class AuditDrilldownDrawerTests : BunitContext
|
|||||||
Kind = kind,
|
Kind = kind,
|
||||||
CorrelationId = correlationId,
|
CorrelationId = correlationId,
|
||||||
ExecutionId = executionId,
|
ExecutionId = executionId,
|
||||||
|
ParentExecutionId = parentExecutionId,
|
||||||
SourceSiteId = "plant-a",
|
SourceSiteId = "plant-a",
|
||||||
SourceInstanceId = "boiler-3",
|
SourceInstanceId = "boiler-3",
|
||||||
SourceScript = "OnAlarm.csx",
|
SourceScript = "OnAlarm.csx",
|
||||||
@@ -258,6 +260,49 @@ public class AuditDrilldownDrawerTests : BunitContext
|
|||||||
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
|
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drawer_NullParentExecutionId_HidesViewParentExecutionButton()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(parentExecutionId: null);
|
||||||
|
|
||||||
|
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||||
|
.Add(c => c.Event, ev)
|
||||||
|
.Add(c => c.IsOpen, true));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("data-test=\"view-parent-execution\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drawer_NonNullParentExecutionId_ShowsViewParentExecutionButton()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(parentExecutionId: Guid.Parse("bbbbbbbb-1111-2222-3333-444444444444"));
|
||||||
|
|
||||||
|
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||||
|
.Add(c => c.Event, ev)
|
||||||
|
.Add(c => c.IsOpen, true));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ViewParentExecution_Navigates_WithExecutionIdQueryString()
|
||||||
|
{
|
||||||
|
// A routed (child) row drills in to its spawner: the "View parent
|
||||||
|
// execution" action navigates to /audit/log?executionId={ParentExecutionId}
|
||||||
|
// so the user sees the spawner execution's rows.
|
||||||
|
var parent = Guid.Parse("eeeeeeee-dddd-cccc-bbbb-aaaaaaaaaaaa");
|
||||||
|
var ev = MakeEvent(parentExecutionId: parent);
|
||||||
|
|
||||||
|
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||||
|
.Add(c => c.Event, ev)
|
||||||
|
.Add(c => c.IsOpen, true));
|
||||||
|
|
||||||
|
cut.Find("[data-test=\"view-parent-execution\"]").Click();
|
||||||
|
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
Assert.Contains($"/audit/log?executionId={parent}", nav.Uri);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
|
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
"data-test=\"filter-target\"",
|
"data-test=\"filter-target\"",
|
||||||
"data-test=\"filter-actor\"",
|
"data-test=\"filter-actor\"",
|
||||||
"data-test=\"filter-execution-id\"",
|
"data-test=\"filter-execution-id\"",
|
||||||
|
"data-test=\"filter-parent-execution-id\"",
|
||||||
"data-test=\"filter-errors-only\"",
|
"data-test=\"filter-errors-only\"",
|
||||||
};
|
};
|
||||||
foreach (var marker in markers)
|
foreach (var marker in markers)
|
||||||
@@ -215,6 +216,42 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
Assert.Null(captured!.ExecutionId);
|
Assert.Null(captured!.ExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_WithPastedParentExecutionId_MapsThroughToFilter()
|
||||||
|
{
|
||||||
|
// The operator pastes a Guid into the Parent execution ID box; Apply must
|
||||||
|
// map it straight onto AuditLogQueryFilter.ParentExecutionId.
|
||||||
|
var parentExecutionId = Guid.Parse("11112222-3333-4444-5555-666677778888");
|
||||||
|
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-parent-execution-id\"] input").Change(parentExecutionId.ToString());
|
||||||
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(parentExecutionId, captured!.ParentExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_WithBlankOrUnparseableParentExecutionId_LeavesFilterParentExecutionIdNull()
|
||||||
|
{
|
||||||
|
// Lax parsing: a blank box yields no constraint; garbage text likewise.
|
||||||
|
AuditLogQueryFilter? captured = null;
|
||||||
|
var cut = Render<AuditFilterBar>(p => p
|
||||||
|
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||||
|
|
||||||
|
// Blank — never typed into.
|
||||||
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Null(captured!.ParentExecutionId);
|
||||||
|
|
||||||
|
// Unparseable paste — still dropped, no error.
|
||||||
|
cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change("not-a-guid");
|
||||||
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
Assert.Null(captured!.ParentExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
|
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
private readonly IAuditLogQueryService _service;
|
private readonly IAuditLogQueryService _service;
|
||||||
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
|
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
|
||||||
|
|
||||||
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null)
|
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
EventId = Guid.NewGuid(),
|
EventId = Guid.NewGuid(),
|
||||||
@@ -34,6 +34,7 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
Target = "demo-target",
|
Target = "demo-target",
|
||||||
Actor = "tester",
|
Actor = "tester",
|
||||||
ExecutionId = executionId,
|
ExecutionId = executionId,
|
||||||
|
ParentExecutionId = parentExecutionId,
|
||||||
DurationMs = 42,
|
DurationMs = 42,
|
||||||
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
|
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
|
||||||
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
|
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
|
||||||
@@ -165,6 +166,49 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
|
Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_IncludesParentExecutionIdColumn()
|
||||||
|
{
|
||||||
|
StubPage(new List<AuditEvent>
|
||||||
|
{
|
||||||
|
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||||
|
});
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
// The ParentExecutionId column header is present alongside the spec columns.
|
||||||
|
Assert.Contains("data-test=\"col-header-ParentExecutionId\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParentExecutionId_NonNullRow_RendersShortMonospaceValue()
|
||||||
|
{
|
||||||
|
var parentExecutionId = Guid.Parse("fedcba98-2222-3333-4444-555555555555");
|
||||||
|
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: parentExecutionId);
|
||||||
|
StubPage(new[] { row });
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
var cell = cut.Find($"[data-test=\"parent-execution-id-{row.EventId}\"]");
|
||||||
|
// Short form: first 8 hex digits of the "N" form — mirrors ExecutionId.
|
||||||
|
Assert.Equal("fedcba98", cell.TextContent.Trim());
|
||||||
|
// Monospace presentation; full value retained in the title attribute.
|
||||||
|
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
|
||||||
|
Assert.Equal(parentExecutionId.ToString(), cell.GetAttribute("title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParentExecutionId_NullRow_RendersBlankPlaceholder_NoParentExecutionIdCell()
|
||||||
|
{
|
||||||
|
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: null);
|
||||||
|
StubPage(new[] { row });
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
// A null ParentExecutionId renders the em-dash placeholder, not a value cell.
|
||||||
|
Assert.Empty(cut.FindAll($"[data-test=\"parent-execution-id-{row.EventId}\"]"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Status_FailedRow_HasErrorBadgeClass()
|
public void Status_FailedRow_HasErrorBadgeClass()
|
||||||
{
|
{
|
||||||
@@ -193,7 +237,8 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
private static readonly string[] DefaultOrder =
|
private static readonly string[] DefaultOrder =
|
||||||
{
|
{
|
||||||
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
|
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
|
||||||
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
|
"Target", "Actor", "ExecutionId", "ParentExecutionId",
|
||||||
|
"DurationMs", "HttpStatus", "ErrorMessage",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static int HeaderIndex(string markup, string key)
|
private static int HeaderIndex(string markup, string key)
|
||||||
|
|||||||
@@ -89,6 +89,20 @@ public class AuditLogPageExportUrlTests
|
|||||||
Assert.Equal(exec.ToString(), query["executionId"]);
|
Assert.Equal(exec.ToString(), query["executionId"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildExportUrl_ParentExecutionIdSet_EmitsParentExecutionIdParam()
|
||||||
|
{
|
||||||
|
var parent = Guid.Parse("34343434-5656-7878-9090-121212121212");
|
||||||
|
var filter = new AuditLogQueryFilter(ParentExecutionId: parent);
|
||||||
|
|
||||||
|
var url = AuditLogPage.BuildExportUrl(filter);
|
||||||
|
|
||||||
|
Assert.StartsWith("/api/centralui/audit/export?", url);
|
||||||
|
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
|
||||||
|
Assert.Single(query);
|
||||||
|
Assert.Equal(parent.ToString(), query["parentExecutionId"]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
|
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -214,6 +214,45 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithParentExecutionIdParam_AppliesFilter_AndAutoLoads()
|
||||||
|
{
|
||||||
|
// The "View parent execution" drill-in (and operator-crafted URLs) land on
|
||||||
|
// /audit/log?parentExecutionId={id}. The page parses the Guid, builds an
|
||||||
|
// AuditLogQueryFilter with ParentExecutionId set, and auto-loads the grid.
|
||||||
|
var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb");
|
||||||
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||||
|
|
||||||
|
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin");
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
_queryService.Received().QueryAsync(
|
||||||
|
Arg.Is<AuditLogQueryFilter>(f => f.ParentExecutionId == parentExecutionId),
|
||||||
|
Arg.Any<AuditLogPaging?>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
|
||||||
|
{
|
||||||
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
|
||||||
|
var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Admin");
|
||||||
|
|
||||||
|
// An unparseable parentExecutionId leaves ParentExecutionId null. With no
|
||||||
|
// other filter params present the page renders but does NOT call the query
|
||||||
|
// service.
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
|
||||||
|
_queryService.DidNotReceive().QueryAsync(
|
||||||
|
Arg.Any<AuditLogQueryFilter>(),
|
||||||
|
Arg.Any<AuditLogPaging?>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NavigateWithTargetParam_AppliesTargetFilter()
|
public void NavigateWithTargetParam_AppliesTargetFilter()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user