9e243493fb
Applies the family-wide admin-UI cleanup playbook to the Central UI so the Blazor surfaces stop diverging from the shared kit: buttons are grouped rather than individually sized, long cell values are contained instead of widening tables, and hard-coded colours give way to theme tokens. The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell, which the kit emits as an inline style on the shell root. Being a descendant of <html>, it beat the [data-bs-theme="dark"] override for the entire app, so the dark accent had never rendered. Declaring --accent in site.css :root instead lets both schemes resolve; light is unchanged because the value already matched the kit's light default. Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so that block is deleted here rather than duplicated. Verified byte-identical before removal; the repo now declares no --bs-btn-* anywhere. NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages, SiteCallsReport) were additionally refactored from holding the selected record to holding its id and re-resolving from the current page each render, with the resolve doubling as the visibility gate. A background refresh that drops the row now closes the modal instead of showing a stale snapshot. This is a behaviour change and is called out rather than buried: a full-suite run turned up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack (GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler was disposed between render and click — a window the previous field-held record made structurally impossible. Treat the modal lifecycle here as unreviewed. Build 0/0; suite green apart from that one intermittent failure.
220 lines
12 KiB
C#
220 lines
12 KiB
C#
using Microsoft.Playwright;
|
|
using System.Text.Json;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin;
|
|
|
|
/// <summary>
|
|
/// End-to-end CRUD round-trip for the LDAP Group Mappings admin page.
|
|
/// Covers create → edit → delete via the UI against the running dev cluster.
|
|
/// </summary>
|
|
[Collection("Playwright")]
|
|
public class LdapMappingCrudTests
|
|
{
|
|
private readonly PlaywrightFixture _fixture;
|
|
|
|
public LdapMappingCrudTests(PlaywrightFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task CreateEditDelete_LdapMapping_RoundTrips()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
// Truncated to 18 chars to stay within the form field's maximum input length
|
|
// while still being unique (the zztest-grp- prefix + 7 hex chars from the GUID).
|
|
var group = $"zztest-grp-{Guid.NewGuid():N}"[..18];
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
try
|
|
{
|
|
// ── CREATE ────────────────────────────────────────────────────────────────
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The LDAP Group Name label has no `for=` attribute so GetByLabel does not
|
|
// work. Locate the input that immediately follows the label text instead.
|
|
await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm").FillAsync(group);
|
|
// Scope the role select to the div.mb-2 that owns the "Role" label so a
|
|
// second select (Site Scope) on the edit page cannot cause a strict-mode
|
|
// violation. The LdapMappingForm.razor uses Blazor @bind, not <form>,
|
|
// so the "Role" label's parent div.mb-2 is the tightest stable scope.
|
|
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Designer");
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
|
|
|
// Wait for Blazor enhanced navigation back to the list page.
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The new row must be visible on the list.
|
|
var newRow = page.Locator("tr", new() { HasText = group });
|
|
await Assertions.Expect(newRow).ToBeVisibleAsync();
|
|
|
|
// ── EDIT ──────────────────────────────────────────────────────────────────
|
|
// Click the Edit button within that row.
|
|
await newRow.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings/", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Change the role to Viewer and save.
|
|
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Viewer");
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/edit");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Row must still be present after the edit.
|
|
var editedRow = page.Locator("tr", new() { HasText = group });
|
|
await Assertions.Expect(editedRow).ToBeVisibleAsync();
|
|
|
|
// ── DELETE (no confirmation dialog) ───────────────────────────────────────
|
|
// Scope all dropdown interactions to the row's .dropdown container so we
|
|
// never accidentally match a Delete button from another row's menu.
|
|
var rowDropdown = editedRow.Locator(".dropdown");
|
|
var kebab = rowDropdown.Locator("button[aria-label^='More actions']");
|
|
await kebab.ClickAsync();
|
|
|
|
// Click Delete in the now-open dropdown within this row — no confirm dialog.
|
|
var deleteBtn = rowDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger");
|
|
await deleteBtn.ClickAsync();
|
|
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The row must be gone.
|
|
await Assertions.Expect(page.Locator("tr", new() { HasText = group }))
|
|
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort safety net: if the test failed mid-way and the mapping was
|
|
// not deleted by the UI, clean it up via the CLI so it doesn't leak.
|
|
// Uses `security role-mapping list` + `security role-mapping delete --id`
|
|
// (CLI verbs from SecurityCommands.cs BuildRoleMapping). All exceptions
|
|
// are swallowed — teardown must never mask the test's own failure.
|
|
try
|
|
{
|
|
using var doc = await CliRunner.RunJsonAsync("security", "role-mapping", "list");
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var mapping in doc.RootElement.EnumerateArray())
|
|
{
|
|
if (mapping.TryGetProperty("ldapGroupName", out var grpProp)
|
|
&& grpProp.ValueKind == JsonValueKind.String
|
|
&& string.Equals(grpProp.GetString(), group, StringComparison.Ordinal)
|
|
&& mapping.TryGetProperty("id", out var idProp)
|
|
&& idProp.TryGetInt32(out var mappingId))
|
|
{
|
|
await CliRunner.RunAsync(
|
|
"security", "role-mapping", "delete",
|
|
"--id", mappingId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — the mapping may already be deleted (happy path) or
|
|
// the cluster may be unreachable; never fail teardown.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The create form's <c>SaveMapping()</c> validates manually (no EditForm /
|
|
/// DataAnnotations): a blank LDAP Group Name renders "LDAP Group Name is
|
|
/// required." in <c>div.text-danger.small.mt-2</c> and returns early; a present
|
|
/// group but blank role renders "Role is required." Both branches return before
|
|
/// any persistence, so nothing is created and no teardown is needed. The first
|
|
/// Save is a full circuit roundtrip — proving the Blazor circuit is live — which
|
|
/// makes the second-leg fill (group → blank role) safe from the prerender race.
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task Save_MissingGroupName_ShowsRequiredError()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// ── Leg 1: blank group, role selected → "LDAP Group Name is required." ─────
|
|
// Leave the group input blank; pick a role so the group-blank branch (checked
|
|
// FIRST in SaveMapping) is unambiguously the one that fires.
|
|
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
|
|
.SelectOptionAsync("Designer");
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
|
|
|
await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
|
|
.ToHaveTextAsync("LDAP Group Name is required.");
|
|
await Assertions.Expect(page)
|
|
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/ldap-mappings/create"));
|
|
|
|
// ── Leg 2: group filled, role blank → "Role is required." ──────────────────
|
|
// The first Save roundtrip proved the circuit live, so filling now is safe.
|
|
var group = $"zztest-grp-{Guid.NewGuid():N}"[..18];
|
|
await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm")
|
|
.FillAsync(group);
|
|
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
|
|
.SelectOptionAsync(new[] { "" });
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
|
|
|
await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
|
|
.ToHaveTextAsync("Role is required.");
|
|
await Assertions.Expect(page)
|
|
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/ldap-mappings/create"));
|
|
|
|
// Both validations returned early → nothing persisted → no teardown.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempting to create a mapping whose LDAP group already exists violates the DB
|
|
/// unique index on <c>LdapGroupName</c>. The form has no friendly pre-check, so
|
|
/// the persistence exception is caught and surfaced as "Save failed: {message}"
|
|
/// in <c>div.text-danger.small.mt-2</c>, keeping the user on the create page.
|
|
/// The duplicate is CLI-seeded (and torn down) so this asserts the save-failure
|
|
/// branch without depending on the raw DB error text.
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task Create_DuplicateGroup_ShowsSaveFailedError()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var group = CliRunner.UniqueName("grp");
|
|
var seededId = await CliRunner.CreateRoleMappingAsync(group, "Designer");
|
|
|
|
try
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Fill the SAME group name the CLI just seeded → unique-index violation.
|
|
await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm")
|
|
.FillAsync(group);
|
|
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
|
|
.SelectOptionAsync("Viewer");
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
|
|
|
// The catch block prefixes the raw DB message with "Save failed:" — assert
|
|
// only on that stable prefix, never the provider-specific tail.
|
|
await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
|
|
.ToContainTextAsync("Save failed");
|
|
await Assertions.Expect(page)
|
|
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/ldap-mappings/create"));
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort: remove the CLI-seeded mapping; the UI never persisted a
|
|
// duplicate (the save failed), so only the seed needs cleanup.
|
|
await CliRunner.DeleteRoleMappingAsync(seededId);
|
|
}
|
|
}
|
|
}
|