From d6ead8ae62a60c3f842e60e43dd41164e2a00644 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 19 Jun 2026 11:54:25 -0400 Subject: [PATCH] test(sms): live Playwright E2E for SMS config page + Type selector + Type column (S11) --- .../Notifications/SmsNotificationE2ETests.cs | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs new file mode 100644 index 00000000..61b53bad --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs @@ -0,0 +1,271 @@ +using System.Text.Json; +using Microsoft.Playwright; +using Xunit; +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications; + +/// +/// Live end-to-end coverage for the SMS Notifications feature against the running dev +/// cluster. Three facts, each exercising one of the new SMS surfaces: +/// +/// +/// — the SMS +/// Configuration page (/notifications/sms, RequireAdmin; multi-role +/// has Admin). Doubles as the RENDER-CRASH GATE for the new page: if the Blazor circuit +/// crashed on render, the heading/form interaction below would hang or fail. Also asserts +/// live persistence (the encrypted AuthToken round-trips into real MS SQL) and that the +/// secret VALUE never appears anywhere in the rendered page (presence indicator only). +/// — the +/// /notifications/lists/create Type selector flips the recipient contact field +/// from email to phone when Type = SMS. +/// — a list created with +/// Type = SMS renders "Sms" in the Type column on /notifications/lists. +/// +/// +/// +/// Idempotency: the SMS-config fact uses a STABLE Account SID (ACtest123) and probes +/// notification sms list first — if a prior run already created it, the fact SKIPS the +/// create and verifies the existing row instead (the page exposes no delete verb — neither UI +/// nor CLI — so we never create a second copy we could not clean up; the render-crash + +/// non-leak assertions still run against the existing row). The notification-list facts use a +/// name with best-effort CLI teardown in a finally. +/// +/// +[Collection("Playwright")] +public class SmsNotificationE2ETests +{ + private const string SmsUrl = "/notifications/sms"; + private const string ListsUrl = "/notifications/lists"; + + // Stable test fixture values. The Account SID is stable (not random) so reruns find the + // prior config via 'notification sms list' and skip re-creation — the SMS config page has + // no delete verb, so a unique-per-run SID would leak a config on every run. + private const string TestAccountSid = "ACtest123"; + private const string TestFromNumber = "+15551230000"; + // The Auth Token VALUE that must NEVER be echoed back to the page (presence flag only). + private const string TestAuthTokenValue = "e2e-secret-token-xyz"; + + private readonly PlaywrightFixture _pw; + + public SmsNotificationE2ETests(PlaywrightFixture pw) + { + _pw = pw; + } + + /// + /// Render-crash gate + live persistence + secret-non-leak for the SMS Configuration page. + /// + /// + /// Probes notification sms list; if no config with + /// exists, fills and saves the create form (Account SID, From Number, Auth Token; other + /// fields default) — asserting the "SMS configuration saved." toast, which confirms the + /// encrypted token persisted to real MS SQL. Whether the row was just created or already + /// existed, the fact then asserts (a) the config card for is + /// rendered with the Auth Token shown as "(stored)" — a presence flag — and (b) the secret + /// VALUE appears NOWHERE in the page HTML. + /// + /// + /// + /// The render-crash gate is implicit and strong: a circuit that crashed while rendering the + /// new page would never surface the SMS Configuration heading, never accept the form + /// fills, and never raise the saved toast — all of which are asserted with web-first waits. + /// + /// + [SkippableFact] + public async Task SmsConfigPage_CreateOrRender_NeverLeaksAuthToken() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + // Probe whether our stable-SID config already exists (a prior run). The SMS config page + // exposes no delete verb, so we must not create a second copy we cannot clean up. + bool alreadyExists = await SmsConfigExistsAsync(TestAccountSid); + + var page = await _pw.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SmsUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // RENDER-CRASH GATE: the heading only appears if the new page's circuit rendered cleanly. + await Assertions.Expect(page.Locator("h4:has-text('SMS Configuration')")).ToBeVisibleAsync(); + + if (!alreadyExists) + { + // Open the create form. When no config exists the page shows the empty-state + // "Add SMS configuration" button (rendered both in the empty-state block and the + // fall-through else); .First disambiguates if both are momentarily present. + await page.Locator("button.btn-primary.btn-sm:has-text('Add SMS configuration')").First.ClickAsync(); + + // The form's text inputs, in document order: Account SID, From Number, Messaging + // Service SID, API Base URL. We fill the first two and leave the rest default. + var textInputs = page.Locator("input[type=text].form-control"); + await textInputs.Nth(0).FillAsync(TestAccountSid); + await textInputs.Nth(1).FillAsync(TestFromNumber); + + // Auth Token is the single password input; required on create. + await page.Locator("input[type=password].form-control").FillAsync(TestAuthTokenValue); + + await page.Locator("button.btn-success:has-text('Save')").ClickAsync(); + + // Live-persistence proof: the saved toast only fires after AddSmsConfiguration + + // SaveChanges succeed against MS SQL (the encrypted AuthToken is now stored). + await Assertions + .Expect(page.Locator(".toast", new() { HasText = "SMS configuration saved." })) + .ToHaveCountAsync(1, new() { Timeout = 15_000 }); + } + + // Whether just-created or pre-existing: the config card for our SID must be rendered, + // with the Auth Token shown as the presence flag "(stored)" — never the value. + var configCard = page.Locator(".card").Filter(new() { HasText = TestAccountSid }); + await Assertions.Expect(configCard.First).ToBeVisibleAsync(new() { Timeout = 10_000 }); + await Assertions.Expect(page.GetByText("(stored)").First).ToBeVisibleAsync(new() { Timeout = 10_000 }); + + // SECRET-NON-LEAK: the raw Auth Token value must not appear anywhere in the page HTML. + // (The form is closed after save, so the password input that briefly held it is gone; + // the rendered card shows only the "(stored)" presence flag.) + var html = await page.ContentAsync(); + Assert.DoesNotContain(TestAuthTokenValue, html, StringComparison.Ordinal); + } + + /// + /// Asserts the /notifications/lists/create Type selector is channel-aware on the + /// EDIT surface: a list created with Type = SMS renders a phone (input[type=tel]) + /// recipient field and NOT an email (input[type=email]) field, then accepts a phone + /// recipient. (Recipient inputs render only after the list exists — the create page persists + /// the Type, the edit page then exposes the matching contact field.) + /// + /// + /// Drives the whole flow through the UI: create (Type = SMS) → redirect to the list → + /// open Edit → assert the phone input is present and the email input is absent → add a + /// phone recipient and assert the row appears. Best-effort CLI teardown in finally. + /// + /// + [SkippableFact] + public async Task ListCreate_SmsType_ShowsPhoneInput_NotEmail() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var name = CliRunner.UniqueName("sms-list"); + const string recipPhone = "+15559998888"; + + var page = await _pw.NewAuthenticatedPageAsync(); + try + { + // ── CREATE with Type = SMS ────────────────────────────────────────────────── + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}/create"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await Assertions.Expect(page.Locator("h4:has-text('Add Notification List')")).ToBeVisibleAsync(); + + // The create form has the list-name text input and the Type