test(sms): live Playwright E2E for SMS config page + Type selector + Type column (S11)
This commit is contained in:
+271
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="SmsConfigPage_CreateOrRender_NeverLeaksAuthToken"/> — the SMS
|
||||
/// Configuration page (<c>/notifications/sms</c>, <c>RequireAdmin</c>; <c>multi-role</c>
|
||||
/// 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).</item>
|
||||
/// <item><see cref="ListCreate_SmsType_ShowsPhoneInput_NotEmail"/> — the
|
||||
/// <c>/notifications/lists/create</c> Type selector flips the recipient contact field
|
||||
/// from email to phone when Type = SMS.</item>
|
||||
/// <item><see cref="ListCreate_SmsType_ShowsSmsInTypeColumn"/> — a list created with
|
||||
/// Type = SMS renders "Sms" in the Type column on <c>/notifications/lists</c>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Idempotency: the SMS-config fact uses a STABLE Account SID (<c>ACtest123</c>) and probes
|
||||
/// <c>notification sms list</c> 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
|
||||
/// <see cref="CliRunner.UniqueName"/> name with best-effort CLI teardown in a <c>finally</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render-crash gate + live persistence + secret-non-leak for the SMS Configuration page.
|
||||
///
|
||||
/// <para>
|
||||
/// Probes <c>notification sms list</c>; if no config with <see cref="TestAccountSid"/>
|
||||
/// 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 <see cref="TestAccountSid"/> is
|
||||
/// rendered with the Auth Token shown as "(stored)" — a presence flag — and (b) the secret
|
||||
/// VALUE <see cref="TestAuthTokenValue"/> appears NOWHERE in the page HTML.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The render-crash gate is implicit and strong: a circuit that crashed while rendering the
|
||||
/// new page would never surface the <c>SMS Configuration</c> heading, never accept the form
|
||||
/// fills, and never raise the saved toast — all of which are asserted with web-first waits.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the <c>/notifications/lists/create</c> Type selector is channel-aware on the
|
||||
/// EDIT surface: a list created with Type = SMS renders a phone (<c>input[type=tel]</c>)
|
||||
/// recipient field and NOT an email (<c>input[type=email]</c>) 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.)
|
||||
///
|
||||
/// <para>
|
||||
/// 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 <c>finally</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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 <select>. The select's
|
||||
// option values are the NotificationType enum names ("Email"/"Sms"); select by value.
|
||||
await page.Locator("input[type=text].form-control").First.FillAsync(name);
|
||||
await page.Locator("select.form-select").SelectOptionAsync(new SelectOptionValue { Value = "Sms" });
|
||||
|
||||
await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
// On success the form navigates back to the list page.
|
||||
await PlaywrightFixture.WaitForPathAsync(page, ListsUrl, excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// ── OPEN EDIT — recipient inputs only render once the list exists ────────────
|
||||
var listRow = page.Locator("tr").Filter(new() { HasText = name });
|
||||
await Assertions.Expect(listRow).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
await listRow.Locator("button.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
||||
|
||||
// Blazor enhanced navigation (SignalR round-trip) loads the edit form's data.
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Edit Notification List')")).ToBeVisibleAsync();
|
||||
|
||||
// CHANNEL-AWARE ASSERTION: SMS list → phone input present, email input absent.
|
||||
await Assertions.Expect(page.Locator("input[type=tel].form-control")).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(page.Locator("input[type=email]")).ToHaveCountAsync(0);
|
||||
|
||||
// ── ADD a phone recipient ───────────────────────────────────────────────────
|
||||
// Scope to the recipient card (the one holding the phone input) to avoid colliding
|
||||
// with the list-name text input at the top of the page.
|
||||
var recipientCard = page.Locator(".card").Filter(new() { Has = page.Locator("input[type=tel]") });
|
||||
await recipientCard.Locator("input[type=text].form-control").FillAsync("zzrec-sms");
|
||||
await recipientCard.Locator("input[type=tel].form-control").FillAsync(recipPhone);
|
||||
await recipientCard.Locator("button.btn-success:has-text('Add')").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = recipPhone }))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var id in await CliRunner.ListNotificationListIdsByNamePrefixAsync(name))
|
||||
await CliRunner.DeleteNotificationListAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts a list created with Type = SMS renders "Sms" in the Type column on
|
||||
/// <c>/notifications/lists</c>. Creates the list through the UI Type selector, returns to
|
||||
/// the list page, and scopes the assertion to that list's row so it cannot match another
|
||||
/// list's Type cell. Best-effort CLI teardown in <c>finally</c>.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task ListCreate_SmsType_ShowsSmsInTypeColumn()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var name = CliRunner.UniqueName("sms-type");
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
try
|
||||
{
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Add Notification List')")).ToBeVisibleAsync();
|
||||
|
||||
await page.Locator("input[type=text].form-control").First.FillAsync(name);
|
||||
await page.Locator("select.form-select").SelectOptionAsync(new SelectOptionValue { Value = "Sms" });
|
||||
await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, ListsUrl, excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Scope to our list's row, then assert its Type cell reads exactly "Sms" (the enum
|
||||
// name). An EXACT-text regex anchor is required: a substring "Sms" match would also
|
||||
// hit the name cell (e.g. "zztest-sms-type-…"), which contains "sms".
|
||||
var listRow = page.Locator("tbody tr").Filter(new() { HasText = name });
|
||||
await Assertions.Expect(listRow).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(
|
||||
listRow.Locator("td", new() { HasTextRegex = new System.Text.RegularExpressions.Regex("^Sms$") }))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var id in await CliRunner.ListNotificationListIdsByNamePrefixAsync(name))
|
||||
await CliRunner.DeleteNotificationListAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether an SMS configuration with the given Account SID already exists, via
|
||||
/// <c>notification sms list</c> (Auth Token is shown only as a presence flag — never the
|
||||
/// value). Used to keep the create fact idempotent across reruns: the SMS config page has
|
||||
/// no delete verb, so we create at most one copy of the stable-SID fixture.
|
||||
/// </summary>
|
||||
private static async Task<bool> SmsConfigExistsAsync(string accountSid)
|
||||
{
|
||||
using var doc = await CliRunner.RunJsonAsync("notification", "sms", "list");
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var cfg in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if ((cfg.TryGetProperty("accountSid", out var sid) || cfg.TryGetProperty("AccountSid", out sid))
|
||||
&& sid.ValueKind == JsonValueKind.String
|
||||
&& string.Equals(sid.GetString(), accountSid, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user