Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/NotificationSmsCommandTests.cs
T
Joseph Doherty 33e1802e6d feat(sms): make FromNumber optional — support Twilio Messaging-Service-only configs (UI-Med-2)
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.
2026-06-19 15:19:40 -04:00

300 lines
11 KiB
C#

using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for the SMS surface of the <c>notification</c> command group (SMS Notifications S6):
/// the channel-aware <c>--type</c> / <c>--phones</c> flags on <c>notification list create|update</c>,
/// and the new <c>notification sms list|update</c> group. Pins the validation rules, the
/// per-channel command construction, and that the Twilio Auth Token is never echoed back.
/// </summary>
public class NotificationSmsCommandTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
private static Command Notification() => NotificationCommands.Build(Url, Format, Username, Password);
private static Command ListCreate() =>
Notification().Subcommands.Single(c => c.Name == "create");
private static Command ListUpdate() =>
Notification().Subcommands.Single(c => c.Name == "update");
private static Command SmsGroup() =>
Notification().Subcommands.Single(c => c.Name == "sms");
private static Command SmsUpdate() =>
SmsGroup().Subcommands.Single(c => c.Name == "update");
// System.CommandLine's Parse takes a string[] (no params overload here), so route
// every invocation through a helper that wraps the variadic args into an array.
private static System.CommandLine.ParseResult Parse(Command command, params string[] args)
=> command.Parse(args);
// ---- list create/update: --type + --phones ----------------------------
[Fact]
public void ListCreate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
{
var parse = Parse(ListCreate(),
"--name", "Ops SMS", "--type", "sms", "--phones", "+15551230000,+15551230001");
Assert.Empty(parse.Errors);
var cmd = NotificationCommands.BuildCreateNotificationListCommand(parse);
Assert.Equal("Ops SMS", cmd.Name);
Assert.Equal(NotificationType.Sms, cmd.Type);
Assert.Equal(new[] { "+15551230000", "+15551230001" }, cmd.RecipientPhones);
Assert.Empty(cmd.RecipientEmails);
}
[Fact]
public void ListCreate_TypeIsCaseInsensitive()
{
var parse = Parse(ListCreate(),
"--name", "Ops SMS", "--type", "SMS", "--phones", "+15551230000");
Assert.Empty(parse.Errors);
var cmd = NotificationCommands.BuildCreateNotificationListCommand(parse);
Assert.Equal(NotificationType.Sms, cmd.Type);
}
[Fact]
public void ListCreate_DefaultType_IsEmail()
{
var parse = Parse(ListCreate(), "--name", "Ops", "--emails", "ops@example.com");
Assert.Empty(parse.Errors);
var cmd = NotificationCommands.BuildCreateNotificationListCommand(parse);
Assert.Equal(NotificationType.Email, cmd.Type);
Assert.Equal(new[] { "ops@example.com" }, cmd.RecipientEmails);
Assert.Null(cmd.RecipientPhones);
}
[Fact]
public void ListCreate_EmailTypeWithPhones_Throws()
{
var parse = Parse(ListCreate(),
"--name", "Ops", "--type", "email", "--emails", "ops@example.com", "--phones", "+15551230000");
var ex = Assert.Throws<ArgumentException>(
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
Assert.Contains("--phones", ex.Message);
}
[Fact]
public void ListCreate_SmsTypeWithoutPhones_Throws()
{
var parse = Parse(ListCreate(), "--name", "Ops SMS", "--type", "sms");
var ex = Assert.Throws<ArgumentException>(
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
Assert.Contains("--phones", ex.Message);
}
[Fact]
public void ListCreate_EmailTypeWithoutEmails_Throws()
{
var parse = Parse(ListCreate(), "--name", "Ops", "--type", "email");
var ex = Assert.Throws<ArgumentException>(
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
Assert.Contains("--emails", ex.Message);
}
[Fact]
public void ListCreate_SmsTypeWithEmails_Throws()
{
var parse = Parse(ListCreate(),
"--name", "Ops SMS", "--type", "sms", "--emails", "ops@example.com");
var ex = Assert.Throws<ArgumentException>(
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
Assert.Contains("--emails", ex.Message);
}
[Fact]
public void ListCreate_UnknownType_Throws()
{
var parse = Parse(ListCreate(),
"--name", "Ops", "--type", "carrier-pigeon", "--emails", "ops@example.com");
Assert.Throws<ArgumentException>(
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
}
[Fact]
public void ListUpdate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
{
var parse = Parse(ListUpdate(),
"--id", "7", "--name", "Ops SMS", "--type", "sms", "--phones", "+15551230000");
Assert.Empty(parse.Errors);
var cmd = NotificationCommands.BuildUpdateNotificationListCommand(parse);
Assert.Equal(7, cmd.NotificationListId);
Assert.Equal(NotificationType.Sms, cmd.Type);
Assert.Equal(new[] { "+15551230000" }, cmd.RecipientPhones);
Assert.Empty(cmd.RecipientEmails);
}
[Fact]
public void ListUpdate_EmailTypeWithPhones_Throws()
{
var parse = Parse(ListUpdate(),
"--id", "7", "--name", "Ops", "--type", "email",
"--emails", "ops@example.com", "--phones", "+15551230000");
var ex = Assert.Throws<ArgumentException>(
() => NotificationCommands.BuildUpdateNotificationListCommand(parse));
Assert.Contains("--phones", ex.Message);
}
// ---- notification sms group -------------------------------------------
[Fact]
public void Notification_HasSmsGroupWithListAndUpdate()
{
var subNames = SmsGroup().Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("list", subNames);
Assert.Contains("update", subNames);
}
[Fact]
public void SmsUpdate_WithAllFlags_ProducesCommandCarryingThem()
{
var parse = Parse(SmsUpdate(),
"--id", "1", "--account-sid", "ACnew", "--from-number", "+15551110000",
"--messaging-service-sid", "MGnew", "--api-base-url", "https://new.example.com",
"--auth-token", "new-secret");
Assert.Empty(parse.Errors);
var cmd = NotificationCommands.BuildUpdateSmsConfigCommand(parse);
Assert.Equal(1, cmd.SmsConfigId);
Assert.Equal("ACnew", cmd.AccountSid);
Assert.Equal("+15551110000", cmd.FromNumber);
Assert.Equal("MGnew", cmd.MessagingServiceSid);
Assert.Equal("https://new.example.com", cmd.ApiBaseUrl);
Assert.Equal("new-secret", cmd.AuthToken);
}
[Fact]
public void SmsUpdate_WithoutOptionalFlags_ProducesCommandWithNulls()
{
var parse = Parse(SmsUpdate(),
"--id", "2", "--account-sid", "AConly", "--from-number", "+15552220000");
Assert.Empty(parse.Errors);
var cmd = NotificationCommands.BuildUpdateSmsConfigCommand(parse);
Assert.Equal(2, cmd.SmsConfigId);
// messaging-service-sid maps to null (handler CLEARS it); auth-token / api-base-url
// map to null (handler PRESERVES them) — all three optional here.
Assert.Null(cmd.MessagingServiceSid);
Assert.Null(cmd.ApiBaseUrl);
Assert.Null(cmd.AuthToken);
}
[Fact]
public void SmsUpdate_OptionalFlags_AreNotRequired()
{
var update = SmsUpdate();
Assert.False(update.Options.Single(o => o.Name == "--from-number").Required);
Assert.False(update.Options.Single(o => o.Name == "--messaging-service-sid").Required);
Assert.False(update.Options.Single(o => o.Name == "--api-base-url").Required);
Assert.False(update.Options.Single(o => o.Name == "--auth-token").Required);
}
[Fact]
public void SmsUpdate_MessagingServiceSidOnly_NoFromNumber_Builds()
{
// Twilio Messaging-Service-only config: From number omitted, Messaging Service SID
// supplied. The either-or validation accepts it and FromNumber maps to null.
var parse = Parse(SmsUpdate(),
"--id", "3", "--account-sid", "ACmsg", "--messaging-service-sid", "MGonly");
Assert.Empty(parse.Errors);
var cmd = NotificationCommands.BuildUpdateSmsConfigCommand(parse);
Assert.Null(cmd.FromNumber);
Assert.Equal("MGonly", cmd.MessagingServiceSid);
}
[Fact]
public void SmsUpdate_NeitherFromNorMessagingService_Throws()
{
// Neither sender identity supplied => the build rejects it so an SMS config is
// never left with no way to send (mirrors the UI + delivery-adapter validation).
var parse = Parse(SmsUpdate(), "--id", "4", "--account-sid", "ACnone");
Assert.Throws<ArgumentException>(() => NotificationCommands.BuildUpdateSmsConfigCommand(parse));
}
[Fact]
public void SmsCommands_ResolveViaRegistry()
{
// The CLI calls GetCommandName for every command it sends; both SMS commands
// must round-trip through the management command registry.
Assert.Equal(typeof(ListSmsConfigsCommand),
ManagementCommandRegistry.Resolve(ManagementCommandRegistry.GetCommandName(typeof(ListSmsConfigsCommand))));
Assert.Equal(typeof(UpdateSmsConfigCommand),
ManagementCommandRegistry.Resolve(ManagementCommandRegistry.GetCommandName(typeof(UpdateSmsConfigCommand))));
}
// ---- Auth Token is never rendered -------------------------------------
}
/// <summary>
/// Console-capturing companion to <see cref="NotificationSmsCommandTests"/> — pins that the
/// Twilio Auth Token is never echoed to the rendered output. Lives in the shared "Console"
/// collection so the <see cref="Console.SetOut"/> redirection does not race the other
/// console-capturing test classes.
/// </summary>
[Collection("Console")]
public class NotificationSmsAuthTokenRenderingTests
{
[Fact]
public void SmsConfigListResponse_RenderedAsJson_DoesNotContainAuthToken()
{
// The server projects the AuthToken away to a HasAuthToken presence flag
// (SmsConfigPublicShape). The CLI renders that projected response verbatim,
// so a rendered SMS-config list never surfaces the secret token value.
var projected = new[]
{
new
{
Id = 1,
AccountSid = "ACxxxx",
FromNumber = "+15550000000",
HasAuthToken = true,
},
};
var original = Console.Out;
using var sw = new StringWriter();
try
{
Console.SetOut(sw);
OutputFormatter.WriteJson(projected);
}
finally
{
Console.SetOut(original);
}
var rendered = sw.ToString();
Assert.Contains("hasAuthToken", rendered);
Assert.DoesNotContain("authToken\"", rendered);
Assert.DoesNotContain("secret", rendered, StringComparison.OrdinalIgnoreCase);
}
}