268 lines
14 KiB
C#
268 lines
14 KiB
C#
using Microsoft.Playwright;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
|
|
|
|
/// <summary>
|
|
/// End-to-end coverage for the Configuration Audit Log page
|
|
/// (<c>/audit/configuration</c>, <c>OperationalAudit</c> policy — the
|
|
/// <c>multi-role</c> test user carries Admin + Viewer so it can reach the page).
|
|
/// The page reads the central <c>AuditLogEntries</c> table; each fact seeds its
|
|
/// own rows via <see cref="ConfigAuditDataSeeder"/> under a unique per-test
|
|
/// <c>marker</c> (stamped as <c>EntityType</c>), filters the grid by that marker
|
|
/// so the <c>(N total)</c> pagination footer is deterministic regardless of the
|
|
/// other rows the live cluster produces, then best-effort deletes the marker's
|
|
/// rows in <c>finally</c>.
|
|
///
|
|
/// <para>
|
|
/// This part (Wave 3, Task 10) covers render / search-narrows / pagination. The
|
|
/// class is intentionally structured so Task 11 can APPEND modal/copy/chip facts
|
|
/// to it without restructuring: shared constants (the DB-unavailable skip reason,
|
|
/// the page route) and the seeded-test gate idiom live here once.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The seeded 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="AuditGridColumnTests"/> idiom.
|
|
/// </para>
|
|
/// </summary>
|
|
[Collection("Playwright")]
|
|
public class AuditConfigurationTests
|
|
{
|
|
/// <summary>
|
|
/// Route to the Configuration Audit Log page. The results grid only renders
|
|
/// after a Search (a bare visit, without a <c>?bundleImportId=</c> query param,
|
|
/// does not auto-fetch), so each fact issues a marker-scoped Search.
|
|
/// </summary>
|
|
private const string ConfigAuditUrl = "/audit/configuration";
|
|
|
|
/// <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 SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string.";
|
|
|
|
private readonly PlaywrightFixture _fixture;
|
|
|
|
public AuditConfigurationTests(PlaywrightFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a fresh per-test marker: a <c>zzCfgAudit-</c> prefix plus a short
|
|
/// GUID slice. Stamped as <c>EntityType</c> on every seeded row so a UI filter
|
|
/// on Entity Type isolates exactly this run's rows (deterministic totals) and
|
|
/// the <c>finally</c> cleanup never touches cluster-produced rows.
|
|
/// </summary>
|
|
private static string NewMarker() => "zzCfgAudit-" + Guid.NewGuid().ToString("N")[..8];
|
|
|
|
[SkippableFact]
|
|
public async Task Grid_RendersAndSearchNarrows()
|
|
{
|
|
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var marker = NewMarker();
|
|
try
|
|
{
|
|
// 3 rows, all EntityType = marker, no bundle rows → exactly 3 match.
|
|
await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 3, bundleRows: 0);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The page renders its heading and filter controls. The results table
|
|
// (the whole @if (_entries != null) block) does NOT render until a
|
|
// Search is issued — the page only auto-fetches when arriving with a
|
|
// ?bundleImportId= query param, which a bare visit lacks — so the
|
|
// Entity Type filter is present and ready to drive the first query.
|
|
await Assertions.Expect(page.Locator("h4:has-text('Configuration Audit Log')")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page.Locator("#audit-filter-entity-type")).ToBeVisibleAsync();
|
|
|
|
// Filter by the unique marker so only this run's 3 rows remain, then
|
|
// Search to load the grid.
|
|
await page.Locator("#audit-filter-entity-type").FillAsync(marker);
|
|
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Seeded rows are isolated: a known row is visible AND the footer total
|
|
// is exactly 3 (filtering by the unique marker makes this deterministic
|
|
// regardless of the other rows the live cluster holds).
|
|
await Assertions.Expect(page.Locator($"tr:has-text('{marker}-0')")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page.Locator("span:has-text('(3 total)')")).ToBeVisibleAsync();
|
|
}
|
|
finally
|
|
{
|
|
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Pagination_PrevDisabledOnPage1_NextPaginates()
|
|
{
|
|
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var marker = NewMarker();
|
|
try
|
|
{
|
|
// 55 rows → 2 pages at 50/page.
|
|
await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 55, bundleRows: 0);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Filter by the marker so the totals/page-count reflect only this run.
|
|
await page.Locator("#audit-filter-entity-type").FillAsync(marker);
|
|
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Page 1 of 2: Previous disabled, Next enabled.
|
|
await Assertions.Expect(page.Locator("span:has-text('Page 1 of 2')")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page.Locator("span:has-text('(55 total)')")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')")).ToBeDisabledAsync();
|
|
await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')")).ToBeEnabledAsync();
|
|
|
|
// Advance to the last page: Previous enabled, Next disabled.
|
|
await page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await Assertions.Expect(page.Locator("span:has-text('Page 2 of 2')")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')")).ToBeEnabledAsync();
|
|
await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')")).ToBeDisabledAsync();
|
|
}
|
|
finally
|
|
{
|
|
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task LargeState_OpensAndClosesModal()
|
|
{
|
|
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var marker = NewMarker();
|
|
try
|
|
{
|
|
// 1 bulk row, no bundle rows. The seeder gives bulk row index 0 a
|
|
// > 1024-char AfterStateJson, so its State cell renders the large-state
|
|
// "View in modal" button (small-state rows show an inline View/Hide
|
|
// toggle instead, which we are not exercising here).
|
|
await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 1, bundleRows: 0);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Filter by the unique marker and Search so only this run's single
|
|
// large-state row populates the grid.
|
|
await page.Locator("#audit-filter-entity-type").FillAsync(marker);
|
|
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Open the modal from the seeded large-state row. The marker is unique,
|
|
// so a page-wide "View in modal" locator resolves to exactly this row.
|
|
await page.Locator("button.btn-outline-info.btn-sm:has-text('View in modal')").ClickAsync();
|
|
|
|
// The modal renders with a title like "Audit entry {id} — {EntityType} state".
|
|
await Assertions.Expect(page.Locator("div.modal.show .modal-title")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page.Locator("div.modal.show .modal-title")).ToContainTextAsync("state");
|
|
|
|
// Closing via the footer Close button removes the modal from the DOM.
|
|
await page.Locator(".modal.show button.btn-outline-secondary.btn-sm:has-text('Close')").ClickAsync();
|
|
await Assertions.Expect(page.Locator("div.modal.show")).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
}
|
|
finally
|
|
{
|
|
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task CopyEntityId_ShowsToast()
|
|
{
|
|
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var marker = NewMarker();
|
|
try
|
|
{
|
|
// 1 bulk row (non-empty EntityId) so the row renders the 📋 copy button.
|
|
await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 1, bundleRows: 0);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await page.Locator("#audit-filter-entity-type").FillAsync(marker);
|
|
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Click the copy-entity-ID button on the (only) seeded row.
|
|
await page.Locator("button[aria-label^='Copy entity ID']").First.ClickAsync();
|
|
|
|
// Assert a toast appears, TOLERANTLY of which one. The cluster is served
|
|
// over a non-secure, non-localhost http origin (http://scadabridge-traefik),
|
|
// so navigator.clipboard is unavailable: CopyAsync's writeText throws and
|
|
// the handler shows the ERROR toast "Copy failed." instead of the success
|
|
// toast "Copied to clipboard.". Either path produces exactly one .toast, so
|
|
// we assert the count only and deliberately do NOT assert the message text.
|
|
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
|
}
|
|
finally
|
|
{
|
|
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task BundleImportId_DrillIn_ChipFiltersAndClears()
|
|
{
|
|
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var marker = NewMarker();
|
|
var bundleId = Guid.NewGuid();
|
|
try
|
|
{
|
|
// 1 bulk row (EntityName "{marker}-0", BundleImportId NULL) + 2 bundle
|
|
// rows (EntityName "{marker}-bundle-0/1", BundleImportId = bundleId), all
|
|
// EntityType = marker so the finally cleanup removes every row.
|
|
await ConfigAuditDataSeeder.SeedAsync(marker, bundleId, bulkCount: 1, bundleRows: 2);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
// Arriving WITH a ?bundleImportId= query param auto-loads the grid
|
|
// pre-filtered (OnParametersSetAsync fires FetchPage because the param
|
|
// differs from the initial null) — no Search click needed.
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/configuration?bundleImportId={bundleId}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The filter chip is shown for the active bundle import.
|
|
await Assertions.Expect(page.Locator("span.badge.bg-primary:has-text('Filtered by Bundle Import:')")).ToBeVisibleAsync();
|
|
|
|
// Exactly the 2 bundle rows show (EntityName "{marker}-bundle-0/1"). The
|
|
// bulk row (EntityName "{marker}-0", BundleImportId NULL) is filtered out.
|
|
// The "{marker}-0" token is NOT a substring of "{marker}-bundle-0", so the
|
|
// two locators distinguish the bundle rows from the bulk row unambiguously.
|
|
await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = $"{marker}-bundle-" }))
|
|
.ToHaveCountAsync(2, new() { Timeout = 10_000 });
|
|
await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = $"{marker}-0" }))
|
|
.ToHaveCountAsync(0);
|
|
|
|
// Clearing the filter removes the chip and navigates to the bare route;
|
|
// because BundleImportId goes from the guid back to null, the page
|
|
// re-fetches and reloads the grid with all entries (chip gone, rows ≥ 1).
|
|
await page.Locator("button[aria-label='Clear Bundle Import filter']").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await Assertions.Expect(page.Locator("span.badge.bg-primary:has-text('Filtered by Bundle Import:')")).ToHaveCountAsync(0);
|
|
await Assertions.Expect(page.Locator("tbody tr").First).ToBeVisibleAsync();
|
|
}
|
|
finally
|
|
{
|
|
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
|
}
|
|
}
|
|
}
|