+ /// Paste-in ExecutionId filter — the operator pastes the universal per-run
+ /// correlation Guid. Stored as free text; lax-parses it
+ /// through so a blank or
+ /// unparseable value simply yields no constraint.
+ ///
+ public string ExecutionId { get; set; } = string.Empty;
+
public bool ErrorsOnly { get; set; }
///
@@ -114,6 +122,12 @@ public sealed class AuditQueryModel
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
+ // Lax-parse the pasted ExecutionId — blank or malformed text yields no
+ // constraint rather than an error, mirroring the optional-filter contract.
+ Guid? executionId = Guid.TryParse(ExecutionId, out var parsedExecutionId)
+ ? parsedExecutionId
+ : null;
+
return new AuditLogQueryFilter(
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
@@ -122,6 +136,7 @@ public sealed class AuditQueryModel
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,
+ ExecutionId: executionId,
FromUtc: fromUtc,
ToUtc: toUtc);
}
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
index af2fb0d..2e5c692 100644
--- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
@@ -83,6 +83,15 @@
@code {
+ // Compact display for Guid id columns: the first 8 hex digits, mirroring
+ // the drilldown drawer's ShortEventId presentation. The full value is kept
+ // in the cell's title attribute so it stays copy-paste accessible.
+ private static string ShortGuid(Guid value)
+ {
+ var n = value.ToString("N");
+ return n.Length >= 8 ? n[..8] : n;
+ }
+
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
{
switch (key)
@@ -111,6 +120,18 @@
case "Actor":
@(row.Actor ?? "—")
break;
+ case "ExecutionId":
+ @if (row.ExecutionId is { } executionId)
+ {
+
@ShortGuid(executionId)
+ }
+ else
+ {
+
—
+ }
+ break;
case "DurationMs":
@(row.DurationMs?.ToString() ?? "—")
break;
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs
index d6b08c4..1303628 100644
--- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs
@@ -9,9 +9,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
///
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
-/// Renders the 10 columns named in Component-AuditLog.md §10:
-/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs,
-/// HttpStatus, ErrorMessage. Talks to
+/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
+/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
+/// ErrorMessage — plus the ExecutionId per-run correlation column. Talks to
+///
/// — never to IAuditLogRepository directly — so tests can stub the data
/// source without standing up EF Core.
///
@@ -121,6 +122,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
("Status", "Status"),
("Target", "Target"),
("Actor", "Actor"),
+ ("ExecutionId", "ExecutionId"),
("DurationMs", "DurationMs"),
("HttpStatus", "HttpStatus"),
("ErrorMessage", "ErrorMessage"),
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
index bd2796e..9fb1d42 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
+++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
@@ -22,7 +22,8 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// ?actor=, ?site=, ?channel=, ?kind=, and the UI-only
/// ?instance= are read on initialization. Bundle E (M7-T13) extends
/// this with ?status= so the Health-dashboard Audit error-rate tile can
-/// drill in to ?status=Failed. When any param is present we allocate a
+/// drill in to ?status=Failed. The ExecutionId follow-up adds
+/// ?executionId= for the "View this execution" drill-in. When any param is present we allocate a
/// fresh and assign it to
/// , which kicks the results grid into auto-load
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
@@ -60,6 +61,16 @@ public partial class AuditLogPage
correlationId = parsedCorr;
}
+ // ?executionId= is the "View this execution" drill-in target — the
+ // universal per-run correlation value. Lax-parsed like ?correlationId=:
+ // an unparseable value is silently dropped (no constraint).
+ Guid? executionId = null;
+ if (query.TryGetValue("executionId", out var execValues)
+ && Guid.TryParse(execValues.ToString(), out var parsedExec))
+ {
+ executionId = parsedExec;
+ }
+
string? target = null;
if (query.TryGetValue("target", out var targetValues))
{
@@ -117,7 +128,7 @@ public partial class AuditLogPage
// 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
// to refine + Apply for those.
- if (correlationId is null && target is null && actor is null
+ if (correlationId is null && executionId is null && target is null && actor is null
&& sites is null && channels is null && kinds is null && statuses is null)
{
return;
@@ -130,7 +141,8 @@ public partial class AuditLogPage
SourceSiteIds: sites,
Target: target,
Actor: actor,
- CorrelationId: correlationId);
+ CorrelationId: correlationId,
+ ExecutionId: executionId);
}
///
@@ -236,6 +248,10 @@ public partial class AuditLogPage
{
parts.Add(new("correlationId", corr.ToString()));
}
+ if (filter.ExecutionId is { } exec)
+ {
+ parts.Add(new("executionId", exec.ToString()));
+ }
if (filter.FromUtc is { } from)
{
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs
index 31252af..4c2480d 100644
--- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs
@@ -65,6 +65,7 @@ internal static class AuditDataSeeder
string? target = null,
string? actor = null,
Guid? correlationId = null,
+ Guid? executionId = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
@@ -76,13 +77,13 @@ internal static class AuditDataSeeder
const string sql = @"
INSERT INTO [AuditLog]
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
- [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], [Status],
- [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
+ [ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
+ [Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
VALUES
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
- @sourceSiteId, NULL, NULL, @actor, @target, @status,
- @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
+ @executionId, @sourceSiteId, NULL, NULL, @actor, @target,
+ @status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
@responseSummary, 0, @extra, NULL);";
await using var connection = new SqlConnection(ConnectionString);
@@ -94,6 +95,7 @@ VALUES
cmd.Parameters.AddWithValue("@channel", channel);
cmd.Parameters.AddWithValue("@kind", kind);
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs
index 68305d0..f066cdb 100644
--- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs
@@ -24,6 +24,9 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// link relies on; verified by reproducing the link target directly because
/// seeding a notification visible to the report page requires the Akka query
/// path, not just an INSERT).
+/// - DrillInFromExecutionId_LandsOnAuditLogWithFilterContext — the
+/// ?executionId= drill-in (the drawer's "View this execution" action)
+/// auto-loads the grid filtered by ExecutionId.
/// - NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist —
/// the report page wires drill-in links when notifications are present.
/// - ExportCsv_LinkIsVisibleAndDownloads — Export CSV button gated on
@@ -289,6 +292,64 @@ public class AuditLogPageTests
}
}
+ [Fact]
+ public async Task DrillInFromExecutionId_LandsOnAuditLogWithFilterContext()
+ {
+ // Mirrors the correlationId drill-in: the "View this execution" drawer
+ // action navigates to /audit/log?executionId={ExecutionId}. We seed a row
+ // carrying that ExecutionId, hit the deep link directly, and assert the
+ // page deserializes the param and auto-loads the seeded row.
+ 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/exec-drill-in/{runId}/";
+ var executionId = Guid.NewGuid();
+ var eventId = Guid.NewGuid();
+ var now = DateTime.UtcNow;
+
+ try
+ {
+ await AuditDataSeeder.InsertAuditEventAsync(
+ eventId: eventId,
+ occurredAtUtc: now,
+ channel: "ApiOutbound",
+ kind: "ApiCall",
+ status: "Delivered",
+ target: targetPrefix + "endpoint",
+ executionId: executionId,
+ httpStatus: 200,
+ durationMs: 11);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ // The exact URL the drawer's "View this execution" button produces.
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={executionId}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ Assert.Contains($"executionId={executionId}", page.Url);
+ await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();
+
+ // Auto-load: the query-string drill-in resolves the ?executionId=
+ // filter on OnInitialized and the seeded row appears without an
+ // Apply click.
+ var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
+ await Assertions.Expect(seededRow).ToBeVisibleAsync();
+
+ // The ExecutionId column renders the row's short-form value.
+ var execCell = page.Locator($"[data-test='execution-id-{eventId}']");
+ await Assertions.Expect(execCell).ToBeVisibleAsync();
+ }
+ finally
+ {
+ await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
+ }
+ }
+
[Fact]
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs
index 53c7c2d..b82d944 100644
--- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs
@@ -40,6 +40,7 @@ public class AuditDrilldownDrawerTests : BunitContext
string? responseSummary = null,
string? extra = null,
Guid? correlationId = null,
+ Guid? executionId = null,
string? errorMessage = null,
string? errorDetail = null,
string? target = "demo-target")
@@ -51,6 +52,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Channel = channel,
Kind = kind,
CorrelationId = correlationId,
+ ExecutionId = executionId,
SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx",
@@ -216,6 +218,46 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains(corr.ToString(), nav.Uri);
}
+ [Fact]
+ public void Drawer_NullExecutionId_HidesViewThisExecutionButton()
+ {
+ var ev = MakeEvent(executionId: null);
+
+ var cut = Render(p => p
+ .Add(c => c.Event, ev)
+ .Add(c => c.IsOpen, true));
+
+ Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup);
+ }
+
+ [Fact]
+ public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton()
+ {
+ var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"));
+
+ var cut = Render(p => p
+ .Add(c => c.Event, ev)
+ .Add(c => c.IsOpen, true));
+
+ Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
+ }
+
+ [Fact]
+ public void ViewThisExecution_Navigates_WithExecutionIdQueryString()
+ {
+ var exec = Guid.Parse("dddddddd-cccc-bbbb-aaaa-999999999999");
+ var ev = MakeEvent(executionId: exec);
+
+ var cut = Render(p => p
+ .Add(c => c.Event, ev)
+ .Add(c => c.IsOpen, true));
+
+ cut.Find("[data-test=\"view-this-execution\"]").Click();
+
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
+ }
+
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
index d62918d..3c3b202 100644
--- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
@@ -61,6 +61,7 @@ public class AuditFilterBarTests : BunitContext
"data-test=\"filter-script\"",
"data-test=\"filter-target\"",
"data-test=\"filter-actor\"",
+ "data-test=\"filter-execution-id\"",
"data-test=\"filter-errors-only\"",
};
foreach (var marker in markers)
@@ -178,6 +179,42 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains(AuditStatus.Failed, captured.Statuses);
}
+ [Fact]
+ public void Apply_WithPastedExecutionId_MapsThroughToFilter()
+ {
+ // The operator pastes a Guid into the Execution ID box; Apply must map it
+ // straight onto AuditLogQueryFilter.ExecutionId.
+ var executionId = Guid.Parse("99999999-8888-7777-6666-555555555555");
+ AuditLogQueryFilter? captured = null;
+ var cut = Render(p => p
+ .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f)));
+
+ cut.Find("[data-test=\"filter-execution-id\"] input").Change(executionId.ToString());
+ cut.Find("[data-test=\"filter-apply\"]").Click();
+
+ Assert.NotNull(captured);
+ Assert.Equal(executionId, captured!.ExecutionId);
+ }
+
+ [Fact]
+ public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull()
+ {
+ // Lax parsing: a blank box yields no constraint; garbage text likewise.
+ AuditLogQueryFilter? captured = null;
+ var cut = Render(p => p
+ .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f)));
+
+ // Blank — never typed into.
+ cut.Find("[data-test=\"filter-apply\"]").Click();
+ Assert.NotNull(captured);
+ Assert.Null(captured!.ExecutionId);
+
+ // Unparseable paste — still dropped, no error.
+ cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid");
+ cut.Find("[data-test=\"filter-apply\"]").Click();
+ Assert.Null(captured!.ExecutionId);
+ }
+
[Fact]
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs
index ab30a70..4ba39d5 100644
--- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs
@@ -22,7 +22,7 @@ public class AuditResultsGridTests : BunitContext
private readonly IAuditLogQueryService _service;
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")
+ private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null)
=> new()
{
EventId = Guid.NewGuid(),
@@ -33,6 +33,7 @@ public class AuditResultsGridTests : BunitContext
SourceSiteId = site,
Target = "demo-target",
Actor = "tester",
+ ExecutionId = executionId,
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
@@ -121,6 +122,49 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(target.EventId, captured!.EventId);
}
+ [Fact]
+ public void Render_IncludesExecutionIdColumn()
+ {
+ StubPage(new List
+ {
+ MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
+ });
+
+ var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
+
+ // The ExecutionId column header is present alongside the spec columns.
+ Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup);
+ }
+
+ [Fact]
+ public void ExecutionId_NonNullRow_RendersShortMonospaceValue()
+ {
+ var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
+ var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId);
+ StubPage(new[] { row });
+
+ var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
+
+ var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]");
+ // Short form: first 8 hex digits of the "N" form.
+ Assert.Equal("abcdef01", cell.TextContent.Trim());
+ // Monospace presentation; full value retained in the title attribute.
+ Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
+ Assert.Equal(executionId.ToString(), cell.GetAttribute("title"));
+ }
+
+ [Fact]
+ public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell()
+ {
+ var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null);
+ StubPage(new[] { row });
+
+ var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
+
+ // A null ExecutionId renders the em-dash placeholder, not a value cell.
+ Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
+ }
+
[Fact]
public void Status_FailedRow_HasErrorBadgeClass()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs
index 60e3d56..02892a7 100644
--- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs
@@ -75,6 +75,20 @@ public class AuditLogPageExportUrlTests
Assert.Equal("Notification", query["channel"]);
}
+ [Fact]
+ public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam()
+ {
+ var exec = Guid.Parse("12121212-3434-5656-7878-909090909090");
+ var filter = new AuditLogQueryFilter(ExecutionId: exec);
+
+ 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(exec.ToString(), query["executionId"]);
+ }
+
[Fact]
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
index 328048e..55062dd 100644
--- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
@@ -176,6 +176,44 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
+ [Fact]
+ public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads()
+ {
+ // The "View this execution" drill-in lands on /audit/log?executionId={id}.
+ // The page parses the Guid, builds an AuditLogQueryFilter with ExecutionId
+ // set, and auto-loads the grid.
+ var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+ _queryService = Substitute.For();
+ _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+
+ var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin");
+
+ cut.WaitForAssertion(() =>
+ {
+ _queryService.Received().QueryAsync(
+ Arg.Is(f => f.ExecutionId == executionId),
+ Arg.Any(),
+ Arg.Any());
+ });
+ }
+
+ [Fact]
+ public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
+ {
+ _queryService = Substitute.For();
+
+ var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin");
+
+ // An unparseable executionId leaves ExecutionId 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(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
[Fact]
public void NavigateWithTargetParam_AppliesTargetFilter()
{