diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs index 09c18bb4..71bc017f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs @@ -1,4 +1,5 @@ using Microsoft.Playwright; +using System.Text.Json; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin; @@ -22,65 +23,104 @@ public class LdapMappingCrudTests { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); - // Keep the group name short and unique to avoid collisions with other test runs. + // 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(); - // ── CREATE ──────────────────────────────────────────────────────────────── - await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create"); - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + 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); - await page.SelectOptionAsync(".form-select.form-select-sm", "Designer"); - await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')"); + // 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
, + // 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); + // 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(); + // 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(); + // ── 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, "/edit"); - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings/", excludePath: "/create"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - // Change the role to Viewer and save. - await page.SelectOptionAsync(".form-select.form-select-sm", "Viewer"); - await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')"); + // 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); + 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(); + // 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(); + // ── 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(); + // 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); + 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 }); - - // TODO: best-effort cleanup if a mid-test failure left a zztest-grp-* row. - // The happy path deletes via the UI above; no CLI cleanup needed for the passing case. + // 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. + } + } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/HealthDashboardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/HealthDashboardTests.cs index 26ab2fcf..6f2b561c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/HealthDashboardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/HealthDashboardTests.cs @@ -76,19 +76,26 @@ public class HealthDashboardTests await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // ── Notification Outbox tiles (no data-test; inlined in Health.razor as - // plain div.card elements, not buttons — use that to disambiguate from - // the SiteCallKpiTiles which render their cards as