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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class LoginTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public LoginTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnauthenticatedUser_RedirectsToLogin()
|
||||
{
|
||||
var page = await _fixture.NewPageAsync();
|
||||
|
||||
await page.GotoAsync(PlaywrightFixture.BaseUrl);
|
||||
|
||||
Assert.Contains("/login", page.Url);
|
||||
await Expect(page.Locator("h4")).ToHaveTextAsync("ScadaBridge");
|
||||
await Expect(page.Locator("#username")).ToBeVisibleAsync();
|
||||
await Expect(page.Locator("#password")).ToBeVisibleAsync();
|
||||
await Expect(page.Locator("button[type='submit']")).ToHaveTextAsync("Sign In");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidCredentials_AuthenticatesSuccessfully()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
// Should be on the dashboard, not the login page
|
||||
Assert.DoesNotContain("/login", page.Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidCredentials_ShowsError()
|
||||
{
|
||||
var page = await _fixture.NewPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
|
||||
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
|
||||
|
||||
// POST invalid credentials via fetch
|
||||
var status = await page.EvaluateAsync<int>(@"
|
||||
async () => {
|
||||
const resp = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=baduser&password=badpass',
|
||||
redirect: 'follow'
|
||||
});
|
||||
return resp.status;
|
||||
}
|
||||
");
|
||||
|
||||
// The login endpoint redirects to /login?error=... on failure.
|
||||
// Reload the page to see the error state.
|
||||
await page.ReloadAsync();
|
||||
Assert.Equal(200, status); // redirect followed to login page
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenEndpoint_ReturnsJwt()
|
||||
{
|
||||
var page = await _fixture.NewPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
|
||||
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
|
||||
|
||||
var result = await page.EvaluateAsync<JsonElement>(@"
|
||||
async () => {
|
||||
const resp = await fetch('/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=multi-role&password=password'
|
||||
});
|
||||
const json = await resp.json();
|
||||
return { status: resp.status, hasToken: !!json.access_token, username: json.username || '' };
|
||||
}
|
||||
");
|
||||
|
||||
Assert.Equal(200, result.GetProperty("status").GetInt32());
|
||||
Assert.True(result.GetProperty("hasToken").GetBoolean());
|
||||
Assert.Equal("multi-role", result.GetProperty("username").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenEndpoint_InvalidCredentials_Returns401()
|
||||
{
|
||||
var page = await _fixture.NewPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
|
||||
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
|
||||
|
||||
var status = await page.EvaluateAsync<int>(@"
|
||||
async () => {
|
||||
const resp = await fetch('/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=baduser&password=badpass'
|
||||
});
|
||||
return resp.status;
|
||||
}
|
||||
");
|
||||
|
||||
Assert.Equal(401, status);
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
Assertions.Expect(locator);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
||||
|
||||
/// <summary>
|
||||
/// E2E tests for the collapsible sidebar nav sections: sections are collapsed
|
||||
/// by default, a header toggle reveals a section's items, the state persists in
|
||||
/// the <c>scadabridge_nav</c> cookie across a full page reload, and navigating
|
||||
/// into a section auto-expands it.
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class NavCollapseTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public NavCollapseTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sections_AreCollapsedByDefault_AfterLogin()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
// The dashboard is sectionless, so no section is auto-expanded and the
|
||||
// cookie is empty on a fresh context — every section toggle is collapsed.
|
||||
await Expect(page.Locator("button.nav-section-toggle[aria-expanded='true']"))
|
||||
.ToHaveCountAsync(0);
|
||||
// A sectioned link is therefore absent from the DOM.
|
||||
Assert.Equal(0, await page.Locator("nav a:has-text('Topology')").CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClickingSectionHeader_RevealsItsItems()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
var toggle = page.Locator("button.nav-section-toggle:has-text('Deployment')");
|
||||
|
||||
Assert.Equal(0, await page.Locator("nav a:has-text('Topology')").CountAsync());
|
||||
|
||||
await toggle.ClickAsync();
|
||||
|
||||
await Expect(toggle).ToHaveAttributeAsync("aria-expanded", "true");
|
||||
await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Topology" }))
|
||||
.ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollapseState_SurvivesPageReload()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.Locator("button.nav-section-toggle:has-text('Deployment')").ClickAsync();
|
||||
await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Topology" }))
|
||||
.ToBeVisibleAsync();
|
||||
|
||||
await page.ReloadAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The scadabridge_nav cookie restored the expanded Deployment section.
|
||||
await Expect(page.Locator("button.nav-section-toggle:has-text('Deployment')"))
|
||||
.ToHaveAttributeAsync("aria-expanded", "true");
|
||||
await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Topology" }))
|
||||
.ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NavigatingIntoCollapsedSection_AutoExpandsIt()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
var auditToggle = page.Locator("button.nav-section-toggle:has-text('Audit')");
|
||||
|
||||
// The Audit section starts collapsed.
|
||||
await Expect(auditToggle).ToHaveAttributeAsync("aria-expanded", "false");
|
||||
|
||||
// Navigate into the Audit section via an in-page link (SPA navigation,
|
||||
// which raises NavigationManager.LocationChanged) — the Configuration
|
||||
// Audit Log quick-action card on the dashboard.
|
||||
await page.Locator("a[href='/audit/configuration']").First.ClickAsync();
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/audit/configuration");
|
||||
|
||||
// The Audit nav section auto-expanded on arrival.
|
||||
await Expect(auditToggle).ToHaveAttributeAsync("aria-expanded", "true");
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
Assertions.Expect(locator);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class NavigationTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public NavigationTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dashboard_IsVisibleAfterLogin()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
// The nav sidebar should be visible with the brand. ToContainText, not
|
||||
// ToHaveText: the brand also carries the accent mark glyph (▮).
|
||||
await Expect(page.Locator(".brand")).ToContainTextAsync("ScadaBridge");
|
||||
// The nav should contain "Dashboard" link (exact match to avoid "Health Dashboard")
|
||||
await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Dashboard", Exact = true })).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Sites", "/admin/sites")]
|
||||
[InlineData("API Keys", "/admin/api-keys")]
|
||||
[InlineData("LDAP Mappings", "/admin/ldap-mappings")]
|
||||
public async Task AdminNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SMTP Configuration", "/notifications/smtp")]
|
||||
[InlineData("Notification Lists", "/notifications/lists")]
|
||||
[InlineData("Notification Report", "/notifications/report")]
|
||||
[InlineData("Notification KPIs", "/notifications/kpis")]
|
||||
public async Task NotificationsNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Templates", "/design/templates")]
|
||||
[InlineData("Shared Scripts", "/design/shared-scripts")]
|
||||
[InlineData("Connections", "/design/connections")]
|
||||
[InlineData("External Systems", "/design/external-systems")]
|
||||
public async Task DesignNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Topology", "/deployment/topology")]
|
||||
[InlineData("Deployments", "/deployment/deployments")]
|
||||
public async Task DeploymentNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Health Dashboard", "/monitoring/health")]
|
||||
[InlineData("Event Logs", "/monitoring/event-logs")]
|
||||
[InlineData("Parked Messages", "/monitoring/parked-messages")]
|
||||
public async Task MonitoringNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
private static async Task ClickNavAndWait(IPage page, string linkText, string expectedPath)
|
||||
{
|
||||
// Sections are collapsed by default — open them so the link is in the DOM.
|
||||
await PlaywrightFixture.ExpandAllNavSectionsAsync(page);
|
||||
await page.Locator($"nav a:has-text('{linkText}')").ClickAsync();
|
||||
await PlaywrightFixture.WaitForPathAsync(page, expectedPath);
|
||||
Assert.Contains(expectedPath, page.Url);
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
Assertions.Expect(locator);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fixture that manages the Playwright browser connection.
|
||||
/// Creates a single browser connection per test collection, reused across all tests.
|
||||
/// Requires the Playwright Docker container running at ws://localhost:3000.
|
||||
/// </summary>
|
||||
public class PlaywrightFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>
|
||||
/// Playwright Server WebSocket endpoint (Docker container on host port 3000).
|
||||
/// </summary>
|
||||
private const string PlaywrightWsEndpoint = "ws://localhost:3000";
|
||||
|
||||
/// <summary>
|
||||
/// Central UI base URL as seen from inside the Docker network.
|
||||
/// The browser runs in the Playwright container, so it uses the Docker hostname.
|
||||
/// </summary>
|
||||
public const string BaseUrl = "http://scadabridge-traefik";
|
||||
|
||||
/// <summary>Test LDAP credentials (multi-role user with Admin + Design + Deployment).</summary>
|
||||
public const string TestUsername = "multi-role";
|
||||
public const string TestPassword = "password";
|
||||
|
||||
public IPlaywright Playwright { get; private set; } = null!;
|
||||
public IBrowser Browser { get; private set; } = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
|
||||
Browser = await Playwright.Chromium.ConnectAsync(PlaywrightWsEndpoint);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await Browser.CloseAsync();
|
||||
Playwright.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new browser context and page. Each test gets an isolated session.
|
||||
/// </summary>
|
||||
public async Task<IPage> NewPageAsync()
|
||||
{
|
||||
var context = await Browser.NewContextAsync();
|
||||
return await context.NewPageAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page and log in with the default multi-role test user.
|
||||
/// </summary>
|
||||
public Task<IPage> NewAuthenticatedPageAsync() =>
|
||||
NewAuthenticatedPageAsync(TestUsername, TestPassword);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page and log in with specific credentials.
|
||||
/// Uses JavaScript fetch() to POST to /auth/login from within the browser,
|
||||
/// which sets the auth cookie in the browser context. Then navigates to the dashboard.
|
||||
/// </summary>
|
||||
public async Task<IPage> NewAuthenticatedPageAsync(string username, string password)
|
||||
{
|
||||
var page = await NewPageAsync();
|
||||
|
||||
// Navigate to the login page first to establish the origin
|
||||
await page.GotoAsync($"{BaseUrl}/login");
|
||||
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
|
||||
|
||||
// POST to /auth/login via fetch() inside the browser.
|
||||
// This sets the auth cookie in the browser context automatically.
|
||||
var finalUrl = await page.EvaluateAsync<string>(@"
|
||||
async ([u, p]) => {
|
||||
const resp = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=' + encodeURIComponent(u) + '&password=' + encodeURIComponent(p),
|
||||
redirect: 'follow'
|
||||
});
|
||||
return resp.url;
|
||||
}
|
||||
", new object[] { username, password });
|
||||
|
||||
if (finalUrl.Contains("/login"))
|
||||
{
|
||||
throw new InvalidOperationException($"Login failed for '{username}' — redirected back to login: {finalUrl}");
|
||||
}
|
||||
|
||||
// Navigate to the dashboard — cookie authenticates us
|
||||
await page.GotoAsync(BaseUrl);
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for Blazor enhanced navigation to update the URL path.
|
||||
/// Blazor Server uses SignalR for client-side navigation (no full page reload),
|
||||
/// so standard WaitForURLAsync times out. This polls window.location instead.
|
||||
/// </summary>
|
||||
public static async Task WaitForPathAsync(IPage page, string path, string? excludePath = null, int timeoutMs = 10000)
|
||||
{
|
||||
var js = excludePath != null
|
||||
? $"() => window.location.pathname.includes('{path}') && !window.location.pathname.includes('{excludePath}')"
|
||||
: $"() => window.location.pathname.includes('{path}')";
|
||||
await page.WaitForFunctionAsync(js, null, new() { Timeout = timeoutMs });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expand every collapsed sidebar nav section. Nav sections are collapsed by
|
||||
/// default, so a section's links are not in the DOM until it is expanded.
|
||||
/// Call this after authenticating, before interacting with sectioned nav links.
|
||||
/// </summary>
|
||||
public static async Task ExpandAllNavSectionsAsync(IPage page)
|
||||
{
|
||||
var toggles = page.Locator("button.nav-section-toggle");
|
||||
int count = await toggles.CountAsync();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var toggle = toggles.Nth(i);
|
||||
if (await toggle.GetAttributeAsync("aria-expanded") == "false")
|
||||
{
|
||||
await toggle.ClickAsync();
|
||||
// Wait for the toggle's own state to flip so the Blazor
|
||||
// re-render has landed before moving to the next section.
|
||||
await Assertions.Expect(toggle).ToHaveAttributeAsync("aria-expanded", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("Playwright")]
|
||||
public class PlaywrightCollection : ICollectionFixture<PlaywrightFixture>;
|
||||
@@ -0,0 +1,237 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that navigation sections and links are shown/hidden based on the user's role.
|
||||
///
|
||||
/// LDAP test users (all passwords: "password"):
|
||||
/// admin → Admin only
|
||||
/// designer → Design only
|
||||
/// deployer → Deployment only
|
||||
/// multi-role → Admin + Design + Deployment
|
||||
///
|
||||
/// Nav structure (from NavMenu.razor):
|
||||
/// All authenticated: Dashboard, Health Dashboard
|
||||
/// Admin: LDAP Mappings, Sites, API Keys, SMTP Configuration, Audit Log
|
||||
/// Design: Templates, Shared Scripts, Connections, External Systems
|
||||
/// Deployment: Topology, Deployments, Debug View, Event Logs, Parked Messages
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class RoleNavigationTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public RoleNavigationTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
// ── Admin-only user ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdminUser_SeesAdminSection()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("admin", "password");
|
||||
|
||||
await AssertNavLinkVisible(page, "LDAP Mappings");
|
||||
await AssertNavLinkVisible(page, "Sites");
|
||||
await AssertNavLinkVisible(page, "API Keys");
|
||||
await AssertNavLinkVisible(page, "SMTP Configuration");
|
||||
await AssertNavLinkVisible(page, "Audit Log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminUser_DoesNotSeeDesignSection()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("admin", "password");
|
||||
|
||||
await AssertNavLinkHidden(page, "Templates");
|
||||
await AssertNavLinkHidden(page, "Shared Scripts");
|
||||
await AssertNavLinkHidden(page, "Connections");
|
||||
await AssertNavLinkHidden(page, "External Systems");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminUser_DoesNotSeeDeploymentSection()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("admin", "password");
|
||||
|
||||
await AssertNavLinkHidden(page, "Topology");
|
||||
await AssertNavLinkHidden(page, "Deployments");
|
||||
await AssertNavLinkHidden(page, "Debug View");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminUser_SeesHealthDashboard_NotDeploymentMonitoring()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("admin", "password");
|
||||
|
||||
// Health Dashboard is all-roles; Event Logs and Parked Messages are
|
||||
// Deployment-role only (NavMenu.razor / Component-CentralUI).
|
||||
await AssertNavLinkVisible(page, "Health Dashboard");
|
||||
await AssertNavLinkHidden(page, "Event Logs");
|
||||
await AssertNavLinkHidden(page, "Parked Messages");
|
||||
}
|
||||
|
||||
// ── Design-only user ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DesignUser_SeesDesignSection()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("designer", "password");
|
||||
|
||||
await AssertNavLinkVisible(page, "Templates");
|
||||
await AssertNavLinkVisible(page, "Shared Scripts");
|
||||
await AssertNavLinkVisible(page, "Connections");
|
||||
await AssertNavLinkVisible(page, "External Systems");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DesignUser_DoesNotSeeAdminSection()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("designer", "password");
|
||||
|
||||
await AssertNavLinkHidden(page, "LDAP Mappings");
|
||||
await AssertNavLinkHidden(page, "Sites");
|
||||
await AssertNavLinkHidden(page, "API Keys");
|
||||
await AssertNavLinkHidden(page, "SMTP Configuration");
|
||||
await AssertNavLinkHidden(page, "Audit Log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DesignUser_DoesNotSeeDeploymentSection()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("designer", "password");
|
||||
|
||||
await AssertNavLinkHidden(page, "Topology");
|
||||
await AssertNavLinkHidden(page, "Deployments");
|
||||
await AssertNavLinkHidden(page, "Debug View");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DesignUser_SeesHealthDashboard_NotDeploymentMonitoringOrAudit()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("designer", "password");
|
||||
|
||||
// A Design-only user sees the all-roles Health Dashboard but not the
|
||||
// Deployment-gated Event Logs / Parked Messages, nor the Admin Audit Log.
|
||||
await AssertNavLinkVisible(page, "Health Dashboard");
|
||||
await AssertNavLinkHidden(page, "Event Logs");
|
||||
await AssertNavLinkHidden(page, "Parked Messages");
|
||||
await AssertNavLinkHidden(page, "Audit Log");
|
||||
}
|
||||
|
||||
// ── Deployment-only user ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentUser_SeesDeploymentSection()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("deployer", "password");
|
||||
|
||||
await AssertNavLinkVisible(page, "Topology");
|
||||
await AssertNavLinkVisible(page, "Deployments");
|
||||
await AssertNavLinkVisible(page, "Debug View");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentUser_DoesNotSeeAdminSection()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("deployer", "password");
|
||||
|
||||
await AssertNavLinkHidden(page, "LDAP Mappings");
|
||||
await AssertNavLinkHidden(page, "Sites");
|
||||
await AssertNavLinkHidden(page, "API Keys");
|
||||
await AssertNavLinkHidden(page, "SMTP Configuration");
|
||||
await AssertNavLinkHidden(page, "Audit Log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentUser_DoesNotSeeDesignSection()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("deployer", "password");
|
||||
|
||||
await AssertNavLinkHidden(page, "Templates");
|
||||
await AssertNavLinkHidden(page, "Shared Scripts");
|
||||
await AssertNavLinkHidden(page, "Connections");
|
||||
await AssertNavLinkHidden(page, "External Systems");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentUser_SeesMonitoringButNotConfigurationAuditLog()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("deployer", "password");
|
||||
|
||||
// Event Logs and Parked Messages are Deployment-role gated, so a
|
||||
// Deployment user sees them; Configuration Audit Log is Admin-only.
|
||||
await AssertNavLinkVisible(page, "Health Dashboard");
|
||||
await AssertNavLinkVisible(page, "Event Logs");
|
||||
await AssertNavLinkVisible(page, "Parked Messages");
|
||||
await AssertNavLinkHidden(page, "Configuration Audit Log");
|
||||
}
|
||||
|
||||
// ── Multi-role user (Admin + Design + Deployment) ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MultiRoleUser_SeesAllSections()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
|
||||
// Admin
|
||||
await AssertNavLinkVisible(page, "LDAP Mappings");
|
||||
await AssertNavLinkVisible(page, "Sites");
|
||||
await AssertNavLinkVisible(page, "API Keys");
|
||||
await AssertNavLinkVisible(page, "SMTP Configuration");
|
||||
await AssertNavLinkVisible(page, "Audit Log");
|
||||
|
||||
// Design
|
||||
await AssertNavLinkVisible(page, "Templates");
|
||||
await AssertNavLinkVisible(page, "Shared Scripts");
|
||||
await AssertNavLinkVisible(page, "Connections");
|
||||
await AssertNavLinkVisible(page, "External Systems");
|
||||
|
||||
// Deployment
|
||||
await AssertNavLinkVisible(page, "Topology");
|
||||
await AssertNavLinkVisible(page, "Deployments");
|
||||
await AssertNavLinkVisible(page, "Debug View");
|
||||
|
||||
// Monitoring
|
||||
await AssertNavLinkVisible(page, "Health Dashboard");
|
||||
await AssertNavLinkVisible(page, "Event Logs");
|
||||
await AssertNavLinkVisible(page, "Parked Messages");
|
||||
}
|
||||
|
||||
// ── All users see Dashboard ─────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("admin")]
|
||||
[InlineData("designer")]
|
||||
[InlineData("deployer")]
|
||||
[InlineData("multi-role")]
|
||||
public async Task AllUsers_SeeDashboardLink(string username)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync(username, "password");
|
||||
|
||||
var dashboardLink = page.GetByRole(AriaRole.Link, new() { Name = "Dashboard", Exact = true });
|
||||
await Assertions.Expect(dashboardLink).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private static async Task AssertNavLinkVisible(IPage page, string linkText)
|
||||
{
|
||||
// Sections are collapsed by default — expand them so a present link is
|
||||
// in the DOM. Idempotent: already-expanded sections are skipped.
|
||||
await PlaywrightFixture.ExpandAllNavSectionsAsync(page);
|
||||
var locator = page.Locator($"nav a:has-text('{linkText}')");
|
||||
var count = await locator.CountAsync();
|
||||
Assert.True(count > 0, $"Expected nav link '{linkText}' to be visible, but it was not found");
|
||||
}
|
||||
|
||||
private static async Task AssertNavLinkHidden(IPage page, string linkText)
|
||||
{
|
||||
var locator = page.Locator($"nav a:has-text('{linkText}')");
|
||||
var count = await locator.CountAsync();
|
||||
Assert.True(count == 0, $"Expected nav link '{linkText}' to be hidden, but it was found");
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls;
|
||||
|
||||
/// <summary>
|
||||
/// Direct-SQL seeding helper for the Site Calls page Playwright E2E tests
|
||||
/// (Site Call Audit #22, follow-ups Task 6).
|
||||
///
|
||||
/// <para>
|
||||
/// The Site Calls page reads the central <c>SiteCalls</c> table through the
|
||||
/// <c>SiteCallAuditActor</c>, which is a pure read-from-table mirror — so a row
|
||||
/// INSERTed directly into <c>SiteCalls</c> surfaces on the page exactly as a
|
||||
/// telemetry-ingested row would. Mirrors <see cref="Audit.AuditDataSeeder"/>:
|
||||
/// each test inserts its own rows at setup and best-effort deletes them at
|
||||
/// teardown, keeping the suite self-contained without touching
|
||||
/// <c>infra/mssql/seed-config.sql</c>.
|
||||
/// </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. <c>CreatedAtUtc</c>/<c>UpdatedAtUtc</c> are pinned to "now" so the
|
||||
/// page's default (unconstrained) query window sees the row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class SiteCallDataSeeder
|
||||
{
|
||||
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 row into the central <c>SiteCalls</c> table. Optional
|
||||
/// fields are nullable so a test can shape the row to the status/channel it
|
||||
/// needs for its grid assertions. <c>TrackedOperationId</c> is stored as the
|
||||
/// 36-character GUID string the entity mapping expects.
|
||||
/// </summary>
|
||||
public static async Task InsertSiteCallAsync(
|
||||
Guid trackedOperationId,
|
||||
string channel,
|
||||
string target,
|
||||
string sourceSite,
|
||||
string status,
|
||||
int retryCount,
|
||||
DateTime createdAtUtc,
|
||||
DateTime updatedAtUtc,
|
||||
string? lastError = null,
|
||||
int? httpStatus = null,
|
||||
DateTime? terminalAtUtc = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT INTO [SiteCalls]
|
||||
([TrackedOperationId], [Channel], [Target], [SourceSite], [Status], [RetryCount],
|
||||
[LastError], [HttpStatus], [CreatedAtUtc], [UpdatedAtUtc], [TerminalAtUtc], [IngestedAtUtc])
|
||||
VALUES
|
||||
(@id, @channel, @target, @sourceSite, @status, @retryCount,
|
||||
@lastError, @httpStatus, @createdAtUtc, @updatedAtUtc, @terminalAtUtc, SYSUTCDATETIME());";
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.Parameters.AddWithValue("@id", trackedOperationId.ToString());
|
||||
cmd.Parameters.AddWithValue("@channel", channel);
|
||||
cmd.Parameters.AddWithValue("@target", target);
|
||||
cmd.Parameters.AddWithValue("@sourceSite", sourceSite);
|
||||
cmd.Parameters.AddWithValue("@status", status);
|
||||
cmd.Parameters.AddWithValue("@retryCount", retryCount);
|
||||
cmd.Parameters.AddWithValue("@lastError", (object?)lastError ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@httpStatus", (object?)httpStatus ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@createdAtUtc", createdAtUtc);
|
||||
cmd.Parameters.AddWithValue("@updatedAtUtc", updatedAtUtc);
|
||||
cmd.Parameters.AddWithValue("@terminalAtUtc", (object?)terminalAtUtc ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort cleanup. Deletes every <c>SiteCalls</c> row whose <c>Target</c>
|
||||
/// starts with <paramref name="targetPrefix"/>. Swallows all errors — the
|
||||
/// prefix carries a per-run GUID so the rows are unique to this test run.
|
||||
/// </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 [SiteCalls] 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 so a downed cluster surfaces a clear message rather than an
|
||||
/// opaque <see cref="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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the central Site Calls page (Site Call Audit #22,
|
||||
/// follow-ups Task 6).
|
||||
///
|
||||
/// <para>
|
||||
/// Each test seeds its own <c>SiteCalls</c> rows directly into the running
|
||||
/// cluster's configuration database via <see cref="SiteCallDataSeeder"/>,
|
||||
/// exercises the UI through Playwright, then best-effort deletes the rows by
|
||||
/// their <c>Target</c> prefix. The Site Calls page reads the <c>SiteCalls</c>
|
||||
/// table through the <c>SiteCallAuditActor</c> (a pure read-from-table mirror),
|
||||
/// so a directly-INSERTed row surfaces exactly as a telemetry-ingested row
|
||||
/// would — the same seeding model the Audit Log E2E tests use. The pattern
|
||||
/// keeps each test self-contained without touching
|
||||
/// <c>infra/mssql/seed-config.sql</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Scenarios covered (per the Task 6 brief):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>PageLoads</c> — the page renders for a Deployment-role user.</item>
|
||||
/// <item><c>FilterNarrowing</c> — a channel filter narrows the results grid.</item>
|
||||
/// <item><c>DrillIn</c> — the "View audit history" link deep-links into the
|
||||
/// Audit Log pre-filtered to the call's TrackedOperationId.</item>
|
||||
/// <item><c>RetryDiscardVisibility</c> — Retry/Discard appear only on Parked
|
||||
/// rows, never on Failed (or other) rows.</item>
|
||||
/// <item><c>RetryClickThrough</c> — clicking Retry on a Parked row confirms
|
||||
/// the dialog, relays to the owning site, and surfaces an outcome toast.</item>
|
||||
/// </list>
|
||||
/// </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 <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests</c> idiom.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class SiteCallsPageTests
|
||||
{
|
||||
private const string SiteCallsUrl = "/site-calls/report";
|
||||
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public SiteCallsPageTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Target-keyword search box and commits the value to the server
|
||||
/// as its own discrete circuit message before the caller clicks Query.
|
||||
/// <para>
|
||||
/// The <c>#sc-search</c> input is a Blazor <c>@bind</c>
|
||||
/// (commit-on-<c>change</c>): <see cref="ILocator.FillAsync"/> only fires
|
||||
/// <c>input</c> events, and the <c>change</c> that actually updates
|
||||
/// <c>_targetFilter</c> on the server fires on blur. The original test
|
||||
/// relied on the Query <c>ClickAsync</c> itself to blur the field — that
|
||||
/// makes the <c>change</c> (blur) and the <c>click</c> a single, near-
|
||||
/// simultaneous gesture and races them over the SignalR circuit: when the
|
||||
/// <c>click</c> is processed before the <c>change</c> has updated
|
||||
/// <c>_targetFilter</c>, <c>Search()</c> runs with a stale (empty) keyword
|
||||
/// and the grid returns unfiltered rows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ILocator.DispatchEventAsync"/> raises the <c>change</c> as a
|
||||
/// fully-awaited action of its own, so its circuit message is enqueued and
|
||||
/// sent before the later Query <c>ClickAsync</c>'s message. The SignalR
|
||||
/// connection delivers messages in send order and the Blazor circuit
|
||||
/// processes them sequentially, so <c>_targetFilter</c> is guaranteed
|
||||
/// committed before <c>Search()</c> runs — the two are no longer one
|
||||
/// racing gesture.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static async Task SetSearchKeywordAsync(IPage page, string keyword)
|
||||
{
|
||||
var search = page.Locator("#sc-search");
|
||||
await search.FillAsync(keyword);
|
||||
// Commit the @bind as a discrete change event — not a blur side effect
|
||||
// of the subsequent Query click.
|
||||
await search.DispatchEventAsync("change");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PageLoads_ForDeploymentUser()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
Assert.Contains(SiteCallsUrl, page.Url);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Site Calls')")).ToBeVisibleAsync();
|
||||
// The filter card's Query button is the page's primary action.
|
||||
await Assertions.Expect(page.Locator("[data-test='site-calls-query']")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
|
||||
private const string DbUnavailableSkipReason =
|
||||
"SiteCallDataSeeder 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.";
|
||||
|
||||
[SkippableFact]
|
||||
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
|
||||
{
|
||||
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/sc-filter/{runId}/";
|
||||
var apiId = Guid.NewGuid();
|
||||
var dbId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// One ApiOutbound row, one DbOutbound row — distinct Targets.
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: apiId, channel: "ApiOutbound", target: targetPrefix + "api",
|
||||
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
|
||||
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: dbId, channel: "DbOutbound", target: targetPrefix + "db",
|
||||
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
|
||||
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Unfiltered query: both seeded rows appear (the Target keyword scopes
|
||||
// to this run so unrelated cluster rows do not interfere).
|
||||
await SetSearchKeywordAsync(page, targetPrefix + "api");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Only the ApiOutbound row matches the exact target keyword. The
|
||||
// grid filters with an exact Target match, so the db row must be
|
||||
// absent — use the retrying ToHaveCount assertion so the negative
|
||||
// check waits out the post-query re-render rather than reading a
|
||||
// point-in-time count.
|
||||
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToHaveCountAsync(0);
|
||||
|
||||
// Now filter by Channel = DbOutbound with the db target — the row flips.
|
||||
await SetSearchKeywordAsync(page, targetPrefix + "db");
|
||||
await page.Locator("#sc-channel").SelectOptionAsync("DbOutbound");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToHaveCountAsync(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DrillIn_ViewAuditHistory_NavigatesToPreFilteredAuditLog()
|
||||
{
|
||||
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/sc-drill-in/{runId}/";
|
||||
var trackedId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: trackedId, channel: "ApiOutbound", target: targetPrefix + "endpoint",
|
||||
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
|
||||
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await SetSearchKeywordAsync(page, targetPrefix + "endpoint");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The row carries a "View audit history" link whose href is the
|
||||
// canonical correlationId deep-link — the TrackedOperationId IS the
|
||||
// audit CorrelationId.
|
||||
var link = page.Locator($"a[data-test='audit-link-{trackedId}']");
|
||||
await Assertions.Expect(link).ToBeVisibleAsync();
|
||||
var href = await link.GetAttributeAsync("href");
|
||||
Assert.Equal($"/audit/log?correlationId={trackedId}", href);
|
||||
|
||||
// Following the link lands on the Audit Log page with the query-string
|
||||
// drill-in context intact.
|
||||
await link.ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
Assert.Contains($"correlationId={trackedId}", page.Url);
|
||||
await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RetryDiscard_VisibleOnlyOnParkedRows()
|
||||
{
|
||||
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/sc-actions/{runId}/";
|
||||
var parkedId = Guid.NewGuid();
|
||||
var failedId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// One Parked row (actionable) and one Failed row (terminal — not
|
||||
// actionable from central).
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
|
||||
sourceSite: "plant-a", status: "Parked", retryCount: 3,
|
||||
lastError: "HTTP 503 from ERP", httpStatus: 503,
|
||||
createdAtUtc: now, updatedAtUtc: now);
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: failedId, channel: "DbOutbound", target: targetPrefix + "failed",
|
||||
sourceSite: "plant-a", status: "Failed", retryCount: 1,
|
||||
lastError: "constraint violation",
|
||||
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Query the parked row first.
|
||||
await SetSearchKeywordAsync(page, targetPrefix + "parked");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
|
||||
await Assertions.Expect(parkedRow).ToBeVisibleAsync();
|
||||
// The Parked row exposes both Retry and Discard.
|
||||
await Assertions.Expect(parkedRow.Locator("button:has-text('Retry')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync();
|
||||
|
||||
// Now the Failed row — Retry/Discard are absent.
|
||||
await SetSearchKeywordAsync(page, targetPrefix + "failed");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var failedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "failed" });
|
||||
await Assertions.Expect(failedRow).ToBeVisibleAsync();
|
||||
Assert.Equal(0, await failedRow.Locator("button:has-text('Retry')").CountAsync());
|
||||
Assert.Equal(0, await failedRow.Locator("button:has-text('Discard')").CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast()
|
||||
{
|
||||
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/sc-retry-click/{runId}/";
|
||||
var parkedId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// A single Parked row — the only status from which Retry/Discard can
|
||||
// be relayed to the owning site. Unlike the display-only tests above,
|
||||
// this one actually relays to the owning site, so the SourceSite must
|
||||
// be a *real* site identifier from the running cluster (site-a) and
|
||||
// not the cosmetic "plant-a" label: an unknown site has no registered
|
||||
// ClusterClient, so CentralCommunicationActor drops the envelope
|
||||
// without replying and the relay only resolves on the 10s inner Ask
|
||||
// timeout — too slow for the toast assertion below. Relayed to a live
|
||||
// site, the site finds no parked S&F message for this freshly-seeded
|
||||
// GUID and replies a fast NotParked ack, which still surfaces a toast.
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
|
||||
sourceSite: "site-a", status: "Parked", retryCount: 3,
|
||||
lastError: "HTTP 503 from ERP", httpStatus: 503,
|
||||
createdAtUtc: now, updatedAtUtc: now);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await SetSearchKeywordAsync(page, targetPrefix + "parked");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
|
||||
await Assertions.Expect(parkedRow).ToBeVisibleAsync();
|
||||
|
||||
// Click Retry — this opens the confirmation dialog (DialogHost modal).
|
||||
await parkedRow.Locator("button:has-text('Retry')").ClickAsync();
|
||||
|
||||
// Confirm the relay in the dialog footer ("Confirm" — the non-danger
|
||||
// label; Discard would render "Delete").
|
||||
var confirmButton = page.Locator(".modal-footer button:has-text('Confirm')");
|
||||
await Assertions.Expect(confirmButton).ToBeVisibleAsync();
|
||||
await confirmButton.ClickAsync();
|
||||
|
||||
// The relay outcome surfaces on a toast — Applied, NotParked or, if
|
||||
// the owning site is offline in this environment, SiteUnreachable.
|
||||
// We only assert that an outcome toast appears (exactly one — the
|
||||
// single-toast contract), not which one, since the live cluster
|
||||
// state determines the outcome. The wait is generous (15s): the
|
||||
// relay round-trips to the site over ClusterClient, and a worst-case
|
||||
// path can sit on the 10s inner relay timeout before the response —
|
||||
// and the toast itself auto-dismisses 5s after it appears, so the
|
||||
// assertion must catch it inside that window.
|
||||
var toast = page.Locator(".toast");
|
||||
await Assertions.Expect(toast).ToBeVisibleAsync(
|
||||
new() { Timeout = 15_000 });
|
||||
Assert.Equal(1, await toast.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class SiteCrudTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public SiteCrudTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SitesPage_ShowsSiteManagement()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Sites.razor renders the management page as a heading plus site cards
|
||||
// (not an HTML table) and an always-present "Add Site" action.
|
||||
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
|
||||
await Expect(page.Locator("button:has-text('Add Site')")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSiteButton_NavigatesToCreatePage()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.ClickAsync("button:has-text('Add Site')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites/create");
|
||||
var inputCount = await page.Locator("input").CountAsync();
|
||||
Assert.True(inputCount >= 2, $"Expected at least 2 inputs, found {inputCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_BackButton_ReturnsToList()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.ClickAsync("button:has-text('Back')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
||||
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_CancelButton_ReturnsToList()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.ClickAsync("button:has-text('Cancel')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_HasNodeSubsections()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Expect(page.Locator("h6:has-text('Node A')")).ToBeVisibleAsync();
|
||||
await Expect(page.Locator("h6:has-text('Node B')")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_SaveWithoutName_ShowsError()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.ClickAsync("button:has-text('Save')");
|
||||
|
||||
// Should stay on create page with validation error
|
||||
Assert.Contains("/admin/sites/create", page.Url);
|
||||
await Expect(page.Locator(".text-danger")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
Assertions.Expect(locator);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.Playwright" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<!--
|
||||
SkippableFact lets the Site Calls E2E tests report as Skipped (not Failed)
|
||||
when the dev cluster / MSSQL is not running. xunit 2.9.x does not ship
|
||||
Assert.Skip / SkipUnless — those are v3-only — so we use the canonical
|
||||
community wrapper, matching ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.
|
||||
-->
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
Reference in New Issue
Block a user