From 721c713fa85ec77eb06922bbad70391d28f67d65 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Wed, 30 Jan 2019 12:28:55 +1300 Subject: [PATCH] NC-2043: Toml authentication provider (#689) * NC-2043: Toml authentication provider * NC-2043: CLI option to generate hashed password * Renaming test * Removing unnecessary TomlAuth interface Signed-off-by: Adrian Sutton --- ethereum/jsonrpc/build.gradle | 2 + .../jsonrpc/authentication/TomlAuth.java | 154 ++++++++++++++++++ .../authentication/TomlAuthOptions.java | 50 ++++++ .../jsonrpc/authentication/TomlUser.java | 86 ++++++++++ .../jsonrpc/authentication/TomlAuthTest.java | 128 +++++++++++++++ .../test/resources/authentication/auth.toml | 17 ++ gradle/versions.gradle | 2 + pantheon/build.gradle | 1 + .../pegasys/pantheon/cli/PantheonCommand.java | 4 +- .../pantheon/cli/PasswordSubCommand.java | 57 +++++++ .../pantheon/cli/PasswordSubCommandTest.java | 59 +++++++ 11 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuth.java create mode 100644 ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthOptions.java create mode 100644 ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlUser.java create mode 100644 ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthTest.java create mode 100644 ethereum/jsonrpc/src/test/resources/authentication/auth.toml create mode 100644 pantheon/src/main/java/tech/pegasys/pantheon/cli/PasswordSubCommand.java create mode 100644 pantheon/src/test/java/tech/pegasys/pantheon/cli/PasswordSubCommandTest.java diff --git a/ethereum/jsonrpc/build.gradle b/ethereum/jsonrpc/build.gradle index e43ce6c06b..c74cbaa8ca 100644 --- a/ethereum/jsonrpc/build.gradle +++ b/ethereum/jsonrpc/build.gradle @@ -38,6 +38,8 @@ dependencies { implementation 'com.google.guava:guava' implementation 'io.vertx:vertx-core' implementation 'io.vertx:vertx-web' + implementation 'net.consensys.cava:cava-toml' + implementation 'org.springframework.security:spring-security-crypto' testImplementation project(':config') testImplementation project(path: ':config', configuration: 'testSupportArtifacts') diff --git a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuth.java b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuth.java new file mode 100644 index 0000000000..481e5127d5 --- /dev/null +++ b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuth.java @@ -0,0 +1,154 @@ +/* + * Copyright 2019 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.pantheon.ethereum.jsonrpc.authentication; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.AuthProvider; +import io.vertx.ext.auth.User; +import net.consensys.cava.toml.Toml; +import net.consensys.cava.toml.TomlParseResult; +import net.consensys.cava.toml.TomlTable; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.crypto.bcrypt.BCrypt; + +public class TomlAuth implements AuthProvider { + + private static final Logger LOG = LogManager.getLogger(); + private final Vertx vertx; + private final TomlAuthOptions options; + + public TomlAuth(final Vertx vertx, final TomlAuthOptions options) { + this.vertx = vertx; + this.options = options; + } + + @Override + public void authenticate( + final JsonObject authInfo, final Handler> resultHandler) { + final String username = authInfo.getString("username"); + if (username == null) { + resultHandler.handle(Future.failedFuture("No username provided")); + return; + } + + final String password = authInfo.getString("password"); + if (password == null) { + resultHandler.handle(Future.failedFuture("No password provided")); + return; + } + + LOG.debug("Authenticating user {} with password {}", username, password); + + readUser( + username, + rs -> { + if (rs.succeeded()) { + TomlUser user = rs.result(); + checkPasswordHash( + password, + user.getPassword(), + rs2 -> { + if (rs2.succeeded()) { + resultHandler.handle(Future.succeededFuture(user)); + } else { + resultHandler.handle(Future.failedFuture(rs2.cause())); + } + }); + } else { + resultHandler.handle(Future.failedFuture(rs.cause())); + } + }); + } + + private void readUser(final String username, final Handler> resultHandler) { + vertx.executeBlocking( + f -> { + TomlParseResult parseResult; + try { + parseResult = Toml.parse(options.getTomlPath()); + } catch (IOException e) { + f.fail(e); + return; + } + + final TomlTable userData = parseResult.getTableOrEmpty("Users." + username); + if (userData.isEmpty()) { + f.fail("User not found"); + return; + } + + final TomlUser tomlUser = readTomlUserFromTable(username, userData); + if ("".equals(tomlUser.getPassword())) { + f.fail("No password set for user"); + return; + } + + f.complete(tomlUser); + }, + res -> { + if (res.succeeded()) { + resultHandler.handle(Future.succeededFuture((TomlUser) res.result())); + } else { + resultHandler.handle(Future.failedFuture(res.cause())); + } + }); + } + + private TomlUser readTomlUserFromTable(final String username, final TomlTable userData) { + final String saltedAndHashedPassword = userData.getString("password", () -> ""); + final List groups = + userData + .getArrayOrEmpty("groups") + .toList() + .stream() + .map(Object::toString) + .collect(Collectors.toList()); + final List permissions = + userData + .getArrayOrEmpty("permissions") + .toList() + .stream() + .map(Object::toString) + .collect(Collectors.toList()); + final List roles = + userData + .getArrayOrEmpty("roles") + .toList() + .stream() + .map(Object::toString) + .collect(Collectors.toList()); + + return new TomlUser(username, saltedAndHashedPassword, groups, permissions, roles); + } + + private void checkPasswordHash( + final String password, + final String passwordHash, + final Handler> resultHandler) { + boolean passwordMatches = BCrypt.checkpw(password, passwordHash); + if (passwordMatches) { + resultHandler.handle(Future.succeededFuture()); + } else { + resultHandler.handle(Future.failedFuture("Invalid password")); + } + } +} diff --git a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthOptions.java b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthOptions.java new file mode 100644 index 0000000000..03f0257381 --- /dev/null +++ b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthOptions.java @@ -0,0 +1,50 @@ +/* + * Copyright 2019 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.pantheon.ethereum.jsonrpc.authentication; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import io.vertx.core.Vertx; +import io.vertx.ext.auth.AuthOptions; +import io.vertx.ext.auth.AuthProvider; + +public class TomlAuthOptions implements AuthOptions { + + private Path tomlPath; + + public TomlAuthOptions() {} + + public TomlAuthOptions(final TomlAuthOptions that) { + tomlPath = that.tomlPath; + } + + @Override + public AuthProvider createProvider(final Vertx vertx) { + return new TomlAuth(vertx, this); + } + + @Override + public AuthOptions clone() { + return new TomlAuthOptions(this); + } + + public TomlAuthOptions setTomlPath(final String tomlPath) { + this.tomlPath = Paths.get(tomlPath); + return this; + } + + public Path getTomlPath() { + return tomlPath; + } +} diff --git a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlUser.java b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlUser.java new file mode 100644 index 0000000000..b0012c5fe5 --- /dev/null +++ b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlUser.java @@ -0,0 +1,86 @@ +/* + * Copyright 2019 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.pantheon.ethereum.jsonrpc.authentication; + +import java.util.List; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Handler; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.AbstractUser; +import io.vertx.ext.auth.AuthProvider; + +public class TomlUser extends AbstractUser { + + private final String username; + private final String password; + private final List groups; + private final List permissions; + private final List roles; + + TomlUser( + final String username, + final String password, + final List groups, + final List permissions, + final List roles) { + this.username = username; + this.password = password; + this.groups = groups; + this.permissions = permissions; + this.roles = roles; + } + + @Override + public JsonObject principal() { + return new JsonObject() + .put("username", username) + .put("password", password) + .put("groups", groups) + .put("permissions", permissions) + .put("roles", roles); + } + + @Override + public void setAuthProvider(final AuthProvider authProvider) { + // we only use Toml for authentication + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + protected void doIsPermitted( + final String permission, final Handler> resultHandler) { + // we only use Toml for authentication + throw new UnsupportedOperationException("Not implemented"); + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public List getGroups() { + return groups; + } + + public List getPermissions() { + return permissions; + } + + public List getRoles() { + return roles; + } +} diff --git a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthTest.java b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthTest.java new file mode 100644 index 0000000000..b318f483f7 --- /dev/null +++ b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2019 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.pantheon.ethereum.jsonrpc.authentication; + +import java.net.URISyntaxException; +import java.nio.file.NoSuchFileException; +import java.nio.file.Paths; + +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(VertxUnitRunner.class) +public class TomlAuthTest { + + private Vertx vertx; + private JsonObject validAuthInfo; + private TomlAuth tomlAuth; + + @Before + public void before(final TestContext context) throws URISyntaxException { + vertx = Vertx.vertx(); + tomlAuth = + new TomlAuth( + vertx, new TomlAuthOptions().setTomlPath(getTomlPath("authentication/auth.toml"))); + validAuthInfo = new JsonObject().put("username", "userA").put("password", "pegasys"); + } + + @Test + public void authInfoWithoutUsernameShouldFailAuthentication(final TestContext context) { + JsonObject authInfo = new JsonObject().put("password", "foo"); + + tomlAuth.authenticate( + authInfo, + context.asyncAssertFailure( + th -> context.assertEquals("No username provided", th.getMessage()))); + } + + @Test + public void authInfoWithoutPasswordShouldFailAuthentication(final TestContext context) { + JsonObject authInfo = new JsonObject().put("username", "foo"); + + tomlAuth.authenticate( + authInfo, + context.asyncAssertFailure( + th -> context.assertEquals("No password provided", th.getMessage()))); + } + + @Test + public void parseFailureWithIOExceptionShouldFailAuthentication(final TestContext context) { + tomlAuth = new TomlAuth(vertx, new TomlAuthOptions().setTomlPath("invalid_path")); + + tomlAuth.authenticate( + validAuthInfo, + context.asyncAssertFailure( + th -> { + context.assertEquals(th.getClass(), NoSuchFileException.class); + })); + } + + @Test + public void authInfoWithAbsentUserShouldFailAuthentication(final TestContext context) { + JsonObject authInfo = new JsonObject().put("username", "foo").put("password", "foo"); + + tomlAuth.authenticate( + authInfo, + context.asyncAssertFailure(th -> context.assertEquals("User not found", th.getMessage()))); + } + + @Test + public void userWithoutPasswordSetShouldFailAuthentication(final TestContext context) { + JsonObject authInfo = new JsonObject().put("username", "noPasswordUser").put("password", "foo"); + + tomlAuth.authenticate( + authInfo, + context.asyncAssertFailure( + th -> context.assertEquals("No password set for user", th.getMessage()))); + } + + @Test + public void passwordMismatchShouldFailAuthentication(final TestContext context) { + JsonObject authInfo = new JsonObject().put("username", "userA").put("password", "foo"); + + tomlAuth.authenticate( + authInfo, + context.asyncAssertFailure( + th -> context.assertEquals("Invalid password", th.getMessage()))); + } + + @Test + public void validPasswordShouldAuthenticateSuccessfully(final TestContext context) { + JsonObject expectedPrincipal = + new JsonObject() + .put("username", "userA") + .put("password", "$2a$10$l3GA7K8g6rJ/Yv.YFSygCuI9byngpEzxgWS9qEg5emYDZomQW7fGC") + .put("groups", new JsonArray().add("admin")) + .put("permissions", new JsonArray().add("eth:*").add("perm:*")) + .put("roles", new JsonArray().add("net")); + + JsonObject authInfo = new JsonObject().put("username", "userA").put("password", "pegasys"); + + tomlAuth.authenticate( + authInfo, + context.asyncAssertSuccess( + res -> context.assertEquals(expectedPrincipal, res.principal()))); + } + + private String getTomlPath(final String tomlFileName) throws URISyntaxException { + return Paths.get(ClassLoader.getSystemResource(tomlFileName).toURI()) + .toAbsolutePath() + .toString(); + } +} diff --git a/ethereum/jsonrpc/src/test/resources/authentication/auth.toml b/ethereum/jsonrpc/src/test/resources/authentication/auth.toml new file mode 100644 index 0000000000..0c31c70e8a --- /dev/null +++ b/ethereum/jsonrpc/src/test/resources/authentication/auth.toml @@ -0,0 +1,17 @@ +[Users.userA] +password = "$2a$10$l3GA7K8g6rJ/Yv.YFSygCuI9byngpEzxgWS9qEg5emYDZomQW7fGC" +groups = ["admin"] +permissions = ["eth:*", "perm:*"] +roles = ["net"] + +[Groups.admins] +roles = ["admin"] + +[Roles.admin] +permissions = ["admin:*"] + +[Roles.net] +permissions = ["net:*"] + +[Users.noPasswordUser] +groups = ["admin"] \ No newline at end of file diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 384f0f9c4c..239ec10748 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -72,6 +72,8 @@ dependencyManagement { dependency 'org.rocksdb:rocksdbjni:5.17.2' + dependency 'org.springframework.security:spring-security-crypto:5.1.3.RELEASE' + dependency 'org.web3j:abi:4.1.0' dependency 'org.web3j:core:4.1.0' dependency 'org.web3j:crypto:4.1.0' diff --git a/pantheon/build.gradle b/pantheon/build.gradle index c00aca8701..fd04cbe468 100644 --- a/pantheon/build.gradle +++ b/pantheon/build.gradle @@ -48,6 +48,7 @@ dependencies { implementation 'io.vertx:vertx-web' implementation 'net.consensys.cava:cava-toml' implementation 'org.apache.logging.log4j:log4j-api' + implementation 'org.springframework.security:spring-security-crypto' runtime 'org.apache.logging.log4j:log4j-core' diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java index ff1c4759cb..10a555a999 100644 --- a/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java +++ b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java @@ -372,7 +372,7 @@ public class PantheonCommand implements DefaultCommandValues, Runnable { description = "Set if the metrics push gateway integration should be started (default: ${DEFAULT-VALUE})" ) - private Boolean isMetricsPushEnabled = false; + private final Boolean isMetricsPushEnabled = false; @Option( names = {"--metrics-push-host"}, @@ -528,6 +528,8 @@ public class PantheonCommand implements DefaultCommandValues, Runnable { BlocksSubCommand.COMMAND_NAME, new BlocksSubCommand(blockImporter, resultHandler.out())); commandLine.addSubcommand( PublicKeySubCommand.COMMAND_NAME, new PublicKeySubCommand(resultHandler.out())); + commandLine.addSubcommand( + PasswordSubCommand.COMMAND_NAME, new PasswordSubCommand(resultHandler.out())); commandLine.registerConverter(Address.class, Address::fromHexString); commandLine.registerConverter(BytesValue.class, BytesValue::fromHexString); diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/cli/PasswordSubCommand.java b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PasswordSubCommand.java new file mode 100644 index 0000000000..6ba548b464 --- /dev/null +++ b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PasswordSubCommand.java @@ -0,0 +1,57 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.pantheon.cli; + +import static tech.pegasys.pantheon.cli.PasswordSubCommand.COMMAND_NAME; + +import java.io.PrintStream; + +import org.springframework.security.crypto.bcrypt.BCrypt; +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ParentCommand; +import picocli.CommandLine.Spec; + +@Command( + name = COMMAND_NAME, + description = "This command generates the hash of a given password.", + mixinStandardHelpOptions = true +) +class PasswordSubCommand implements Runnable { + + static final String COMMAND_NAME = "password-hash"; + + @SuppressWarnings("unused") + @ParentCommand + private PantheonCommand parentCommand; // Picocli injects reference to parent command + + @SuppressWarnings("unused") + @Spec + private CommandSpec spec; // Picocli injects reference to command spec + + final PrintStream out; + + @SuppressWarnings("FieldMustBeFinal") + @Parameters(arity = "1..1", description = "The password input") + private String password = null; + + PasswordSubCommand(final PrintStream out) { + this.out = out; + } + + @Override + public void run() { + out.print(BCrypt.hashpw(password, BCrypt.gensalt())); + } +} diff --git a/pantheon/src/test/java/tech/pegasys/pantheon/cli/PasswordSubCommandTest.java b/pantheon/src/test/java/tech/pegasys/pantheon/cli/PasswordSubCommandTest.java new file mode 100644 index 0000000000..1bcaa3d916 --- /dev/null +++ b/pantheon/src/test/java/tech/pegasys/pantheon/cli/PasswordSubCommandTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2019 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.pantheon.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import picocli.CommandLine.Model.CommandSpec; + +public class PasswordSubCommandTest extends CommandTestAbstract { + + @Test + public void passwordHashSubCommandExists() { + CommandSpec spec = parseCommand(); + assertThat(spec.subcommands()).containsKeys("password-hash"); + assertThat(commandOutput.toString()).isEmpty(); + assertThat(commandErrorOutput.toString()).isEmpty(); + } + + @Test + public void callingPasswordHashWithoutPasswordParameterMustDisplayUsage() { + final String expectedUsage = + "Missing required parameter: " + + System.lineSeparator() + + "Usage: pantheon password-hash [-hV] " + + System.lineSeparator() + + "This command generates the hash of a given password." + + System.lineSeparator() + + " The password input" + + System.lineSeparator() + + " -h, --help Show this help message and exit." + + System.lineSeparator() + + " -V, --version Print version information and exit." + + System.lineSeparator(); + + parseCommand("password-hash"); + assertThat(commandOutput.toString()).isEmpty(); + assertThat(commandErrorOutput.toString()).startsWith(expectedUsage); + } + + @Test + public void publicKeySubCommandExistAnbHaveSubCommands() { + parseCommand("password-hash", "foo"); + + // we can't predict the final value so we are only checking if it starts with the hash marker + assertThat(commandOutput.toString()).startsWith("$2"); + assertThat(commandErrorOutput.toString()).isEmpty(); + } +}