using Microsoft.Playwright; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin; /// /// E2E coverage for the inbound API-key create form (/admin/api-keys/create, /// rendered by ApiKeyForm.razor). One happy-path test asserts the one-time token /// reveal after a successful create (and tears the key down via the CLI in a finally), /// plus two validation-edge tests that assert the exact inline messages the razor renders /// for an empty name and for a key with no method selected. /// /// /// The provisions a single inbound api-method so the form /// renders at least one method checkbox (#method-access-{MethodId}); this fixture /// owns that method (cleaned at fixture dispose). Created keys are owned by the tests and /// deleted per-test. /// /// /// /// Verified against ApiKeyForm.razor: the name input is the single /// input[type='text'].form-control.form-control-sm (editable on create); the Save /// button is the success button with text "Save"; the created-token panel exposes /// [data-test='created-token'] with an adjacent "Copy" button; inline validation /// renders in div.text-danger.small with the literal messages /// "Name is required." and "Select at least one API method for this key.". /// /// [Collection("Playwright")] public sealed class ApiKeyCrudTests : IClassFixture { private readonly PlaywrightFixture _fixture; private readonly ApiSurfaceFixture _api; public ApiKeyCrudTests(PlaywrightFixture fixture, ApiSurfaceFixture api) { _fixture = fixture; _api = api; } /// /// Happy path: filling the name, checking the fixture method, and saving creates the key /// and reveals the one-time token panel ([data-test='created-token']) with its /// adjacent "Copy" button. MUTATES — the created key is deleted via the CLI in a /// finally (resolve by name, best-effort delete) so nothing leaks. /// [SkippableFact] public async Task Create_RevealsOneTimeToken() { Skip.IfNot(_api.Available, ClusterAvailability.SkipReason); var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password"); var keyName = CliRunner.UniqueName("apikey"); try { await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys/create"); // Blazor Server page renders a LoadingSpinner while OnInitializedAsync loads the // method list; web-first wait for the name input before driving it. var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First; await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 }); var saveButton = page.GetByRole(AriaRole.Button, new() { Name = "Save" }); // Same under-load hydration race as Create_NoMethods (see that test): this page is // InteractiveServer with prerendering, so the form renders (and is "visible") before // blazor.web.js hydrates the component and attaches its @bind onchange / @onchange // handlers over the SignalR circuit. Under full-suite load that hydration lands late, // so a name fill / checkbox check dispatched against the not-yet-live circuit is // silently dropped — SaveKey then short-circuits on the empty _formName (or empty // method set) and no key is created, so the token panel never appears. // // PROVE the circuit is interactive with an observable server round-trip first: clicking // Save on the empty form forces SaveKey to run and render "Name is required." (harmless, // creates nothing). Once that message appears the component is hydrated and its handlers // are live, so the subsequent name fill + checkbox check are guaranteed to round-trip. await saveButton.ClickAsync(); await Assertions.Expect(page.Locator("div.text-danger.small")) .ToContainTextAsync("Name is required.", new() { Timeout = 15_000 }); await nameInput.FillAsync(keyName); await nameInput.DispatchEventAsync("change"); await page.Locator($"#method-access-{_api.MethodId}").CheckAsync(); await saveButton.ClickAsync(); await Assertions.Expect(page.Locator("[data-test='created-token']")) .ToBeVisibleAsync(new() { Timeout = 10_000 }); await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = "Copy" })) .ToBeVisibleAsync(); } finally { var keyId = await CliRunner.ResolveApiKeyIdByNameAsync(keyName); if (keyId is not null) { await CliRunner.DeleteApiKeyAsync(keyId); } } } /// /// Validation edge: an empty name (with a method checked) blocks the save and renders the /// literal "Name is required." message in div.text-danger; no token panel /// appears, so nothing is created and there is no teardown. /// [SkippableFact] public async Task Create_EmptyName_ShowsValidationError() { Skip.IfNot(_api.Available, ClusterAvailability.SkipReason); var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password"); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys/create"); // Web-first wait on the method checkbox guarantees the form (and its method list) // has rendered before we interact. var methodCheckbox = page.Locator($"#method-access-{_api.MethodId}"); await Assertions.Expect(methodCheckbox).ToBeVisibleAsync(new() { Timeout = 15_000 }); // Leave the name blank; check the method so only the name rule fires. await methodCheckbox.CheckAsync(); await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); var error = page.Locator("div.text-danger.small"); await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 10_000 }); await Assertions.Expect(error).ToContainTextAsync("Name is required."); // Nothing should have been created — the token panel must never appear. await Assertions.Expect(page.Locator("[data-test='created-token']")).ToHaveCountAsync(0); } /// /// Validation edge: a named key with no method selected blocks the save and renders the /// literal "Select at least one API method for this key." message in /// div.text-danger; no token panel appears, so nothing is created and there is no /// teardown. /// [SkippableFact] public async Task Create_NoMethods_ShowsValidationError() { Skip.IfNot(_api.Available, ClusterAvailability.SkipReason); var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password"); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys/create"); var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First; await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 }); var saveButton = page.GetByRole(AriaRole.Button, new() { Name = "Save" }); var error = page.Locator("div.text-danger.small"); // ROOT CAUSE of the under-load flake: the page is InteractiveServer with prerendering, // so the name renders (and is "visible") from the prerendered HTML BEFORE // blazor.web.js hydrates this component and attaches its @bind onchange handler over the // SignalR circuit. Under full-suite load (circuit pressure from the 4-context cap) that // hydration lands late, so a name FillAsync + change dispatched against the not-yet-live // circuit is silently dropped — _formName stays empty server-side and SaveKey short- // circuits to "Name is required." (the rule checked BEFORE the methods rule in // ApiKeyForm.SaveKey). In isolation the circuit hydrates fast enough that this never bites. // // Fix: PROVE the circuit is interactive with an observable server round-trip before we // rely on the name commit. Clicking Save with an empty name forces SaveKey to run on the // server and render the "Name is required." message — a circuit-driven re-render. Waiting // for that message guarantees the component is hydrated and its onchange handler is live. // Only THEN do we fill the name + dispatch change (now guaranteed to round-trip) and Save // again, so _formName is committed and the methods rule — not the name rule — fires. await saveButton.ClickAsync(); await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 15_000 }); await Assertions.Expect(error).ToContainTextAsync("Name is required."); // Circuit is now provably interactive. Commit the name as its own discrete change message // (ordered before the Save click over the in-order SignalR channel), leaving ALL method // checkboxes unchecked so only the method rule can fire. await nameInput.FillAsync(CliRunner.UniqueName("apikey")); await nameInput.DispatchEventAsync("change"); await saveButton.ClickAsync(); await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 10_000 }); await Assertions.Expect(error).ToContainTextAsync("Select at least one API method for this key."); // The name-only submit must not create a key — no token panel. await Assertions.Expect(page.Locator("[data-test='created-token']")).ToHaveCountAsync(0); } /// /// Enable/disable round-trip on the list page (/admin/api-keys, rendered by /// ApiKeys.razor). MUTATES — creates a key via the CLI, drives the per-row kebab /// (button[aria-label="More actions for {name}"], a Bootstrap data-bs-toggle="dropdown") /// to Disable then Enable. The authoritative state indicator is the /// span.badge.bg-secondary "Disabled" badge the razor renders on a disabled row's name /// cell: it must appear after Disable and be gone after Enable. The CLI-created key is deleted /// in a finally (best-effort) so nothing leaks. /// [SkippableFact] public async Task ToggleEnabled_TransitionsDisabledBadge() { Skip.IfNot(_api.Available, ClusterAvailability.SkipReason); var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password"); var keyName = CliRunner.UniqueName("apikey"); var keyId = await CliRunner.CreateApiKeyAsync(keyName, _api.MethodName); try { await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys"); // Web-first: the row for our key (by name) must render before we drive it. var row = page.Locator("tr").Filter(new() { HasText = keyName }); await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 }); // Scope ALL dropdown interactions to THIS row's .dropdown container so the kebab and // its menu items can never multi-match against another row's (hidden) menu under // Playwright strict mode (e.g. when the list has test residue / multiple keys). var rowDropdown = row.Locator(".dropdown"); var kebab = rowDropdown.Locator("button[aria-label^='More actions']"); var disabledBadge = row.Locator("span.badge.bg-secondary"); // Open the kebab (Bootstrap dropdown) and click Disable. Gate the click on the item's // visibility so we don't race the Bootstrap open-transition before the menu is .show. await kebab.ClickAsync(); var disableItem = rowDropdown.Locator(".dropdown-menu button.dropdown-item") .Filter(new() { HasText = "Disable" }); await Assertions.Expect(disableItem).ToBeVisibleAsync(); await disableItem.ClickAsync(); // Authoritative: the "Disabled" badge appears on the row's name cell. await Assertions.Expect(disabledBadge).ToBeVisibleAsync(new() { Timeout = 10_000 }); // Re-open the kebab and click Enable; the badge must disappear from this row. await kebab.ClickAsync(); var enableItem = rowDropdown.Locator(".dropdown-menu button.dropdown-item") .Filter(new() { HasText = "Enable" }); await Assertions.Expect(enableItem).ToBeVisibleAsync(); await enableItem.ClickAsync(); await Assertions.Expect(disabledBadge).ToHaveCountAsync(0, new() { Timeout = 10_000 }); } finally { await CliRunner.DeleteApiKeyAsync(keyId); } } /// /// Delete-with-confirm removes the row (/admin/api-keys, rendered by ApiKeys.razor). /// MUTATES — creates a key via the CLI, then deletes it through the UI: kebab → /// .dropdown-item.text-danger "Delete" → the confirm dialog ("Delete API Key", rendered by /// DialogHost.razor) → .modal-footer .btn-danger. The row for that name must then be /// gone. The CLI delete in the finally is an idempotent safety net (no-op if the UI already /// removed it). /// [SkippableFact] public async Task Delete_WithConfirm_RemovesRow() { Skip.IfNot(_api.Available, ClusterAvailability.SkipReason); var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password"); var keyName = CliRunner.UniqueName("apikey"); var keyId = await CliRunner.CreateApiKeyAsync(keyName, _api.MethodName); try { await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys"); var row = page.Locator("tr").Filter(new() { HasText = keyName }); await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 }); // Scope ALL dropdown interactions to THIS row's .dropdown container so the kebab and // its menu items can never multi-match against another row's (hidden) menu under // Playwright strict mode (e.g. when the list has test residue / multiple keys). var rowDropdown = row.Locator(".dropdown"); var kebab = rowDropdown.Locator("button[aria-label^='More actions']"); // Open the kebab and click the danger "Delete" item. Gate the click on the item's // visibility so we don't race the Bootstrap open-transition before the menu is .show. await kebab.ClickAsync(); var deleteItem = rowDropdown.Locator(".dropdown-menu button.dropdown-item") .Filter(new() { HasText = "Delete" }); await Assertions.Expect(deleteItem).ToBeVisibleAsync(); await deleteItem.ClickAsync(); // The confirm dialog must appear before we confirm. await Assertions.Expect(page.Locator(".modal-title").Filter(new() { HasText = "Delete API Key" })) .ToBeVisibleAsync(new() { Timeout = 10_000 }); await page.Locator(".modal-footer .btn-danger").ClickAsync(); // The row for that name must be gone after the delete round-trips. await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = keyName })) .ToHaveCountAsync(0, new() { Timeout = 10_000 }); } finally { await CliRunner.DeleteApiKeyAsync(keyId); } } }