refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,157 @@
using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
/// <summary>
/// Direct-SQL seeding helper for the Audit Log Playwright E2E tests (#23 M7-T16).
///
/// <para>
/// The Playwright suite runs against the live Docker cluster (the same one that
/// answers <c>http://localhost:9000</c>), which talks to the <c>ScadaBridgeConfig</c>
/// database on <c>localhost:1433</c>. <c>infra/mssql/seed-config.sql</c> is off
/// limits per the task's strict rules, so each test inserts its own
/// <c>AuditLog</c> rows at setup time and best-effort deletes them at teardown.
/// </para>
///
/// <para>
/// Rows are tagged with a unique <c>Target</c> prefix derived from the test
/// name + a GUID so the teardown <c>DELETE</c> never touches rows the cluster
/// itself produced. The <c>OccurredAtUtc</c> is pinned to "now" so the default
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit.AuditTimeRangePreset.LastHour"/>
/// time-range filter still sees the row after Apply.
/// </para>
///
/// <para>
/// Connection string mirrors the Docker cluster's <c>scadabridge_app</c> account
/// from <c>docker/central-node-a/appsettings.Central.json</c>, with the host
/// pointed at the host-exposed port (<c>localhost:1433</c>). The
/// <c>SCADALINK_PLAYWRIGHT_DB</c> env var lets CI override the connection
/// without recompiling.
/// </para>
/// </summary>
internal static class AuditDataSeeder
{
private const string DefaultConnectionString =
"Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
private const string EnvVar = "SCADALINK_PLAYWRIGHT_DB";
/// <summary>
/// Connection string for the running cluster's configuration DB. Resolved
/// from <c>SCADALINK_PLAYWRIGHT_DB</c> when set, otherwise the local docker
/// dev defaults.
/// </summary>
public static string ConnectionString
{
get
{
var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
}
}
/// <summary>
/// Inserts a single audit row into <c>AuditLog</c>. All optional fields are
/// nullable so individual tests can shape the row to whatever payload they
/// need for their drawer/grid assertions.
/// </summary>
public static async Task InsertAuditEventAsync(
Guid eventId,
DateTime occurredAtUtc,
string channel,
string kind,
string status,
string? sourceSiteId = null,
string? target = null,
string? actor = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
string? requestSummary = null,
string? responseSummary = null,
string? extra = null,
CancellationToken ct = default)
{
const string sql = @"
INSERT INTO [AuditLog]
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
[ExecutionId], [ParentExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
[Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
VALUES
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
@executionId, @parentExecutionId, @sourceSiteId, NULL, NULL, @actor, @target,
@status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
@responseSummary, 0, @extra, NULL);";
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
await using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@eventId", eventId);
cmd.Parameters.AddWithValue("@occurredAtUtc", occurredAtUtc);
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("@parentExecutionId", (object?)parentExecutionId ?? 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);
cmd.Parameters.AddWithValue("@status", status);
cmd.Parameters.AddWithValue("@httpStatus", (object?)httpStatus ?? DBNull.Value);
cmd.Parameters.AddWithValue("@durationMs", (object?)durationMs ?? DBNull.Value);
cmd.Parameters.AddWithValue("@errorMessage", (object?)errorMessage ?? DBNull.Value);
cmd.Parameters.AddWithValue("@requestSummary", (object?)requestSummary ?? DBNull.Value);
cmd.Parameters.AddWithValue("@responseSummary", (object?)responseSummary ?? DBNull.Value);
cmd.Parameters.AddWithValue("@extra", (object?)extra ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
/// <summary>
/// Best-effort cleanup. Deletes every <c>AuditLog</c> row whose <c>Target</c>
/// starts with <paramref name="targetPrefix"/>. Swallows all errors — a
/// stuck row carrying a random GUID suffix does not collide with future
/// runs and tests should not fail teardown.
/// </summary>
public static async Task DeleteByTargetPrefixAsync(string targetPrefix, CancellationToken ct = default)
{
try
{
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
await using var cmd = connection.CreateCommand();
cmd.CommandText = "DELETE FROM [AuditLog] WHERE [Target] LIKE @prefix";
cmd.Parameters.AddWithValue("@prefix", targetPrefix + "%");
await cmd.ExecuteNonQueryAsync(ct);
}
catch
{
// Best-effort — the prefix carries a GUID so the rows are unique to
// this test run and won't collide on the next pass.
}
}
/// <summary>
/// Probe whether the configuration DB is reachable. Tests gate their
/// per-test setup on this; when the cluster is down the test fails with a
/// clear "MSSQL unavailable" message instead of an opaque SqlException.
/// </summary>
public static async Task<bool> IsAvailableAsync(CancellationToken ct = default)
{
try
{
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
return true;
}
catch
{
return false;
}
}
}
@@ -0,0 +1,289 @@
using Microsoft.Playwright;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
/// <summary>
/// End-to-end coverage for the Audit Log results-grid column UX (#23
/// follow-ups Task 10): drag-to-resize and drag-to-reorder columns, with the
/// chosen widths + order persisted in the browser's <c>sessionStorage</c>.
///
/// <para>
/// The drag interaction is browser-side (<c>wwwroot/js/audit-grid.js</c>), so
/// Playwright — not bUnit — is the right tool: bUnit cannot drive the native
/// HTML5 drag-and-drop or pointer-capture resize. Each test seeds one
/// <c>AuditLog</c> row via <see cref="AuditDataSeeder"/> so the grid has a
/// header row to act on, then best-effort deletes it.
/// </para>
///
/// <para>
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
/// matching the established <see cref="SiteCalls.SiteCallsPageTests"/> idiom.
/// </para>
/// </summary>
[Collection("Playwright")]
public class AuditGridColumnTests
{
private const string AuditLogUrl = "/audit/log";
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
private const string DbUnavailableSkipReason =
"AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.";
private readonly PlaywrightFixture _fixture;
public AuditGridColumnTests(PlaywrightFixture fixture)
{
_fixture = fixture;
}
/// <summary>
/// Seeds one audit row, opens the Audit Log page, and clicks Apply so the
/// results grid renders a header row the column tests can act on.
/// </summary>
private async Task<IPage> OpenGridWithSeededRowAsync(string targetPrefix, Guid eventId)
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: DateTime.UtcNow,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "endpoint",
httpStatus: 200,
durationMs: 25);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{AuditLogUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Apply with no chips — the default LastHour range matches the fresh row.
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(row).ToBeVisibleAsync();
return page;
}
/// <summary>Pixel width of a header cell, measured from its bounding box.</summary>
private static async Task<double> HeaderWidthAsync(IPage page, string columnKey)
{
var box = await page.Locator($"[data-col-key='{columnKey}']").BoundingBoxAsync();
Assert.NotNull(box);
return box!.Width;
}
/// <summary>The ordered list of column keys as currently rendered in the header.</summary>
private static async Task<IReadOnlyList<string>> HeaderOrderAsync(IPage page)
{
return await page.Locator("thead th[data-col-key]")
.EvaluateAllAsync<string[]>("els => els.map(e => e.getAttribute('data-col-key'))");
}
/// <summary>
/// Polls until <paramref name="storageKey"/> has been written to
/// <c>sessionStorage</c>. The grid persists a resize/reorder
/// asynchronously — the browser-side drag fires a fire-and-forget
/// JS→.NET invoke (<c>OnColumnResized</c>/<c>OnColumnReordered</c>), and
/// the .NET handler then round-trips back through JS interop to write
/// <c>sessionStorage</c>. A bare <c>getItem</c> immediately after the drag
/// races that round-trip; this waits for the key to actually land.
/// </summary>
private static async Task WaitForStorageKeyAsync(IPage page, string storageKey)
{
await page.WaitForFunctionAsync(
"key => sessionStorage.getItem(key) !== null", storageKey);
}
/// <summary>
/// Polls until the header's first column key equals <paramref name="expectedFirstKey"/>.
/// A drag-to-reorder re-renders the header asynchronously (the JS→.NET
/// <c>OnColumnReordered</c> invoke is fire-and-forget), so reading the
/// header order synchronously after <c>DragToAsync</c> can observe the
/// pre-reorder layout. This waits for the re-render to settle.
/// </summary>
private static async Task WaitForFirstColumnAsync(IPage page, string expectedFirstKey)
{
await page.WaitForFunctionAsync(
"key => { var th = document.querySelector('thead th[data-col-key]'); " +
"return th && th.getAttribute('data-col-key') === key; }",
expectedFirstKey);
}
[SkippableFact]
public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload()
{
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/grid-resize/{runId}/";
var eventId = Guid.NewGuid();
try
{
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
const string columnKey = "Target";
var before = await HeaderWidthAsync(page, columnKey);
// Drag the resize handle on the column's right edge 120px to the
// right. The handle is a thin strip; grab its centre and drag.
var handle = page.Locator($"[data-test='col-resize-{columnKey}']");
var handleBox = await handle.BoundingBoxAsync();
Assert.NotNull(handleBox);
var startX = handleBox!.X + handleBox.Width / 2;
var startY = handleBox.Y + handleBox.Height / 2;
await page.Mouse.MoveAsync(startX, startY);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(startX + 120, startY, new MouseMoveOptions { Steps = 8 });
await page.Mouse.UpAsync();
var after = await HeaderWidthAsync(page, columnKey);
Assert.True(after > before + 40,
$"Expected the {columnKey} column to widen after the resize drag (before={before}, after={after}).");
// The resize persists asynchronously: pointer-up fires a
// fire-and-forget JS→.NET OnColumnResized invoke, and the .NET
// handler then round-trips back through JS interop to write
// sessionStorage. Wait for that write to land before reloading —
// otherwise the reload races it and the restored grid falls back
// to the default width.
await WaitForStorageKeyAsync(page, "auditGrid:columnWidths");
// Reload: the persisted width is restored from sessionStorage.
await page.ReloadAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var afterReload = await HeaderWidthAsync(page, columnKey);
// Allow a small tolerance for sub-pixel layout rounding.
Assert.True(Math.Abs(afterReload - after) < 8,
$"Expected the resized width to survive a reload (after={after}, afterReload={afterReload}).");
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[SkippableFact]
public async Task ReorderDrag_MovesColumn_AndSurvivesReload()
{
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/grid-reorder/{runId}/";
var eventId = Guid.NewGuid();
try
{
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
var initialOrder = await HeaderOrderAsync(page);
// Default order opens with OccurredAtUtc first, Status fifth.
Assert.Equal("OccurredAtUtc", initialOrder[0]);
Assert.Contains("Status", initialOrder);
// Drag the Status header onto the OccurredAtUtc header — Status
// should move into the leading slot.
var source = page.Locator("[data-col-key='Status']");
var target = page.Locator("[data-col-key='OccurredAtUtc']");
await source.DragToAsync(target);
// The reorder re-renders the header asynchronously (fire-and-forget
// JS→.NET invoke); wait for it to settle before reading the order.
await WaitForFirstColumnAsync(page, "Status");
var afterOrder = await HeaderOrderAsync(page);
Assert.Equal("Status", afterOrder[0]);
Assert.True(afterOrder.ToList().IndexOf("Status") < afterOrder.ToList().IndexOf("OccurredAtUtc"),
"Expected Status to be reordered ahead of OccurredAtUtc.");
// Reload: the persisted order is restored from sessionStorage on
// the grid's first render — wait for the header to reflect it.
await page.ReloadAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await WaitForFirstColumnAsync(page, "Status");
var afterReload = await HeaderOrderAsync(page);
Assert.Equal("Status", afterReload[0]);
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[SkippableFact]
public async Task ColumnOrderAndWidths_PersistAcrossReload_ViaSessionStorage()
{
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/grid-persist/{runId}/";
var eventId = Guid.NewGuid();
try
{
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
// Reorder then resize, then confirm sessionStorage carries both.
await page.Locator("[data-col-key='Status']")
.DragToAsync(page.Locator("[data-col-key='OccurredAtUtc']"));
// Wait for the reorder re-render to settle before measuring the
// resize handle, so the handle's bounding box is read off the
// post-reorder layout.
await WaitForFirstColumnAsync(page, "Status");
var handle = page.Locator("[data-test='col-resize-Target']");
var handleBox = await handle.BoundingBoxAsync();
Assert.NotNull(handleBox);
var startX = handleBox!.X + handleBox.Width / 2;
var startY = handleBox.Y + handleBox.Height / 2;
await page.Mouse.MoveAsync(startX, startY);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(startX + 90, startY, new MouseMoveOptions { Steps = 6 });
await page.Mouse.UpAsync();
// Both keys are written under the auditGrid: namespace — but the
// write is asynchronous: pointer-up fires a fire-and-forget
// OnColumnResized/OnColumnReordered JS→.NET invoke, and the .NET
// handler then round-trips back through JS interop to call
// auditGrid.save. Reading sessionStorage synchronously right after
// Mouse.UpAsync races that round-trip, so poll for both keys to
// land before asserting on them.
await WaitForStorageKeyAsync(page, "auditGrid:columnOrder");
await WaitForStorageKeyAsync(page, "auditGrid:columnWidths");
var orderJson = await page.EvaluateAsync<string?>(
"() => sessionStorage.getItem('auditGrid:columnOrder')");
var widthsJson = await page.EvaluateAsync<string?>(
"() => sessionStorage.getItem('auditGrid:columnWidths')");
Assert.NotNull(orderJson);
Assert.Contains("Status", orderJson!);
Assert.NotNull(widthsJson);
Assert.Contains("Target", widthsJson!);
// After a reload the restored grid reflects the stored order. The
// restore happens on the grid's first render (LoadPersistedState →
// StateHasChanged), so wait for the header to reflect it.
await page.ReloadAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await WaitForFirstColumnAsync(page, "Status");
var restoredOrder = await HeaderOrderAsync(page);
Assert.Equal("Status", restoredOrder[0]);
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
}
@@ -0,0 +1,713 @@
using Microsoft.Playwright;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
/// <summary>
/// End-to-end coverage for the central Audit Log surface (#23 M7-T16 / Bundle H).
///
/// <para>
/// Each test seeds its own <c>AuditLog</c> rows directly into the running cluster's
/// configuration database via <see cref="AuditDataSeeder"/>, exercises the UI
/// through Playwright against <c>http://scadabridge-traefik</c>, then best-effort
/// deletes the rows by their <c>Target</c> prefix. The seed/cleanup pattern keeps
/// each test self-contained without touching <c>infra/mssql/seed-config.sql</c>.
/// </para>
///
/// <para>
/// Scenarios covered (per the M7-T16 brief):
/// <list type="bullet">
/// <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
/// auto-loads the grid (the exact path the Notifications "View audit history"
/// 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).</item>
/// <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
/// <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
/// 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>DoubleClickTreeNode_OpensExecutionRowModal</c> — double-clicking a
/// node on the execution-tree page opens <c>ExecutionDetailModal</c>, walking
/// list → row → detail before closing.</item>
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
/// the report page wires drill-in links when notifications are present.</item>
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
/// the AuditExport policy, click initiates a download.</item>
/// <item><c>PermissionGating_DesignerWithoutOperationalAudit_SeesNotAuthorized</c>
/// — the page-level <c>[Authorize(Policy = OperationalAudit)]</c> gate blocks a
/// Design-only user.</item>
/// </list>
/// </para>
/// </summary>
[Collection("Playwright")]
public class AuditLogPageTests
{
private readonly PlaywrightFixture _fixture;
public AuditLogPageTests(PlaywrightFixture fixture)
{
_fixture = fixture;
}
[Fact]
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
// surfaces a setup gap explicitly rather than as an opaque SqlException.
if (!await AuditDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException(
"AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/filter-narrow/{runId}/";
var apiEventId = Guid.NewGuid();
var dbEventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
// One ApiOutbound row, one DbOutbound row — distinct Targets so the
// grid renders predictable rows we can assert on by data-test id.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: apiEventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "api",
httpStatus: 200,
durationMs: 42);
await AuditDataSeeder.InsertAuditEventAsync(
eventId: dbEventId,
occurredAtUtc: now,
channel: "DbOutbound",
kind: "DbWrite",
status: "Delivered",
target: targetPrefix + "db",
durationMs: 17);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Pre-Apply, both rows are absent because the grid stays empty until
// 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 filter).
var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']");
var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
await Assertions.Expect(apiRow).ToBeVisibleAsync();
Assert.Equal(0, await dbRow.CountAsync());
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task DrilldownDrawer_JsonPrettyPrintsRequestBody()
{
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/drilldown-json/{runId}/";
var eventId = Guid.NewGuid();
var now = DateTime.UtcNow;
var requestJson = "{\"method\":\"POST\",\"headers\":{\"X-Api-Key\":\"abc123\"},\"body\":{\"unit\":\"pump-7\",\"value\":42.5}}";
try
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "endpoint",
httpStatus: 200,
durationMs: 31,
requestSummary: requestJson);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Apply with no chips picked — default time-range LastHour matches the
// freshly-seeded row.
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(row).ToBeVisibleAsync();
await row.ClickAsync();
var drawer = page.Locator("[data-test='audit-drilldown-drawer']");
await Assertions.Expect(drawer).ToBeVisibleAsync();
// The Request body section is rendered as a <pre> with the indented
// JSON. Pretty-printed JSON inserts newlines + spaces — the verbatim
// single-line string we seeded has neither. Asserting on "headers"
// being followed by a colon+newline confirms System.Text.Json's
// WriteIndented produced the body, not the raw RequestSummary.
var requestBody = page.Locator("[data-test='request-body'] pre");
await Assertions.Expect(requestBody).ToBeVisibleAsync();
var bodyText = await requestBody.TextContentAsync();
Assert.NotNull(bodyText);
// Pretty-printed JSON contains a newline after the opening '{'.
Assert.Contains("\n", bodyText);
// The first-level keys are indented (System.Text.Json uses 2 spaces).
Assert.Contains(" \"method\"", bodyText);
// And the inner object's body is also pretty-printed (nested indent).
Assert.Contains(" \"unit\"", bodyText);
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task CopyAsCurlButton_IsVisibleAndClickableForApiInbound()
{
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/curl-button/{runId}/";
var eventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: now,
channel: "ApiInbound",
kind: "InboundRequest",
status: "Delivered",
target: targetPrefix + "method",
actor: "playwright-test-key",
httpStatus: 200,
durationMs: 12,
requestSummary: "{\"method\":\"POST\",\"headers\":{\"X-API-Key\":\"redacted\"},\"body\":{\"ping\":true}}");
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(row).ToBeVisibleAsync();
await row.ClickAsync();
// The Copy-as-cURL button is only rendered for API channels — the
// drawer's IsApiChannel guard. We assert visibility + clickability
// only; clipboard content varies between headless and headed runs.
var curlButton = page.Locator("[data-test='copy-as-curl']");
await Assertions.Expect(curlButton).ToBeVisibleAsync();
await Assertions.Expect(curlButton).ToBeEnabledAsync();
// Clicking should not throw or crash the page — clipboard interop
// failures are swallowed by CopyCurl(). The page stays alive.
await curlButton.ClickAsync();
await Assertions.Expect(page.Locator("[data-test='audit-drilldown-drawer']")).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task DrillInFromCorrelationId_LandsOnAuditLogWithFilterContext()
{
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/drill-in/{runId}/";
var correlationId = Guid.NewGuid();
var eventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: now,
channel: "Notification",
kind: "NotifySend",
status: "Delivered",
target: targetPrefix + "ops-list",
correlationId: correlationId,
durationMs: 8);
var page = await _fixture.NewAuthenticatedPageAsync();
// This is the exact URL the Notifications "View audit history" link
// produces: /audit/log?correlationId={NotificationId}. We assert the
// drill-in lands on the Audit Log page, the query-string survives,
// and the audit results grid surface is rendered (the filter bar +
// grid are present, so an operator can refine + Apply from here).
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?correlationId={correlationId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Assert.Contains($"correlationId={correlationId}", 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();
// Bundle D auto-load: the query-string drill-in populates the grid
// WITHOUT an Apply click. The grid resolves the ?correlationId= filter
// on OnInitialized and the seeded row appears.
//
// This was previously blocked by an EF "A second operation was started
// on this context instance" error — the page-level auto-load raced
// AuditFilterBar.GetAllSitesAsync() on the shared scoped Blazor
// DbContext. AuditLogQueryService now opens its own DI scope per query
// (scope-per-query), so the auto-load no longer contends with the
// filter bar's site enumeration and the assertion is restored.
var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(seededRow).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[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 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();
// The drawer's NavigateTo is a same-page (query-string-only) Blazor
// navigation: it pushes history.pushState over the SignalR circuit
// rather than triggering a document load, so WaitForLoadState would
// return before the URL settles. WaitForURLAsync is the correct wait
// primitive for SPA/pushState navigations.
await page.WaitForURLAsync($"**/audit/log?executionId={parentExecutionId}");
// 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]
public async Task DrillInToExecutionChain_RendersTree_AndNodeClickFiltersGrid()
{
// Audit Log ParentExecutionId feature, Task 10: the drawer's "View
// execution chain" action opens /audit/execution-tree?executionId={id}.
// We seed a spawner row + a child row, open the child's drawer, click
// "View execution chain", assert the tree renders BOTH executions, then
// click the spawner node and assert the Audit Log grid filters to it.
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-chain-tree/{runId}/";
var parentExecutionId = Guid.NewGuid();
var childExecutionId = Guid.NewGuid();
var spawnerEventId = Guid.NewGuid();
var childEventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
// Spawner execution's own row.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: spawnerEventId,
occurredAtUtc: now,
channel: "ApiInbound",
kind: "InboundRequest",
status: "Delivered",
target: targetPrefix + "spawner",
executionId: parentExecutionId,
httpStatus: 200,
durationMs: 7);
// Child (spawned) row — links to the spawner via ParentExecutionId.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: childEventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "child",
executionId: childExecutionId,
parentExecutionId: parentExecutionId,
httpStatus: 200,
durationMs: 13);
var page = await _fixture.NewAuthenticatedPageAsync();
// Open the child row's drawer via its ExecutionId filter.
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={childExecutionId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var childRow = page.Locator($"[data-test='grid-row-{childEventId}']");
await Assertions.Expect(childRow).ToBeVisibleAsync();
await childRow.ClickAsync();
// "View execution chain" opens the tree view.
var viewChain = page.Locator("[data-test='view-execution-chain']");
await Assertions.Expect(viewChain).ToBeVisibleAsync();
await viewChain.ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The tree page rendered both executions as nodes.
Assert.Contains($"executionId={childExecutionId}", page.Url);
await Assertions.Expect(page.Locator($"[data-test='tree-node-{parentExecutionId}']")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator($"[data-test='tree-node-{childExecutionId}']")).ToBeVisibleAsync();
// Clicking the spawner node's link filters the Audit Log to its rows.
await page.Locator($"[data-test='tree-node-link-{parentExecutionId}']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Assert.Contains($"executionId={parentExecutionId}", page.Url);
await Assertions.Expect(page.Locator($"[data-test='grid-row-{spawnerEventId}']")).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task DoubleClickTreeNode_OpensExecutionRowModal()
{
// Execution-Tree Node Detail Modal feature, Task 5: double-clicking a
// node on the /audit/execution-tree page opens ExecutionDetailModal —
// a modal listing that execution's audit rows, with click-through to
// each row's full <AuditEventDetail> view. We seed ONE execution with
// TWO audit rows (so the modal opens to the list view, not straight to
// a single-row detail), open the tree, double-click the node, walk
// list → row → detail, then close the modal.
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-node-modal/{runId}/";
var executionId = Guid.NewGuid();
var inboundEventId = Guid.NewGuid();
var outboundEventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
// Two rows sharing the same ExecutionId — an inbound request and an
// outbound call it made. The shared ExecutionId makes the tree node
// multi-row, so the modal lands on the list view.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: inboundEventId,
occurredAtUtc: now,
channel: "ApiInbound",
kind: "InboundRequest",
status: "Delivered",
target: targetPrefix + "inbound",
executionId: executionId,
httpStatus: 200,
durationMs: 9);
await AuditDataSeeder.InsertAuditEventAsync(
eventId: outboundEventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "outbound",
executionId: executionId,
httpStatus: 200,
durationMs: 21);
var page = await _fixture.NewAuthenticatedPageAsync();
// Open the execution tree directly for the seeded execution.
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/execution-tree?executionId={executionId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The seeded execution renders as a tree node.
var nodeBody = page.Locator($"[data-test='tree-node-{executionId}'] .execution-tree-body");
await Assertions.Expect(nodeBody).ToBeVisibleAsync();
// Double-clicking the node body raises ExecutionTree's @ondblclick,
// which is a Blazor Server (InteractiveServer) handler — it only
// fires once the SignalR circuit is live. NetworkIdle can settle
// before the circuit connects, so a single early DblClick can be
// dropped. Retry the double-click until the modal appears.
var modal = page.Locator("[data-test='execution-detail-modal']");
for (var attempt = 0; attempt < 10 && await modal.CountAsync() == 0; attempt++)
{
await nodeBody.DblClickAsync();
try
{
await modal.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 1000 });
}
catch (TimeoutException)
{
// Circuit not connected yet — loop and re-issue the dblclick.
}
}
await Assertions.Expect(modal).ToBeVisibleAsync();
// The modal opens on the list view — one button per audit row.
var inboundRow = page.Locator($"[data-test='execution-detail-row-{inboundEventId}']");
var outboundRow = page.Locator($"[data-test='execution-detail-row-{outboundEventId}']");
await Assertions.Expect(inboundRow).ToBeVisibleAsync();
await Assertions.Expect(outboundRow).ToBeVisibleAsync();
// Clicking a row switches the modal to that row's full detail —
// the shared <AuditEventDetail> field block renders.
await outboundRow.ClickAsync();
await Assertions.Expect(page.Locator("[data-test='drawer-fields']")).ToBeVisibleAsync();
// Closing the modal tears it down. The close click round-trips
// over the SignalR circuit before the @if(IsOpen) block re-renders
// away, so use the auto-retrying assertion rather than a bare
// CountAsync.
await page.Locator("[data-test='execution-detail-close']").ClickAsync();
await Assertions.Expect(modal).ToHaveCountAsync(0);
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
{
// Lighter-weight than the auto-load test above: we don't need MSSQL,
// because we only verify that the Notifications page is reachable and
// is gated by the right policy — the link target itself is exercised by
// DrillInFromCorrelationId_AutoLoadsAuditLog. If the notifications list
// is empty (no seed) we still validate the empty-state markup is in
// place; if rows are present we confirm at least one "View audit history"
// link is rendered.
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/notifications/report");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Page reachable for the multi-role user; the Notification Report page
// is Deployment-gated, so this also covers that authorization path.
Assert.Contains("/notifications/report", page.Url);
await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
// When there are notifications visible, the "View audit history" link
// is rendered. When there are none, we just confirm the empty-state.
var auditLinks = page.Locator("a:has-text('View audit history')");
var linkCount = await auditLinks.CountAsync();
if (linkCount > 0)
{
var firstLink = auditLinks.First;
var href = await firstLink.GetAttributeAsync("href");
Assert.NotNull(href);
Assert.StartsWith("/audit/log?correlationId=", href);
}
// No notifications visible? The page still rendered correctly, which is
// all we can assert without exercising the Akka query path to seed one.
}
[Fact]
public async Task ExportCsv_LinkVisibleForAuditExportUserAndTriggersDownload()
{
// Multi-role test user holds Admin, which grants AuditExport — the
// Export-CSV anchor is gated by AuthorizeView Policy=AuditExport.
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var exportLink = page.Locator("a:has-text('Export CSV')");
await Assertions.Expect(exportLink).ToBeVisibleAsync();
var href = await exportLink.GetAttributeAsync("href");
Assert.NotNull(href);
Assert.StartsWith("/api/centralui/audit/export", href!);
// Click the link via Playwright's download API so the framework drains
// the response body instead of letting the browser leave a dangling
// navigation. The endpoint streams text/csv; we assert the suggested
// filename matches the audit-log-{timestamp}.csv pattern from
// AuditExportEndpoints.HandleExportAsync.
var download = await page.RunAndWaitForDownloadAsync(async () =>
{
await exportLink.ClickAsync();
});
Assert.NotNull(download);
Assert.StartsWith("audit-log-", download.SuggestedFilename);
Assert.EndsWith(".csv", download.SuggestedFilename);
}
[Fact]
public async Task PermissionGating_DesignerWithoutOperationalAudit_IsDeniedAccess()
{
// The designer LDAP user holds only the Design role, which does NOT
// grant OperationalAudit (Component-AuditLog.md §Authorization +
// AuthorizationPolicies.OperationalAuditRoles). The page-level
// [Authorize(Policy = OperationalAudit)] attribute is enforced by the
// ASP.NET Core authorization middleware, which redirects an
// authenticated-but-unauthorized user to the cookie scheme's
// AccessDenied path (/Account/AccessDenied?ReturnUrl=...) BEFORE the
// Blazor router runs — so the audit page never renders.
var page = await _fixture.NewAuthenticatedPageAsync("designer", "password");
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Access was denied: we landed on the AccessDenied path, not the audit
// page. The ReturnUrl carries the original /audit/log target.
Assert.Contains("/Account/AccessDenied", page.Url);
Assert.Contains("ReturnUrl", page.Url);
// The audit results grid never rendered for the unauthorized user.
Assert.Equal(0, await page.Locator("[data-test='audit-results-grid']").CountAsync());
}
}