From 9fe3ac30c994af054c9067144817b4e48f5bd0f4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 12:06:09 -0400 Subject: [PATCH] =?UTF-8?q?test(e2e):=20API-key=20create=E2=86=92token=20r?= =?UTF-8?q?eveal=20+=20name/method=20validation=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/ApiKeyCrudTests.cs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs new file mode 100644 index 00000000..0f1d4f0d --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs @@ -0,0 +1,148 @@ +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 }); + + await nameInput.FillAsync(keyName); + await page.Locator($"#method-access-{_api.MethodId}").CheckAsync(); + await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).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"); + 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 }); + + // Fill the name but leave ALL method checkboxes unchecked so only the method rule fires. + // The name input uses Blazor @bind (onchange), which only commits on blur — blur the input + // and let the change round-trip to the server (NetworkIdle) so _formName is populated before + // Save, otherwise the name-required rule short-circuits first. + await nameInput.FillAsync(CliRunner.UniqueName("apikey")); + await nameInput.BlurAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); + + var error = page.Locator("div.text-danger"); + 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); + } +}