From 4860aeff6230034b4e112722ec9277cd2879a30d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 19 Jun 2026 10:52:56 -0400 Subject: [PATCH] feat(sms): SMS configuration Central UI page + nav (S9) --- .../Components/Layout/NavMenu.razor | 1 + .../Notifications/SmsConfiguration.razor | 278 ++++++++++++++++++ .../Pages/SmsConfigurationPageTests.cs | 176 +++++++++++ 3 files changed, 455 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmsConfiguration.razor create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmsConfigurationPageTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor index eb61d14d..07d9e221 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor @@ -57,6 +57,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmsConfiguration.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmsConfiguration.razor new file mode 100644 index 00000000..4b01803e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmsConfiguration.razor @@ -0,0 +1,278 @@ +@page "/notifications/sms" +@using ZB.MOM.WW.ScadaBridge.Security +@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories +@using SmsConfigurationEntity = ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmsConfiguration +@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] +@inject INotificationRepository NotificationRepository +@inject NavigationManager NavigationManager + +
+
+

SMS Configuration

+
+ + + + @if (_loading) + { + + } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else + { + @if (_smsConfigs.Count == 0 && !_showForm) + { +
+

No SMS configuration set.

+ +
+ } + else + { + @foreach (var sms in _smsConfigs) + { +
+
+ @sms.AccountSid + @if (_editingSms?.Id != sms.Id || !_showForm) + { + + } +
+
+
+
Account SID
+
@sms.AccountSid
+
From Number
+
@sms.FromNumber
+
Messaging Service SID
+
@(string.IsNullOrWhiteSpace(sms.MessagingServiceSid) ? "(not set)" : sms.MessagingServiceSid)
+
API Base URL
+
@(string.IsNullOrWhiteSpace(sms.ApiBaseUrl) ? "(provider default)" : sms.ApiBaseUrl)
+
Auth Token
+
@(string.IsNullOrWhiteSpace(sms.AuthToken) ? "(not set)" : "(stored)")
+
Connection Timeout
+
@sms.ConnectionTimeoutSeconds s
+
Max Retries
+
@sms.MaxRetries
+
Retry Delay
+
@sms.RetryDelay
+
+
+
+ } + + @if (_showForm) + { +
+
@(_editingSms != null ? "Edit SMS Configuration" : "Add SMS Configuration")
+
+
+
+ + +
+
+ + +
+
+ + +
Optional — used instead of the From number when set.
+
+
+ + +
+
+ + +
+ Treat as sensitive — visible to admins only. + @if (_editingSms != null) + { + Leave blank to keep the existing token. + } +
+
+
+ + +
+
+ + +
+
+ + +
+ @if (_formError != null) + { +
@_formError
+ } +
+ + +
+
+
+
+ } + else if (_smsConfigs.Count == 0) + { + + } + } + } +
+ +@code { + private bool _loading = true; + private string? _errorMessage; + + private List _smsConfigs = new(); + private bool _showForm; + private SmsConfigurationEntity? _editingSms; + + private string _accountSid = string.Empty; + private string _fromNumber = string.Empty; + private string? _messagingServiceSid; + private string? _apiBaseUrl; + private string? _authToken; + private int _connectionTimeoutSeconds = 30; + private int _maxRetries = 10; + private int _retryDelaySeconds = 60; + private string? _formError; + + private ToastNotification _toast = default!; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + _loading = true; + _errorMessage = null; + try + { + _smsConfigs = (await NotificationRepository.GetAllSmsConfigurationsAsync()).ToList(); + } + catch (Exception ex) + { + _errorMessage = ex.Message; + } + _loading = false; + } + + private void ShowAddForm() + { + _editingSms = null; + _accountSid = string.Empty; + _fromNumber = string.Empty; + _messagingServiceSid = null; + _apiBaseUrl = null; + _authToken = null; + _connectionTimeoutSeconds = 30; + _maxRetries = 10; + _retryDelaySeconds = 60; + _formError = null; + _showForm = true; + } + + private void StartEdit(SmsConfigurationEntity sms) + { + _editingSms = sms; + _accountSid = sms.AccountSid; + _fromNumber = sms.FromNumber; + _messagingServiceSid = sms.MessagingServiceSid; + _apiBaseUrl = sms.ApiBaseUrl; + // Never pre-fill the stored secret; blank means "keep existing". + _authToken = null; + _connectionTimeoutSeconds = sms.ConnectionTimeoutSeconds; + _maxRetries = sms.MaxRetries; + _retryDelaySeconds = (int)sms.RetryDelay.TotalSeconds; + _formError = null; + _showForm = true; + } + + private void CancelForm() + { + _showForm = false; + _formError = null; + } + + private async Task Save() + { + _formError = null; + if (string.IsNullOrWhiteSpace(_accountSid) || string.IsNullOrWhiteSpace(_fromNumber)) + { + _formError = "Account SID and From Number are required."; + return; + } + + var typedAuthToken = string.IsNullOrWhiteSpace(_authToken) ? null : _authToken.Trim(); + + try + { + if (_editingSms != null) + { + _editingSms.AccountSid = _accountSid.Trim(); + _editingSms.FromNumber = _fromNumber.Trim(); + _editingSms.MessagingServiceSid = string.IsNullOrWhiteSpace(_messagingServiceSid) + ? null + : _messagingServiceSid.Trim(); + _editingSms.ApiBaseUrl = string.IsNullOrWhiteSpace(_apiBaseUrl) ? null : _apiBaseUrl.Trim(); + // Preserve-if-blank: only overwrite the stored token when a new value was typed. + if (typedAuthToken != null) + { + _editingSms.AuthToken = typedAuthToken; + } + _editingSms.ConnectionTimeoutSeconds = _connectionTimeoutSeconds; + _editingSms.MaxRetries = _maxRetries; + _editingSms.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds); + await NotificationRepository.UpdateSmsConfigurationAsync(_editingSms); + } + else + { + if (typedAuthToken == null) + { + _formError = "Auth Token is required."; + return; + } + + var sms = new SmsConfigurationEntity(_accountSid.Trim(), _fromNumber.Trim()) + { + MessagingServiceSid = string.IsNullOrWhiteSpace(_messagingServiceSid) + ? null + : _messagingServiceSid.Trim(), + ApiBaseUrl = string.IsNullOrWhiteSpace(_apiBaseUrl) ? null : _apiBaseUrl.Trim(), + AuthToken = typedAuthToken, + ConnectionTimeoutSeconds = _connectionTimeoutSeconds, + MaxRetries = _maxRetries, + RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds), + }; + await NotificationRepository.AddSmsConfigurationAsync(sms); + } + await NotificationRepository.SaveChangesAsync(); + _showForm = false; + _toast.ShowSuccess("SMS configuration saved."); + await LoadAsync(); + } + catch (Exception ex) + { + _formError = ex.Message; + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmsConfigurationPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmsConfigurationPageTests.cs new file mode 100644 index 00000000..d4140956 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmsConfigurationPageTests.cs @@ -0,0 +1,176 @@ +using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using SmsConfigurationPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.SmsConfiguration; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; + +/// +/// bUnit rendering tests for the SMS Configuration page — verifies the read-only list, +/// that the stored Auth Token value is never rendered (only a presence indicator), +/// repository-direct save on create/edit, and the preserve-if-blank secret handling. +/// +public class SmsConfigurationPageTests : BunitContext +{ + private void WireAuth() + { + var claims = new[] + { + new Claim(JwtTokenService.UsernameClaimType, "tester"), + new Claim(JwtTokenService.RoleClaimType, "Administrator"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + } + + private const string SecretToken = "super-secret-auth-token"; + + private static SmsConfiguration Sample() => + new("ACtest_account_sid", "+15551234567") + { + Id = 1, + MessagingServiceSid = "MGtest_messaging_service", + ApiBaseUrl = "https://api.example.com", + AuthToken = SecretToken, + ConnectionTimeoutSeconds = 30, + MaxRetries = 10, + RetryDelay = TimeSpan.FromMinutes(1), + }; + + private static INotificationRepository RepoWith(params SmsConfiguration[] configs) + { + var repo = Substitute.For(); + repo.GetAllSmsConfigurationsAsync() + .Returns(Task.FromResult>(configs.ToList())); + return repo; + } + + [Fact] + public void ReadOnlyView_ShowsConfigRow_ButNeverRendersAuthTokenValue() + { + var repo = RepoWith(Sample()); + Services.AddSingleton(repo); + WireAuth(); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + // Config row fields render. + Assert.Contains("ACtest_account_sid", cut.Markup); + Assert.Contains("+15551234567", cut.Markup); + Assert.Contains("MGtest_messaging_service", cut.Markup); + // Auth Token shows a presence indicator only — never the value. + Assert.Contains("Auth Token", cut.Markup); + Assert.Contains("(stored)", cut.Markup); + Assert.DoesNotContain(SecretToken, cut.Markup); + }); + } + + [Fact] + public void EditForm_DoesNotPrefillAuthToken_ButPrefillsMessagingServiceSid() + { + var repo = RepoWith(Sample()); + Services.AddSingleton(repo); + WireAuth(); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ACtest_account_sid")); + + cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click(); + + cut.WaitForAssertion(() => + { + // The secret is never placed into the form markup (no input pre-fill). + Assert.DoesNotContain(SecretToken, cut.Markup); + // Non-secret fields, including MessagingServiceSid, are pre-filled. + var inputs = cut.FindAll("input"); + Assert.Contains(inputs, i => i.GetAttribute("value") == "MGtest_messaging_service"); + }); + } + + [Fact] + public void SavingNewConfig_CallsAddAndSaveChanges() + { + var repo = RepoWith(); + Services.AddSingleton(repo); + WireAuth(); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("No SMS configuration set.")); + + cut.FindAll("button").First(b => b.TextContent.Contains("Add SMS configuration")).Click(); + + // Re-query between each Change(): two-way binding re-renders the form and + // invalidates previously found element references. + cut.FindAll("input[type=text]")[0].Change("ACnew_account"); // Account SID + cut.FindAll("input[type=text]")[1].Change("+15559876543"); // From Number + cut.FindAll("input[type=password]").First().Change("new-token"); + + cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click(); + + cut.WaitForAssertion(() => + { + repo.Received().AddSmsConfigurationAsync( + Arg.Is(c => + c.AccountSid == "ACnew_account" && + c.FromNumber == "+15559876543" && + c.AuthToken == "new-token")); + repo.Received().SaveChangesAsync(); + }); + } + + [Fact] + public void SavingEdit_WithBlankAuthToken_PreservesExistingToken() + { + var config = Sample(); + var repo = RepoWith(config); + Services.AddSingleton(repo); + WireAuth(); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ACtest_account_sid")); + + cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click(); + // Leave the (blank) Auth Token input untouched, then save. + cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click(); + + cut.WaitForAssertion(() => + { + repo.Received().UpdateSmsConfigurationAsync( + Arg.Is(c => + c.AuthToken == SecretToken && + c.MessagingServiceSid == "MGtest_messaging_service")); + repo.Received().SaveChangesAsync(); + }); + } + + [Fact] + public void SavingEdit_WithNewAuthToken_OverwritesToken() + { + var config = Sample(); + var repo = RepoWith(config); + Services.AddSingleton(repo); + WireAuth(); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ACtest_account_sid")); + + cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click(); + cut.FindAll("input[type=password]").First().Change("rotated-token"); + cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click(); + + cut.WaitForAssertion(() => + { + repo.Received().UpdateSmsConfigurationAsync( + Arg.Is(c => c.AuthToken == "rotated-token")); + repo.Received().SaveChangesAsync(); + }); + } +}