33e1802e6d
Code-review finding UI-Med-2: the design doc + delivery adapter treat FromNumber and MessagingServiceSid as either-or, but the entity ctor, EF schema, UI and CLI all hard- required FromNumber — so a Messaging-Service-only Twilio config (a normal production setup) could not be created. Bring the implementation into line with the spec: - Commons: SmsConfiguration.FromNumber -> string? (ctor fromNumber optional); UpdateSmsConfigCommand.FromNumber -> string?. - ConfigurationDatabase: FromNumber.IsRequired(false) + migration SmsFromNumberOptional (ALTER COLUMN nullable, idempotent; Down backfills '' — harmless, MsgSid keeps it deliverable) + regenerated model snapshot. - Transport: SmsConfigDto.FromNumber -> string? (round-trips a Messaging-Service-only config). - CentralUI: form validation requires AccountSid + at-least-one-of(FromNumber, MsgSid); nullable create/edit paths; From-number help text. - CLI: --from-number no longer Required; BuildUpdateSmsConfigCommand validates the either-or. - Adapter: From branch null-forgiving (guarded by the existing incomplete-config check). Tests: ManagementActor MsgSid-only persists null FromNumber; CLI MsgSid-only builds + neither-throws + contract (--from-number not Required); CentralUI MsgSid-only save.
210 lines
7.9 KiB
C#
210 lines
7.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<AuthenticationStateProvider>(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<INotificationRepository>();
|
|
repo.GetAllSmsConfigurationsAsync()
|
|
.Returns(Task.FromResult<IReadOnlyList<SmsConfiguration>>(configs.ToList()));
|
|
return repo;
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadOnlyView_ShowsConfigRow_ButNeverRendersAuthTokenValue()
|
|
{
|
|
var repo = RepoWith(Sample());
|
|
Services.AddSingleton(repo);
|
|
WireAuth();
|
|
|
|
var cut = Render<SmsConfigurationPage>();
|
|
|
|
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<SmsConfigurationPage>();
|
|
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<SmsConfigurationPage>();
|
|
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<SmsConfiguration>(c =>
|
|
c.AccountSid == "ACnew_account" &&
|
|
c.FromNumber == "+15559876543" &&
|
|
c.AuthToken == "new-token"));
|
|
repo.Received().SaveChangesAsync();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void SavingNewConfig_MessagingServiceSidOnly_NoFromNumber_Saves()
|
|
{
|
|
// UI-Med-2: a Twilio Messaging-Service-only config is valid with no From number.
|
|
// The either-or validation must accept it and persist a null FromNumber.
|
|
var repo = RepoWith();
|
|
Services.AddSingleton(repo);
|
|
WireAuth();
|
|
|
|
var cut = Render<SmsConfigurationPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("No SMS configuration set."));
|
|
|
|
cut.FindAll("button").First(b => b.TextContent.Contains("Add SMS configuration")).Click();
|
|
|
|
// Account SID + Messaging Service SID, leaving From Number (index 1) blank.
|
|
cut.FindAll("input[type=text]")[0].Change("ACmsg_account"); // Account SID
|
|
cut.FindAll("input[type=text]")[2].Change("MGmessaging_service"); // Messaging Service SID
|
|
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<SmsConfiguration>(c =>
|
|
c.AccountSid == "ACmsg_account" &&
|
|
c.FromNumber == null &&
|
|
c.MessagingServiceSid == "MGmessaging_service" &&
|
|
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<SmsConfigurationPage>();
|
|
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<SmsConfiguration>(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<SmsConfigurationPage>();
|
|
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<SmsConfiguration>(c => c.AuthToken == "rotated-token"));
|
|
repo.Received().SaveChangesAsync();
|
|
});
|
|
}
|
|
}
|