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);
+ }
+}