using Microsoft.Playwright; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin; /// /// 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. /// [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
, // 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. } } } /// /// The create form's SaveMapping() validates manually (no EditForm / /// DataAnnotations): a blank LDAP Group Name renders "LDAP Group Name is /// required." in div.text-danger.small.mt-2 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. /// [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.btn-sm: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.btn-sm: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. } /// /// Attempting to create a mapping whose LDAP group already exists violates the DB /// unique index on LdapGroupName. The form has no friendly pre-check, so /// the persistence exception is caught and surfaced as "Save failed: {message}" /// in div.text-danger.small.mt-2, 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. /// [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.btn-sm: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); } } }