Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs
T

127 lines
6.6 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.btn-sm: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.btn-sm: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.
}
}
}
}