diff --git a/core/src/main/java/hudson/cli/CLICommand.java b/core/src/main/java/hudson/cli/CLICommand.java index e1f1718e4a83..a8ae52454944 100644 --- a/core/src/main/java/hudson/cli/CLICommand.java +++ b/core/src/main/java/hudson/cli/CLICommand.java @@ -48,10 +48,10 @@ import java.nio.charset.Charset; import java.util.List; import java.util.Locale; -import java.util.UUID; -import java.util.logging.Level; -import java.util.logging.Logger; +import jenkins.cli.listeners.CLIContext; +import jenkins.cli.listeners.CLIListener; import jenkins.model.Jenkins; +import jenkins.util.Listeners; import jenkins.util.SystemProperties; import org.jvnet.hudson.annotation_indexer.Index; import org.jvnet.tiger_types.Types; @@ -242,70 +242,73 @@ public int main(List args, Locale locale, InputStream stdin, PrintStream this.locale = locale; CmdLineParser p = getCmdLineParser(); + Authentication auth = getTransportAuthentication2(); + CLIContext context = new CLIContext(getName(), args, auth); + // add options from the authenticator SecurityContext sc = null; Authentication old = null; - Authentication auth; try { // TODO as in CLIRegisterer this may be doing too much work sc = SecurityContextHolder.getContext(); old = sc.getAuthentication(); - sc.setAuthentication(auth = getTransportAuthentication2()); + sc.setAuthentication(auth); if (!(this instanceof HelpCommand || this instanceof WhoAmICommand)) Jenkins.get().checkPermission(Jenkins.READ); p.parseArgument(args.toArray(new String[0])); - LOGGER.log(Level.FINE, "Invoking CLI command {0}, with {1} arguments, as user {2}.", - new Object[] {getName(), args.size(), auth.getName()}); + + Listeners.notify(CLIListener.class, true, listener -> listener.onExecution(context)); int res = run(); - LOGGER.log(Level.FINE, "Executed CLI command {0}, with {1} arguments, as user {2}, return code {3}", - new Object[] {getName(), args.size(), auth.getName(), res}); + Listeners.notify(CLIListener.class, true, listener -> listener.onCompleted(context, res)); + return res; - } catch (CmdLineException e) { - logFailedCommandAndPrintExceptionErrorMessage(args, e); - printUsage(stderr, p); - return 2; - } catch (IllegalStateException e) { - logFailedCommandAndPrintExceptionErrorMessage(args, e); - return 4; - } catch (IllegalArgumentException e) { - logFailedCommandAndPrintExceptionErrorMessage(args, e); - return 3; - } catch (AbortException e) { - logFailedCommandAndPrintExceptionErrorMessage(args, e); - return 5; - } catch (AccessDeniedException e) { - logFailedCommandAndPrintExceptionErrorMessage(args, e); - return 6; - } catch (BadCredentialsException e) { - // to the caller, we can't reveal whether the user didn't exist or the password didn't match. - // do that to the server log instead - String id = UUID.randomUUID().toString(); - logAndPrintError(e, "Bad Credentials. Search the server log for " + id + " for more details.", - "CLI login attempt failed: " + id, Level.INFO); - return 7; } catch (Throwable e) { - String errorMsg = "Unexpected exception occurred while performing " + getName() + " command."; - logAndPrintError(e, errorMsg, errorMsg, Level.WARNING); - Functions.printStackTrace(e, stderr); - return 1; + int exitCode = handleException(e, context, p); + Listeners.notify(CLIListener.class, true, listener -> listener.onThrowable(context, e)); + return exitCode; } finally { if (sc != null) sc.setAuthentication(old); // restore } } - private void logFailedCommandAndPrintExceptionErrorMessage(List args, Throwable e) { - Authentication auth = getTransportAuthentication2(); - String logMessage = String.format("Failed call to CLI command %s, with %d arguments, as user %s.", - getName(), args.size(), auth != null ? auth.getName() : ""); - - logAndPrintError(e, e.getMessage(), logMessage, Level.FINE); + /** + * Determines command stderr output and return the exit code as described on {@link #main(List, Locale, InputStream, PrintStream, PrintStream)} + * */ + protected int handleException(Throwable e, CLIContext context, CmdLineParser p) { + int exitCode; + if (e instanceof CmdLineException) { + exitCode = 2; + printError(e.getMessage()); + printUsage(stderr, p); + } else if (e instanceof IllegalArgumentException) { + exitCode = 3; + printError(e.getMessage()); + } else if (e instanceof IllegalStateException) { + exitCode = 4; + printError(e.getMessage()); + } else if (e instanceof AbortException) { + exitCode = 5; + printError(e.getMessage()); + } else if (e instanceof AccessDeniedException) { + exitCode = 6; + printError(e.getMessage()); + } else if (e instanceof BadCredentialsException) { + exitCode = 7; + printError( + "Bad Credentials. Search the server log for " + context.getCorrelationId() + " for more details."); + } else { + exitCode = 1; + printError("Unexpected exception occurred while performing " + getName() + " command."); + Functions.printStackTrace(e, stderr); + } + return exitCode; } - private void logAndPrintError(Throwable e, String errorMessage, String logMessage, Level logLevel) { - LOGGER.log(logLevel, logMessage, e); + + private void printError(String errorMessage) { this.stderr.println(); this.stderr.println("ERROR: " + errorMessage); } @@ -541,8 +544,6 @@ public static CLICommand clone(String name) { return null; } - private static final Logger LOGGER = Logger.getLogger(CLICommand.class.getName()); - private static final ThreadLocal CURRENT_COMMAND = new ThreadLocal<>(); /*package*/ static CLICommand setCurrent(CLICommand cmd) { diff --git a/core/src/main/java/hudson/cli/declarative/CLIRegisterer.java b/core/src/main/java/hudson/cli/declarative/CLIRegisterer.java index 76b2c2612c15..33c8e7b48ac2 100644 --- a/core/src/main/java/hudson/cli/declarative/CLIRegisterer.java +++ b/core/src/main/java/hudson/cli/declarative/CLIRegisterer.java @@ -27,11 +27,9 @@ import static java.util.logging.Level.SEVERE; import edu.umd.cs.findbugs.annotations.NonNull; -import hudson.AbortException; import hudson.Extension; import hudson.ExtensionComponent; import hudson.ExtensionFinder; -import hudson.Functions; import hudson.Util; import hudson.cli.CLICommand; import hudson.cli.CloneableCLICommand; @@ -49,19 +47,17 @@ import java.util.Locale; import java.util.MissingResourceException; import java.util.Stack; -import java.util.UUID; -import java.util.logging.Level; import java.util.logging.Logger; import jenkins.ExtensionComponentSet; import jenkins.ExtensionRefreshException; +import jenkins.cli.listeners.CLIContext; +import jenkins.cli.listeners.CLIListener; import jenkins.model.Jenkins; +import jenkins.util.Listeners; import org.jvnet.hudson.annotation_indexer.Index; import org.jvnet.localizer.ResourceBundleHolder; -import org.kohsuke.args4j.CmdLineException; import org.kohsuke.args4j.CmdLineParser; import org.kohsuke.args4j.ParserProperties; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -202,6 +198,9 @@ public int main(List args, Locale locale, InputStream stdin, PrintStream List binders = new ArrayList<>(); + Authentication auth = getTransportAuthentication2(); + CLIContext context = new CLIContext(getName(), args, auth); + CmdLineParser parser = bindMethod(binders); try { // TODO this could probably use ACL.as; why is it calling SecurityContext.setAuthentication rather than SecurityContextHolder.setContext? @@ -211,19 +210,19 @@ public int main(List args, Locale locale, InputStream stdin, PrintStream // fill up all the binders parser.parseArgument(args); - Authentication auth = getTransportAuthentication2(); sc.setAuthentication(auth); // run the CLI with the right credential jenkins.checkPermission(Jenkins.READ); + Listeners.notify(CLIListener.class, true, listener -> listener.onExecution(context)); + // resolve them Object instance = null; for (MethodBinder binder : binders) instance = binder.call(instance); - if (instance instanceof Integer) - return (Integer) instance; - else - return 0; + Integer exitCode = (instance instanceof Integer) ? (Integer) instance : 0; + Listeners.notify(CLIListener.class, true, listener -> listener.onCompleted(context, exitCode)); + return exitCode; } catch (InvocationTargetException e) { Throwable t = e.getTargetException(); if (t instanceof Exception) @@ -232,47 +231,13 @@ public int main(List args, Locale locale, InputStream stdin, PrintStream } finally { sc.setAuthentication(old); // restore } - } catch (CmdLineException e) { - printError(e.getMessage()); - printUsage(stderr, parser); - return 2; - } catch (IllegalStateException e) { - printError(e.getMessage()); - return 4; - } catch (IllegalArgumentException e) { - printError(e.getMessage()); - return 3; - } catch (AbortException e) { - printError(e.getMessage()); - return 5; - } catch (AccessDeniedException e) { - printError(e.getMessage()); - return 6; - } catch (BadCredentialsException e) { - // to the caller, we can't reveal whether the user didn't exist or the password didn't match. - // do that to the server log instead - String id = UUID.randomUUID().toString(); - logAndPrintError(e, "Bad Credentials. Search the server log for " + id + " for more details.", - "CLI login attempt failed: " + id, Level.INFO); - return 7; } catch (Throwable e) { - final String errorMsg = "Unexpected exception occurred while performing " + getName() + " command."; - logAndPrintError(e, errorMsg, errorMsg, Level.WARNING); - Functions.printStackTrace(e, stderr); - return 1; + int exitCode = handleException(e, context, parser); + Listeners.notify(CLIListener.class, true, listener -> listener.onThrowable(context, e)); + return exitCode; } } - private void printError(String errorMessage) { - this.stderr.println(); - this.stderr.println("ERROR: " + errorMessage); - } - - private void logAndPrintError(Throwable e, String errorMessage, String logMessage, Level logLevel) { - LOGGER.log(logLevel, logMessage, e); - printError(errorMessage); - } - @Override protected int run() throws Exception { throw new UnsupportedOperationException(); diff --git a/core/src/main/java/jenkins/cli/listeners/CLIContext.java b/core/src/main/java/jenkins/cli/listeners/CLIContext.java new file mode 100644 index 000000000000..dfcbd03fb796 --- /dev/null +++ b/core/src/main/java/jenkins/cli/listeners/CLIContext.java @@ -0,0 +1,88 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.cli.listeners; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import java.util.List; +import java.util.UUID; +import org.springframework.security.core.Authentication; + +/** + * Holds information of a command execution. Same instance is used to all {@link CLIListener} invocations. + * Use {@code correlationId} in order to group related events to the same command. + * + * @since TODO + */ +public class CLIContext { + private final String correlationId = UUID.randomUUID().toString(); + private final String command; + private final List args; + private final Authentication auth; + + /** + * @param command The command being executed. + * @param args Arguments passed to the command. + * @param auth Authenticated user performing the execution. + */ + public CLIContext(@NonNull String command, @CheckForNull List args, @Nullable Authentication auth) { + this.command = command; + this.args = args != null ? args : List.of(); + this.auth = auth; + } + + /** + * @return Correlate this command event to other, related command events. + */ + @NonNull + public String getCorrelationId() { + return correlationId; + } + + /** + * @return Command being executed. + */ + @NonNull + public String getCommand() { + return command; + } + + /** + * @return Arguments passed to the command. + */ + @NonNull + public List getArgs() { + return args; + } + + /** + * @return Authenticated user performing the execution. + */ + @CheckForNull + public Authentication getAuth() { + return auth; + } +} diff --git a/core/src/main/java/jenkins/cli/listeners/CLIListener.java b/core/src/main/java/jenkins/cli/listeners/CLIListener.java new file mode 100644 index 000000000000..ae5f5381c2af --- /dev/null +++ b/core/src/main/java/jenkins/cli/listeners/CLIListener.java @@ -0,0 +1,60 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.cli.listeners; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.ExtensionPoint; +import hudson.cli.CLICommand; + +/** + * Allows implementations to listen to {@link CLICommand#run()} execution events. + * + * @since TODO + */ +public interface CLIListener extends ExtensionPoint { + + /** + * Invoked before command execution. + * + * @param context Information about the command being executed. + * */ + default void onExecution(@NonNull CLIContext context) {} + + /** + * Invoked after command execution. + * + * @param context Information about the command being executed. + * @param exitCode Exit code returned by the implementation of {@link CLICommand#run()}. + * */ + default void onCompleted(@NonNull CLIContext context, int exitCode) {} + + /** + * Invoked when an exception or error occurs during command execution. + * + * @param context Information about the command being executed. + * @param t Any error during the execution of the command. + * */ + default void onThrowable(@NonNull CLIContext context, @NonNull Throwable t) {} +} diff --git a/core/src/main/java/jenkins/cli/listeners/DefaultCLIListener.java b/core/src/main/java/jenkins/cli/listeners/DefaultCLIListener.java new file mode 100644 index 000000000000..426de3ae04d8 --- /dev/null +++ b/core/src/main/java/jenkins/cli/listeners/DefaultCLIListener.java @@ -0,0 +1,90 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.cli.listeners; + +import hudson.AbortException; +import hudson.Extension; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.args4j.CmdLineException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; + +/** + * Basic default implementation of {@link CLIListener} that just logs. + */ +@Extension +@Restricted(NoExternalUse.class) +public class DefaultCLIListener implements CLIListener { + private static final Logger LOGGER = Logger.getLogger(DefaultCLIListener.class.getName()); + + @Override + public void onExecution(CLIContext context) { + LOGGER.log(Level.FINE, "Invoking CLI command {0}, with {1} arguments, as user {2}.", new Object[] { + context.getCommand(), context.getArgs().size(), authName(context.getAuth()), + }); + } + + @Override + public void onCompleted(CLIContext context, int exitCode) { + LOGGER.log( + Level.FINE, "Executed CLI command {0}, with {1} arguments, as user {2}, return code {3}", new Object[] { + context.getCommand(), context.getArgs().size(), authName(context.getAuth()), exitCode, + }); + } + + @Override + public void onThrowable(CLIContext context, Throwable t) { + if (t instanceof BadCredentialsException) { + // to the caller (stderr), we can't reveal whether the user didn't exist or the password didn't match. + // do that to the server log instead + LOGGER.log(Level.INFO, "CLI login attempt failed: " + context.getCorrelationId(), t); + } else if (t instanceof CmdLineException + || t instanceof IllegalArgumentException + || t instanceof IllegalStateException + || t instanceof AbortException + || t instanceof AccessDeniedException) { + // covered cases on CLICommand#handleException + LOGGER.log( + Level.FINE, + String.format( + "Failed call to CLI command %s, with %d arguments, as user %s.", + context.getCommand(), context.getArgs().size(), authName(context.getAuth())), + t); + } else { + LOGGER.log( + Level.WARNING, + "Unexpected exception occurred while performing " + context.getCommand() + " command.", + t); + } + } + + private static String authName(Authentication auth) { + return auth != null ? auth.getName() : ""; + } +} diff --git a/test/src/test/java/jenkins/cli/DefaultCLIListenerTest.java b/test/src/test/java/jenkins/cli/DefaultCLIListenerTest.java new file mode 100644 index 000000000000..7bb66120b180 --- /dev/null +++ b/test/src/test/java/jenkins/cli/DefaultCLIListenerTest.java @@ -0,0 +1,186 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.cli; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.notNullValue; + +import hudson.cli.CLICommand; +import hudson.cli.CLICommandInvoker; +import hudson.cli.ListJobsCommand; +import java.io.IOException; +import java.util.List; +import java.util.logging.Level; +import jenkins.cli.listeners.DefaultCLIListener; +import jenkins.model.Jenkins; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; +import org.jvnet.hudson.test.TestExtension; + +public class DefaultCLIListenerTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Rule + public LoggerRule logging = new LoggerRule(); + + private static final String USER = "cli-user"; + + @Before + public void setUp() throws IOException { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER) + .everywhere() + .to(USER)); + j.createFreeStyleProject("p"); + logging.record(DefaultCLIListener.class, Level.FINE).capture(2); + } + + @Test + public void commandOnCompletedIsLogged() throws Exception { + CLICommandInvoker command = new CLICommandInvoker(j, new ListJobsCommand()); + command.asUser(USER).invoke(); + + List messages = logging.getMessages(); + assertThat(messages, hasSize(2)); + assertThat( + messages.get(0), + containsString("Invoking CLI command list-jobs, with 0 arguments, as user %s.".formatted(USER))); + assertThat( + messages.get(1), + containsString( + "Executed CLI command list-jobs, with 0 arguments, as user %s, return code 0".formatted(USER))); + } + + @Test + public void commandOnThrowableIsLogged() throws Exception { + CLICommandInvoker command = new CLICommandInvoker(j, new ListJobsCommand()); + command.asUser(USER).invokeWithArgs("view-not-found"); + + List messages = logging.getMessages(); + assertThat(messages, hasSize(2)); + assertThat( + messages.get(0), + containsString("Invoking CLI command list-jobs, with 1 arguments, as user %s.".formatted(USER))); + assertThat( + messages.get(1), + containsString("Failed call to CLI command list-jobs, with 1 arguments, as user %s.".formatted(USER))); + assertThat( + logging.getRecords().get(0).getThrown().getMessage(), + containsString("No view or item group with the given name 'view-not-found' found")); + } + + @Test + public void commandOnThrowableUnexpectedIsLogged() throws Exception { + CLICommandInvoker command = new CLICommandInvoker(j, new ThrowsTestCommand()); + command.asUser(USER).invoke(); + + List messages = logging.getMessages(); + assertThat(messages, hasSize(2)); + assertThat( + messages.get(0), + containsString( + "Invoking CLI command throws-test-command, with 0 arguments, as user %s.".formatted(USER))); + assertThat( + messages.get(1), + containsString("Unexpected exception occurred while performing throws-test-command command.")); + assertThat(logging.getRecords().get(0).getThrown().getMessage(), containsString("unexpected")); + } + + @Test + public void methodOnCompletedIsLogged() throws Exception { + CLICommandInvoker command = new CLICommandInvoker(j, "disable-job"); + command.asUser(USER).invokeWithArgs("p"); + + List messages = logging.getMessages(); + assertThat(messages, hasSize(2)); + assertThat( + messages.get(0), + containsString("Invoking CLI command disable-job, with 1 arguments, as user %s.".formatted(USER))); + assertThat( + messages.get(1), + containsString("Executed CLI command disable-job, with 1 arguments, as user %s, return code 0" + .formatted(USER))); + } + + @Test + public void methodOnThrowableIsLogged() throws Exception { + CLICommandInvoker command = new CLICommandInvoker(j, "disable-job"); + command.asUser(USER).invokeWithArgs("job-not-found"); + + List messages = logging.getMessages(); + assertThat(messages, hasSize(2)); + assertThat( + messages.get(0), + containsString("Invoking CLI command disable-job, with 1 arguments, as user %s.".formatted(USER))); + assertThat( + messages.get(1), + containsString( + "Failed call to CLI command disable-job, with 1 arguments, as user %s.".formatted(USER))); + assertThat( + logging.getRecords().get(0).getThrown().getMessage(), + containsString("No such job ‘job-not-found’ exists.")); + } + + @Test + public void methodOnThrowableUnexpectedIsLogged() throws Exception { + CLICommandInvoker command = new CLICommandInvoker(j, "restart"); + command.asUser(USER).invoke(); + + List messages = logging.getMessages(); + assertThat(messages, hasSize(2)); + assertThat( + messages.get(0), + containsString("Invoking CLI command restart, with 0 arguments, as user %s.".formatted(USER))); + assertThat(messages.get(1), containsString("Unexpected exception occurred while performing restart command.")); + assertThat(logging.getRecords().get(0).getThrown(), notNullValue()); + } + + @TestExtension + public static class ThrowsTestCommand extends CLICommand { + @Override + public String getName() { + return "throws-test-command"; + } + + @Override + public String getShortDescription() { + return "throws test command"; + } + + @Override + protected int run() { + throw new RuntimeException("unexpected"); + } + } +}