diff --git a/src/ScadaLink.CLI/Commands/NotificationCommands.cs b/src/ScadaLink.CLI/Commands/NotificationCommands.cs index 82b1d8f..685599b 100644 --- a/src/ScadaLink.CLI/Commands/NotificationCommands.cs +++ b/src/ScadaLink.CLI/Commands/NotificationCommands.cs @@ -69,33 +69,72 @@ public static class NotificationCommands }); group.Add(listCmd); - var idOption = new Option("--id") { Description = "SMTP config ID", Required = true }; - var serverOption = new Option("--server") { Description = "SMTP server", Required = true }; - var portOption = new Option("--port") { Description = "SMTP port", Required = true }; - var authModeOption = new Option("--auth-mode") { Description = "Auth mode", Required = true }; - var fromOption = new Option("--from-address") { Description = "From email address", Required = true }; var updateCmd = new Command("update") { Description = "Update SMTP configuration" }; - updateCmd.Add(idOption); - updateCmd.Add(serverOption); - updateCmd.Add(portOption); - updateCmd.Add(authModeOption); - updateCmd.Add(fromOption); + updateCmd.Add(SmtpIdOption); + updateCmd.Add(SmtpServerOption); + updateCmd.Add(SmtpPortOption); + updateCmd.Add(SmtpAuthModeOption); + updateCmd.Add(SmtpFromOption); + updateCmd.Add(SmtpTlsModeOption); + updateCmd.Add(SmtpCredentialsOption); updateCmd.SetAction(async (ParseResult result) => { - var id = result.GetValue(idOption); - var server = result.GetValue(serverOption)!; - var port = result.GetValue(portOption); - var authMode = result.GetValue(authModeOption)!; - var from = result.GetValue(fromOption)!; return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, - new UpdateSmtpConfigCommand(id, server, port, authMode, from)); + BuildUpdateSmtpConfigCommand(result)); }); group.Add(updateCmd); return group; } + // SMTP update options are static so the parsed values can be read back both + // from the SetAction and from BuildUpdateSmtpConfigCommand (used by tests). + private static readonly Option SmtpIdOption = + new("--id") { Description = "SMTP config ID", Required = true }; + private static readonly Option SmtpServerOption = + new("--server") { Description = "SMTP server", Required = true }; + private static readonly Option SmtpPortOption = + new("--port") { Description = "SMTP port", Required = true }; + private static readonly Option SmtpAuthModeOption = + new("--auth-mode") { Description = "Auth mode", Required = true }; + private static readonly Option SmtpFromOption = + new("--from-address") { Description = "From email address", Required = true }; + private static readonly Option SmtpTlsModeOption = CreateTlsModeOption(); + private static readonly Option SmtpCredentialsOption = + new("--credentials") + { + Description = "SMTP credentials — 'username:password' for Basic, or client secret " + + "for OAuth2 (optional; preserves existing if omitted)", + }; + + private static Option CreateTlsModeOption() + { + var option = new Option("--tls-mode") + { + Description = "TLS mode: None, StartTLS, or SSL (optional; preserves existing if omitted)", + }; + option.AcceptOnlyFromAmong("None", "StartTLS", "SSL"); + return option; + } + + /// + /// Builds the from a parsed smtp update + /// invocation. The optional --tls-mode / --credentials flags map to + /// null when omitted so the server-side handler preserves the existing values. + /// + internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result) + { + var id = result.GetValue(SmtpIdOption); + var server = result.GetValue(SmtpServerOption)!; + var port = result.GetValue(SmtpPortOption); + var authMode = result.GetValue(SmtpAuthModeOption)!; + var from = result.GetValue(SmtpFromOption)!; + var tlsMode = result.GetValue(SmtpTlsModeOption); + var credentials = result.GetValue(SmtpCredentialsOption); + return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials); + } + private static Command BuildList(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("list") { Description = "List all notification lists" }; diff --git a/tests/ScadaLink.CLI.Tests/Commands/SmtpUpdateCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/SmtpUpdateCommandTests.cs new file mode 100644 index 0000000..d644a53 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/SmtpUpdateCommandTests.cs @@ -0,0 +1,104 @@ +using System.CommandLine; +using ScadaLink.CLI.Commands; +using ScadaLink.Commons.Messages.Management; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Tests for the scadalink notification smtp update subcommand. The command +/// gained two optional flags — --tls-mode and --credentials — that plumb +/// through to . These tests pin that the flags +/// parse, are genuinely optional (non-breaking), and that --tls-mode rejects +/// values outside the canonical {None, StartTLS, SSL} set. +/// +public class SmtpUpdateCommandTests +{ + 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 SmtpUpdateCommand() + { + var notification = NotificationCommands.Build(Url, Format, Username, Password); + var smtp = notification.Subcommands.Single(c => c.Name == "smtp"); + return smtp.Subcommands.Single(c => c.Name == "update"); + } + + private static ParseResult ParseUpdate(params string[] args) + => SmtpUpdateCommand().Parse(args); + + [Fact] + public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem() + { + var parse = ParseUpdate( + "--id", "1", "--server", "smtp.example.com", "--port", "587", + "--auth-mode", "Basic", "--from-address", "noreply@example.com", + "--tls-mode", "None", "--credentials", "user:pass"); + + Assert.Empty(parse.Errors); + var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse); + + Assert.Equal(1, cmd.SmtpConfigId); + Assert.Equal("smtp.example.com", cmd.Server); + Assert.Equal(587, cmd.Port); + Assert.Equal("Basic", cmd.AuthMode); + Assert.Equal("noreply@example.com", cmd.FromAddress); + Assert.Equal("None", cmd.TlsMode); + Assert.Equal("user:pass", cmd.Credentials); + } + + [Fact] + public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls() + { + var parse = ParseUpdate( + "--id", "2", "--server", "smtp.example.com", "--port", "25", + "--auth-mode", "OAuth2", "--from-address", "noreply@example.com"); + + Assert.Empty(parse.Errors); + var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse); + + Assert.Equal(2, cmd.SmtpConfigId); + Assert.Null(cmd.TlsMode); + Assert.Null(cmd.Credentials); + } + + [Theory] + [InlineData("None")] + [InlineData("StartTLS")] + [InlineData("SSL")] + public void Update_TlsModeOption_AcceptsCanonicalValues(string value) + { + var parse = ParseUpdate( + "--id", "1", "--server", "smtp.example.com", "--port", "587", + "--auth-mode", "Basic", "--from-address", "noreply@example.com", + "--tls-mode", value); + + Assert.Empty(parse.Errors); + } + + [Theory] + [InlineData("Bogus")] + [InlineData("tls")] + [InlineData("none")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling + public void Update_TlsModeOption_RejectsValuesOutsideCanonicalSet(string value) + { + var parse = ParseUpdate( + "--id", "1", "--server", "smtp.example.com", "--port", "587", + "--auth-mode", "Basic", "--from-address", "noreply@example.com", + "--tls-mode", value); + + Assert.NotEmpty(parse.Errors); + } + + [Fact] + public void Update_TlsModeAndCredentials_AreNotRequired() + { + var update = SmtpUpdateCommand(); + var tls = update.Options.Single(o => o.Name == "--tls-mode"); + var creds = update.Options.Single(o => o.Name == "--credentials"); + + Assert.False(tls.Required, "--tls-mode must be optional (preserve-if-omitted)."); + Assert.False(creds.Required, "--credentials must be optional (preserve-if-omitted)."); + } +}