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; /// /// Tests for the SMS surface of the notification command group (SMS Notifications S6): /// the channel-aware --type / --phones flags on notification list create|update, /// and the new notification sms list|update group. Pins the validation rules, the /// per-channel command construction, and that the Twilio Auth Token is never echoed back. /// public class NotificationSmsCommandTests { private static readonly Option Url = new("--url") { Recursive = true }; private static readonly Option Username = new("--username") { Recursive = true }; private static readonly Option Password = new("--password") { Recursive = true }; private static readonly Option 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( () => 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( () => 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( () => 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( () => 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( () => 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( () => 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(() => 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 ------------------------------------- } /// /// Console-capturing companion to — pins that the /// Twilio Auth Token is never echoed to the rendered output. Lives in the shared "Console" /// collection so the redirection does not race the other /// console-capturing test classes. /// [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); } }