diff --git a/apitest/dao-setup.gradle b/apitest/dao-setup.gradle new file mode 100644 index 00000000000..5f55ce72e5c --- /dev/null +++ b/apitest/dao-setup.gradle @@ -0,0 +1,83 @@ +// This gradle file contains tasks to install and clean dao-setup files downloaded from +// https://github.com/bisq-network/bisq/raw/master/docs/dao-setup.zip +// These tasks are not run by the default build, but they can can be run during a full +// or partial builds, or by themselves. +// To run a full Bisq clean build, test, and install dao-setup files: +// ./gradlew clean build :apitest:installDaoSetup +// To install or re-install dao-setup file only: +// ./gradlew :apitest:installDaoSetup -x test +// To clean installed dao-setup files: +// ./gradlew :apitest:cleanDaoSetup -x test +// +// The :apitest subproject will not run on Windows, and these tasks have not been +// tested on Windows. +def buildResourcesDir = project(":apitest").buildDir.path + '/resources/main' + +// This task requires ant in the system $PATH. +task installDaoSetup(dependsOn: 'cleanDaoSetup') { + doLast { + println "Installing dao-setup directories in build dir $buildResourcesDir ..." + def src = 'https://github.com/bisq-network/bisq/raw/master/docs/dao-setup.zip' + def destfile = project.rootDir.path + '/apitest/src/main/resources/dao-setup.zip' + def url = new URL(src) + def f = new File(destfile) + if (f.exists()) { + println "File $destfile already exists, skipping download." + } else { + if (!f.parentFile.exists()) + mkdir "$buildResourcesDir" + + println "Downloading $url to $buildResourcesDir ..." + url.withInputStream { i -> f.withOutputStream { it << i } } + } + + // We need an ant task for unzipping the dao-setup.zip file. + println "Unzipping $destfile to $buildResourcesDir ..." + ant.unzip(src: 'src/main/resources/dao-setup.zip', + dest: 'src/main/resources', + overwrite: "true") { + // Warning: overwrite: "true" does not work if empty dirs exist, so the + // cleanDaoSetup task should be run before trying to re-install fresh + // dao-setup files. + patternset() { + include(name: '**') + exclude(name: '**/bitcoin.conf') // installed at runtime with correct blocknotify script path + exclude(name: '**/blocknotify') // installed from src/main/resources to allow port configs + } + mapper(type: "identity") + } + + // Copy files from unzip target dir 'dao-setup' to build/resources/main. + def daoSetupSrc = project.rootDir.path + '/apitest/src/main/resources/dao-setup' + def daoSetupDest = buildResourcesDir + '/dao-setup' + println "Copying $daoSetupSrc to $daoSetupDest ..." + copy { + from daoSetupSrc + into daoSetupDest + } + + // Move dao-setup files from build/resources/main/dao-setup to build/resources/main + file(buildResourcesDir + '/dao-setup/Bitcoin-regtest') + .renameTo(file(buildResourcesDir + '/Bitcoin-regtest')) + file(buildResourcesDir + '/dao-setup/bisq-BTC_REGTEST_Alice_dao') + .renameTo(file(buildResourcesDir + '/bisq-BTC_REGTEST_Alice_dao')) + file(buildResourcesDir + '/dao-setup/bisq-BTC_REGTEST_Bob_dao') + .renameTo(file(buildResourcesDir + '/bisq-BTC_REGTEST_Bob_dao')) + delete file(buildResourcesDir + '/dao-setup') + } +} + +task cleanDaoSetup { + doLast { + // When re-installing dao-setup files before re-running tests, the bitcoin + // datadir and dao-setup dirs have to be cleaned first. This task allows + // you to re-install dao-setup files and re-run tests without having to + // re-compile any code. + println "Deleting dao-setup directories in build dir $buildResourcesDir ..." + delete file(buildResourcesDir + '/Bitcoin-regtest') + delete file(buildResourcesDir + '/bisq-BTC_REGTEST_Seed_2002') + delete file(buildResourcesDir + '/bisq-BTC_REGTEST_Arb_dao') + delete file(buildResourcesDir + '/bisq-BTC_REGTEST_Alice_dao') + delete file(buildResourcesDir + '/bisq-BTC_REGTEST_Bob_dao') + } +} diff --git a/apitest/docs/README.md b/apitest/docs/README.md new file mode 100644 index 00000000000..625dadddc9f --- /dev/null +++ b/apitest/docs/README.md @@ -0,0 +1,5 @@ +# Bisq apitest docs + + - [build-run.md](build-run.md): Build and run API tests at the command line and from Intellij. + - [test-categories.md](test-categories.md): How to categorize a test case as `method`, `scenario` or `e2e`. + - [regtest-port-conflicts.md](regtest-port-conflicts.md): Avoid port conflicts when running multiple bitcoin-core apps in regtest mode. diff --git a/apitest/docs/build-run.md b/apitest/docs/build-run.md new file mode 100644 index 00000000000..308fe02cf66 --- /dev/null +++ b/apitest/docs/build-run.md @@ -0,0 +1,68 @@ +# Build and Run API Test Harness + +## Linux & OSX + +The API test harness uses the GNU Bourne-Again SHell `bash`, and is not supported on Windows. + +## Predefined DAO / Regtest Setup + +The API test harness depends on the contents of https://github.com/bisq-network/bisq/raw/master/docs/dao-setup.zip. +The files contained in dao-setup.zip include a bitcoin-core wallet, a regtest genesis tx and chain of 111 blocks, plus +data directories for Bob and Alice Bisq instances. Bob & Alice wallets are pre-configured with 10 BTC each, and the +equivalent of 2.5 BTC in BSQ distributed among Bob & Alice's BSQ wallets. + +See https://github.com/bisq-network/bisq/blob/master/docs/dao-setup.md for details. + +## Install DAO / Regtest Setup Files + +Bisq's gradle build file defines a task for downloading dao-setup.zip and extracting its contents to the +`apitest/src/main/resources` folder, and the test harness will install a fresh set of data files to the +`apitest/build/resources/main` folder during a test case's scaffold setup phase -- normally a static `@BeforeAll` method. + +The dao-setup files can be downloaded during a normal build: + + $ ./gradlew clean build :apitest:installDaoSetup + +Or by running a single task: + + $ ./gradlew :apitest:installDaoSetup + +The `:apitest:installDaoSetup` task does not need to be run again until after the next time you run the gradle `clean` task. + +## Run API Tests + +The API test harness supports narrow & broad functional and full end to end test cases requiring +long setup and teardown times -- for example, to start a bitcoind instance, seednode, arbnode, plus Bob & Alice +Bisq instances, then shut everything down in proper order. For this reason, API test cases do not run during a normal +gradle build. + +To run API test cases, pass system property`-DrunApiTests=true`. + +To run all existing test cases: + + $ ./gradlew :apitest:test -DrunApiTests=true + +To run all test cases in a package: + + $ ./gradlew :apitest:test --tests "bisq.apitest.method.*" -DrunApiTests=true + +To run a single test case: + + $ ./gradlew :apitest:test --tests "bisq.apitest.method.GetBalanceTest" -DrunApiTests=true + +To run test cases from Intellij, add two JVM arguments to your JUnit launchers: + + -DrunApiTests=true -Dlogback.configurationFile=apitest/build/resources/main/logback.xml + +The `-Dlogback.configurationFile` property will prevent `logback` from printing warnings about multiple `logback.xml` +files it will find in Bisq jars `cli.jar`, `daemon.jar`, and `seednode.jar`. + +## Gradle Test Reports + +To see detailed test results, logs, and full stack traces for test failures, open +`apitest/build/reports/tests/test/index.html` in a browser. + +## See also + + - [test-categories.md](test-categories.md) + diff --git a/apitest/docs/regtest-port-conflicts.md b/apitest/docs/regtest-port-conflicts.md new file mode 100644 index 00000000000..7ec3e6bf45a --- /dev/null +++ b/apitest/docs/regtest-port-conflicts.md @@ -0,0 +1,12 @@ +# Avoiding bitcoin-core regtest port conflicts + +Some developers may already be running a `bitcoind` or `bitcoin-qt` instance in regtest mode when they try to run API +test cases. If a `bitcoin-qt` instance is bound to the default regtest port 18444, `apitest` will not be able to start +its own bitcoind instances. + +Though it would be preferable for `apitest` to change the bind port for Bisq's `bitcoinj` module at runtime, this is +not currently possible because `bitcoinj` hardcodes the default regtest mode bind port in `RegTestParams`. + +To avoid the bind address:port conflict, pass a port option to your bitcoin-core instance: + + bitcoin-qt -regtest -port=20444 diff --git a/apitest/docs/test-categories.md b/apitest/docs/test-categories.md new file mode 100644 index 00000000000..ba1c095dc04 --- /dev/null +++ b/apitest/docs/test-categories.md @@ -0,0 +1,35 @@ +# API Test Categories + +This guide describes the categorization of tests. + +## Method Tests + +A `method` test is the `apitest` analog of a unit test. It tests a single API method such as `getbalance`, but is not +considered a unit test because the code execution path traverses so many layers: from `gRPC` client -> `gRPC` server +side service -> one or more Bisq `core` services, and back to the client. + +Method tests have direct access to `gRPC` client stubs, and test asserts are made directly on `gRPC` return values -- +Java Objects. + +All `method` tests are part of the `bisq.apitest.method` package. + +## Scenario Tests + +A `scenario` test is a narrow or broad functional test case covering a simple use case such as funding a wallet to a +complex series of trades. Generally, a scenario test case requires multiple `gRPC` method calls. + +Scenario tests have direct access to `gRPC` client stubs, and test asserts are made directly on `gRPC` return values -- +Java Objects. + +All `scenario` tests are part of the `bisq.apitest.scenario` package. + +## End to End Tests + +An end to end (`e2e`) test can cover a narrow or broad use case, and all client calls go through the `CLI` shell script +`bisq-cli`. End to end tests do not have access to `gRPC` client stubs, and test asserts are made on what the end +user sees on the console -- what`gRPC CLI` prints to `STDOUT`. + +As test coverage grows, stable scenario test cases should be migrated to `e2e` test cases. + +All `e2e` tests are part of the `bisq.apitest.e2e` package. + diff --git a/apitest/scripts/get-bisq-pid.sh b/apitest/scripts/get-bisq-pid.sh new file mode 100755 index 00000000000..450f1290c00 --- /dev/null +++ b/apitest/scripts/get-bisq-pid.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Find the pid of the java process by grepping for the mainClassName and appName, +# then print the 2nd column of the output to stdout. +# +# Doing this from Java is problematic, probably due to limitation of the +# apitest.linux.BashCommand implementation. + + +MAIN_CLASS_NAME=$1 +APP_NAME=$2 + +# TODO args validation + +ps aux | grep java | grep "${MAIN_CLASS_NAME}" | grep "${APP_NAME}" | awk '{print $2}' diff --git a/apitest/src/main/java/bisq/apitest/ApiTestMain.java b/apitest/src/main/java/bisq/apitest/ApiTestMain.java new file mode 100644 index 00000000000..b79fe6cc3be --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/ApiTestMain.java @@ -0,0 +1,80 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.apitest.Scaffold.EXIT_FAILURE; +import static bisq.apitest.Scaffold.EXIT_SUCCESS; +import static java.lang.System.err; +import static java.lang.System.exit; + + + +import bisq.apitest.config.ApiTestConfig; + +/** + * ApiTestMain is a placeholder for the gradle build file, which requires a valid + * 'mainClassName' property in the :apitest subproject configuration. + * + * It does has some uses: + * + * It can be used to print test scaffolding options: bisq-apitest --help. + * + * It can be used to smoke test your bitcoind environment: bisq-apitest. + * + * It can be used to run the regtest/dao environment for release testing: + * bisq-test --shutdownAfterTests=false + * + * All method, scenario and end to end tests are found in the test sources folder. + * + * Requires bitcoind v0.19.x + */ +@Slf4j +public class ApiTestMain { + + public static void main(String[] args) { + new ApiTestMain().execute(args); + } + + public void execute(@SuppressWarnings("unused") String[] args) { + try { + Scaffold scaffold = new Scaffold(args).setUp(); + ApiTestConfig config = scaffold.config; + + if (config.skipTests) { + log.info("Skipping tests ..."); + } else { + new SmokeTestBitcoind(config).run(); + } + + if (config.shutdownAfterTests) { + scaffold.tearDown(); + exit(EXIT_SUCCESS); + } else { + log.info("Not shutting down scaffolding background processes will run until ^C / kill -15 is rcvd ..."); + } + + } catch (Throwable ex) { + err.println("Fault: An unexpected error occurred. " + + "Please file a report at https://bisq.network/issues"); + ex.printStackTrace(err); + exit(EXIT_FAILURE); + } + } +} diff --git a/apitest/src/main/java/bisq/apitest/Scaffold.java b/apitest/src/main/java/bisq/apitest/Scaffold.java new file mode 100644 index 00000000000..bf0e4c771dd --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/Scaffold.java @@ -0,0 +1,417 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest; + +import bisq.common.config.BisqHelpFormatter; +import bisq.common.util.Utilities; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermissions; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +import static bisq.apitest.config.BisqAppConfig.*; +import static java.lang.String.format; +import static java.lang.System.exit; +import static java.lang.System.out; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + + + +import bisq.apitest.config.ApiTestConfig; +import bisq.apitest.config.BisqAppConfig; +import bisq.apitest.linux.BashCommand; +import bisq.apitest.linux.BisqApp; +import bisq.apitest.linux.BitcoinDaemon; +import bisq.apitest.linux.LinuxProcess; + +@Slf4j +public class Scaffold { + + public static final int EXIT_SUCCESS = 0; + public static final int EXIT_FAILURE = 1; + + public final ApiTestConfig config; + + @Nullable + private SetupTask bitcoindTask; + @Nullable + private Future bitcoindTaskFuture; + @Nullable + private SetupTask seedNodeTask; + @Nullable + private Future seedNodeTaskFuture; + @Nullable + private SetupTask arbNodeTask; + @Nullable + private Future arbNodeTaskFuture; + @Nullable + private SetupTask aliceNodeTask; + @Nullable + private Future aliceNodeTaskFuture; + @Nullable + private SetupTask bobNodeTask; + @Nullable + private Future bobNodeTaskFuture; + + private final ExecutorService executor; + + /** + * Constructor for passing comma delimited list of supporting apps to + * ApiTestConfig, e.g., "bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon". + * + * @param supportingApps String + */ + public Scaffold(String supportingApps) { + this(new ApiTestConfig("--supportingApps", supportingApps)); + } + + /** + * Constructor for passing options accepted by ApiTestConfig. + * + * @param args String[] + */ + public Scaffold(String[] args) { + this(new ApiTestConfig(args)); + } + + /** + * Constructor for passing ApiTestConfig instance. + * + * @param config ApiTestConfig + */ + public Scaffold(ApiTestConfig config) { + verifyNotWindows(); + this.config = config; + this.executor = Executors.newFixedThreadPool(config.supportingApps.size()); + if (config.helpRequested) { + config.printHelp(out, + new BisqHelpFormatter( + "Bisq ApiTest", + "bisq-apitest", + "0.1.0")); + exit(EXIT_SUCCESS); + } + } + + + public Scaffold setUp() throws IOException, InterruptedException, ExecutionException { + installDaoSetupDirectories(); + + // Start each background process from an executor, then add a shutdown hook. + CountDownLatch countdownLatch = new CountDownLatch(config.supportingApps.size()); + startBackgroundProcesses(executor, countdownLatch); + installShutdownHook(); + + // Wait for all submitted startup tasks to decrement the count of the latch. + Objects.requireNonNull(countdownLatch).await(); + + // Verify each startup task's future is done. + verifyStartupCompleted(); + return this; + } + + public void tearDown() { + if (!executor.isTerminated()) { + try { + log.info("Shutting down executor service ..."); + executor.shutdownNow(); + executor.awaitTermination(config.supportingApps.size() * 2000, MILLISECONDS); + + SetupTask[] orderedTasks = new SetupTask[]{ + bobNodeTask, aliceNodeTask, arbNodeTask, seedNodeTask, bitcoindTask}; + Optional firstException = shutDownAll(orderedTasks); + + if (firstException.isPresent()) + throw new IllegalStateException( + "There were errors shutting down one or more background instances.", + firstException.get()); + else + log.info("Teardown complete"); + + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + } + + private Optional shutDownAll(SetupTask[] orderedTasks) { + Optional firstException = Optional.empty(); + for (SetupTask t : orderedTasks) { + if (t != null && t.getLinuxProcess() != null) { + try { + LinuxProcess p = t.getLinuxProcess(); + p.shutdown(); + MILLISECONDS.sleep(1000); + if (p.hasShutdownExceptions()) { + // We log shutdown exceptions, but do not throw any from here + // because all of the background instances must be shut down. + p.logExceptions(p.getShutdownExceptions(), log); + + // We cache only the 1st shutdown exception and move on to the + // next process to be shutdown. This cached exception will be the + // one thrown to the calling test case (the @AfterAll method). + if (!firstException.isPresent()) + firstException = Optional.of(p.getShutdownExceptions().get(0)); + } + } catch (InterruptedException ignored) { + // empty + } + } + } + return firstException; + } + + public void installDaoSetupDirectories() { + cleanDaoSetupDirectories(); + + String daoSetupDir = Paths.get(config.baseSrcResourcesDir, "dao-setup").toFile().getAbsolutePath(); + String buildDataDir = config.rootAppDataDir.getAbsolutePath(); + try { + if (!new File(daoSetupDir).exists()) + throw new FileNotFoundException( + format("Dao setup dir '%s' not found. Run gradle :apitest:installDaoSetup" + + " to download dao-setup.zip and extract contents to resources folder", + daoSetupDir)); + + BashCommand copyBitcoinRegtestDir = new BashCommand( + "cp -rf " + daoSetupDir + "/Bitcoin-regtest/regtest" + + " " + config.bitcoinDatadir); + if (copyBitcoinRegtestDir.run().getExitStatus() != 0) + throw new IllegalStateException("Could not install bitcoin regtest dir"); + + BashCommand copyAliceDataDir = new BashCommand( + "cp -rf " + daoSetupDir + "/" + alicedaemon.appName + + " " + config.rootAppDataDir); + if (copyAliceDataDir.run().getExitStatus() != 0) + throw new IllegalStateException("Could not install alice data dir"); + + BashCommand copyBobDataDir = new BashCommand( + "cp -rf " + daoSetupDir + "/" + bobdaemon.appName + + " " + config.rootAppDataDir); + if (copyBobDataDir.run().getExitStatus() != 0) + throw new IllegalStateException("Could not install bob data dir"); + + log.info("Installed dao-setup files into {}", buildDataDir); + + // Copy the blocknotify script from the src resources dir to the build + // resources dir. Users may want to edit comment out some lines when all + // of the default block notifcation ports being will not be used (to avoid + // seeing rpc notifcation warnings in log files). + installBitcoinBlocknotify(); + + } catch (IOException | InterruptedException ex) { + throw new IllegalStateException("Could not install dao-setup files from " + daoSetupDir, ex); + } + } + + private void cleanDaoSetupDirectories() { + String buildDataDir = config.rootAppDataDir.getAbsolutePath(); + log.info("Cleaning dao-setup data in {}", buildDataDir); + + try { + BashCommand rmBobDataDir = new BashCommand("rm -rf " + config.rootAppDataDir + "/" + bobdaemon.appName); + if (rmBobDataDir.run().getExitStatus() != 0) + throw new IllegalStateException("Could not delete bob data dir"); + + BashCommand rmAliceDataDir = new BashCommand("rm -rf " + config.rootAppDataDir + "/" + alicedaemon.appName); + if (rmAliceDataDir.run().getExitStatus() != 0) + throw new IllegalStateException("Could not delete alice data dir"); + + BashCommand rmArbNodeDataDir = new BashCommand("rm -rf " + config.rootAppDataDir + "/" + arbdaemon.appName); + if (rmArbNodeDataDir.run().getExitStatus() != 0) + throw new IllegalStateException("Could not delete arbitrator data dir"); + + BashCommand rmSeedNodeDataDir = new BashCommand("rm -rf " + config.rootAppDataDir + "/" + seednode.appName); + if (rmSeedNodeDataDir.run().getExitStatus() != 0) + throw new IllegalStateException("Could not delete seednode data dir"); + + BashCommand rmBitcoinRegtestDir = new BashCommand("rm -rf " + config.bitcoinDatadir + "/regtest"); + if (rmBitcoinRegtestDir.run().getExitStatus() != 0) + throw new IllegalStateException("Could not clean bitcoind regtest dir"); + + } catch (IOException | InterruptedException ex) { + throw new IllegalStateException("Could not clean dao-setup files from " + buildDataDir, ex); + } + } + + private void installBitcoinBlocknotify() { + // gradle is not working for this + try { + Path srcPath = Paths.get(config.baseSrcResourcesDir, "blocknotify"); + Path destPath = Paths.get(config.bitcoinDatadir, "blocknotify"); + Files.copy(srcPath, destPath, REPLACE_EXISTING); + String chmod700Perms = "rwx------"; + Files.setPosixFilePermissions(destPath, PosixFilePermissions.fromString(chmod700Perms)); + log.info("Installed {} with perms {}.", destPath.toString(), chmod700Perms); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void installShutdownHook() { + // Background apps can be left running until the jvm is manually shutdown, + // so we add a shutdown hook for that use case. + Runtime.getRuntime().addShutdownHook(new Thread(this::tearDown)); + } + + // Starts bitcoind and bisq apps (seednode, arbnode, etc...) + private void startBackgroundProcesses(ExecutorService executor, + CountDownLatch countdownLatch) + throws InterruptedException, IOException { + + log.info("Starting supporting apps {}", config.supportingApps.toString()); + + if (config.hasSupportingApp("bitcoind")) { + BitcoinDaemon bitcoinDaemon = new BitcoinDaemon(config); + bitcoinDaemon.verifyBitcoinPathsExist(true); + bitcoindTask = new SetupTask(bitcoinDaemon, countdownLatch); + bitcoindTaskFuture = executor.submit(bitcoindTask); + MILLISECONDS.sleep(config.bisqAppInitTime); + + LinuxProcess bitcoindProcess = bitcoindTask.getLinuxProcess(); + if (bitcoindProcess.hasStartupExceptions()) { + bitcoindProcess.logExceptions(bitcoindProcess.getStartupExceptions(), log); + throw new IllegalStateException(bitcoindProcess.getStartupExceptions().get(0)); + } + + bitcoinDaemon.verifyBitcoindRunning(); + } + + if (config.hasSupportingApp(seednode.name())) + startBisqApp(seednode, executor, countdownLatch); + + if (config.hasSupportingApp(arbdaemon.name(), arbdesktop.name())) + startBisqApp(config.runArbNodeAsDesktop ? arbdesktop : arbdaemon, executor, countdownLatch); + + if (config.hasSupportingApp(alicedaemon.name(), alicedesktop.name())) + startBisqApp(config.runAliceNodeAsDesktop ? alicedesktop : alicedaemon, executor, countdownLatch); + + if (config.hasSupportingApp(bobdaemon.name(), bobdesktop.name())) + startBisqApp(config.runBobNodeAsDesktop ? bobdesktop : bobdaemon, executor, countdownLatch); + } + + private void startBisqApp(BisqAppConfig bisqAppConfig, + ExecutorService executor, + CountDownLatch countdownLatch) + throws IOException, InterruptedException { + + BisqApp bisqApp; + switch (bisqAppConfig) { + case seednode: + bisqApp = createBisqApp(seednode); + seedNodeTask = new SetupTask(bisqApp, countdownLatch); + seedNodeTaskFuture = executor.submit(seedNodeTask); + break; + case arbdaemon: + case arbdesktop: + bisqApp = createBisqApp(config.runArbNodeAsDesktop ? arbdesktop : arbdaemon); + arbNodeTask = new SetupTask(bisqApp, countdownLatch); + arbNodeTaskFuture = executor.submit(arbNodeTask); + break; + case alicedaemon: + case alicedesktop: + bisqApp = createBisqApp(config.runAliceNodeAsDesktop ? alicedesktop : alicedaemon); + aliceNodeTask = new SetupTask(bisqApp, countdownLatch); + aliceNodeTaskFuture = executor.submit(aliceNodeTask); + break; + case bobdaemon: + case bobdesktop: + bisqApp = createBisqApp(config.runBobNodeAsDesktop ? bobdesktop : bobdaemon); + bobNodeTask = new SetupTask(bisqApp, countdownLatch); + bobNodeTaskFuture = executor.submit(bobNodeTask); + break; + default: + throw new IllegalStateException("Unknown BisqAppConfig " + bisqAppConfig.name()); + } + log.info("Giving {} ms for {} to initialize ...", config.bisqAppInitTime, bisqAppConfig.appName); + MILLISECONDS.sleep(config.bisqAppInitTime); + if (bisqApp.hasStartupExceptions()) { + bisqApp.logExceptions(bisqApp.getStartupExceptions(), log); + throw new IllegalStateException(bisqApp.getStartupExceptions().get(0)); + } + } + + private BisqApp createBisqApp(BisqAppConfig bisqAppConfig) + throws IOException, InterruptedException { + BisqApp bisqNode = new BisqApp(bisqAppConfig, config); + bisqNode.verifyAppNotRunning(); + bisqNode.verifyAppDataDirInstalled(); + return bisqNode; + } + + private void verifyStartupCompleted() + throws ExecutionException, InterruptedException { + if (bitcoindTaskFuture != null) + verifyStartupCompleted(bitcoindTaskFuture); + + if (seedNodeTaskFuture != null) + verifyStartupCompleted(seedNodeTaskFuture); + + if (arbNodeTaskFuture != null) + verifyStartupCompleted(arbNodeTaskFuture); + + if (aliceNodeTaskFuture != null) + verifyStartupCompleted(aliceNodeTaskFuture); + + if (bobNodeTaskFuture != null) + verifyStartupCompleted(bobNodeTaskFuture); + } + + private void verifyStartupCompleted(Future futureStatus) + throws ExecutionException, InterruptedException { + for (int i = 0; i < 10; i++) { + if (futureStatus.isDone()) { + log.info("{} completed startup at {} {}", + futureStatus.get().getName(), + futureStatus.get().getStartTime().toLocalDate(), + futureStatus.get().getStartTime().toLocalTime()); + return; + } else { + // We are giving the thread more time to terminate after the countdown + // latch reached 0. If we are running only bitcoind, we need to be even + // more lenient. + SECONDS.sleep(config.supportingApps.size() == 1 ? 2 : 1); + } + } + throw new IllegalStateException(format("%s did not complete startup", futureStatus.get().getName())); + } + + private void verifyNotWindows() { + if (Utilities.isWindows()) + throw new IllegalStateException("ApiTest not supported on Windows"); + } +} diff --git a/apitest/src/main/java/bisq/apitest/SetupTask.java b/apitest/src/main/java/bisq/apitest/SetupTask.java new file mode 100644 index 00000000000..7e519b65cec --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/SetupTask.java @@ -0,0 +1,85 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest; + +import java.time.LocalDateTime; + +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; + +import lombok.extern.slf4j.Slf4j; + +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + + + +import bisq.apitest.linux.LinuxProcess; + +@Slf4j +public class SetupTask implements Callable { + + private final LinuxProcess linuxProcess; + private final CountDownLatch countdownLatch; + + public SetupTask(LinuxProcess linuxProcess, CountDownLatch countdownLatch) { + this.linuxProcess = linuxProcess; + this.countdownLatch = countdownLatch; + } + + @Override + public Status call() throws Exception { + try { + linuxProcess.start(); // always runs in background + MILLISECONDS.sleep(1000); // give 1s for bg process to init + } catch (InterruptedException ex) { + throw new IllegalStateException(format("Error starting %s", linuxProcess.getName()), ex); + } + Objects.requireNonNull(countdownLatch).countDown(); + return new Status(linuxProcess.getName(), LocalDateTime.now()); + } + + public LinuxProcess getLinuxProcess() { + return linuxProcess; + } + + public static class Status { + private final String name; + private final LocalDateTime startTime; + + public Status(String name, LocalDateTime startTime) { + super(); + this.name = name; + this.startTime = startTime; + } + + public String getName() { + return name; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + @Override + public String toString() { + return "SetupTask.Status [name=" + name + ", completionTime=" + startTime + "]"; + } + } +} diff --git a/apitest/src/main/java/bisq/apitest/SmokeTestBashCommand.java b/apitest/src/main/java/bisq/apitest/SmokeTestBashCommand.java new file mode 100644 index 00000000000..c3c8a26bc22 --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/SmokeTestBashCommand.java @@ -0,0 +1,51 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest; + +import java.io.IOException; + +import lombok.extern.slf4j.Slf4j; + + + +import bisq.apitest.linux.BashCommand; + +@Slf4j +class SmokeTestBashCommand { + + public SmokeTestBashCommand() { + } + + public void runSmokeTest() { + try { + BashCommand cmd = new BashCommand("ls -l").run(); + log.info("$ {}\n{}", cmd.getCommand(), cmd.getOutput()); + + cmd = new BashCommand("free -g").run(); + log.info("$ {}\n{}", cmd.getCommand(), cmd.getOutput()); + + cmd = new BashCommand("date").run(); + log.info("$ {}\n{}", cmd.getCommand(), cmd.getOutput()); + + cmd = new BashCommand("netstat -a | grep localhost").run(); + log.info("$ {}\n{}", cmd.getCommand(), cmd.getOutput()); + } catch (IOException | InterruptedException e) { + e.printStackTrace(); + } + } +} diff --git a/apitest/src/main/java/bisq/apitest/SmokeTestBitcoind.java b/apitest/src/main/java/bisq/apitest/SmokeTestBitcoind.java new file mode 100644 index 00000000000..92d1ba8051f --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/SmokeTestBitcoind.java @@ -0,0 +1,72 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest; + +import java.io.IOException; + +import lombok.extern.slf4j.Slf4j; + +import static java.lang.String.format; + + + +import bisq.apitest.config.ApiTestConfig; +import bisq.apitest.linux.BitcoinCli; + +@Slf4j +class SmokeTestBitcoind { + + private final ApiTestConfig config; + + public SmokeTestBitcoind(ApiTestConfig config) { + this.config = config; + } + + public void run() throws IOException, InterruptedException { + runBitcoinGetWalletInfo(); // smoke test bitcoin-cli + String newBitcoinAddress = getNewAddress(); + generateToAddress(1, newBitcoinAddress); + } + + public void runBitcoinGetWalletInfo() throws IOException, InterruptedException { + // This might be good for a sanity check to make sure the regtest data was installed. + log.info("Smoke test bitcoin-cli getwalletinfo"); + BitcoinCli walletInfo = new BitcoinCli(config, "getwalletinfo").run(); + log.info("{}\n{}", walletInfo.getCommandWithOptions(), walletInfo.getOutput()); + log.info("balance str = {}", walletInfo.getOutputValueAsString("balance")); + log.info("balance dbl = {}", walletInfo.getOutputValueAsDouble("balance")); + log.info("keypoololdest long = {}", walletInfo.getOutputValueAsLong("keypoololdest")); + log.info("paytxfee dbl = {}", walletInfo.getOutputValueAsDouble("paytxfee")); + log.info("keypoolsize_hd_internal int = {}", walletInfo.getOutputValueAsInt("keypoolsize_hd_internal")); + log.info("private_keys_enabled bool = {}", walletInfo.getOutputValueAsBoolean("private_keys_enabled")); + log.info("hdseedid str = {}", walletInfo.getOutputValueAsString("hdseedid")); + } + + public String getNewAddress() throws IOException, InterruptedException { + BitcoinCli newAddress = new BitcoinCli(config, "getnewaddress").run(); + log.info("{}\n{}", newAddress.getCommandWithOptions(), newAddress.getOutput()); + return newAddress.getOutput(); + } + + public void generateToAddress(int blocks, String address) throws IOException, InterruptedException { + String generateToAddressCmd = format("generatetoaddress %d \"%s\"", blocks, address); + BitcoinCli generateToAddress = new BitcoinCli(config, generateToAddressCmd).run(); + // Return value is an array of TxIDs. + log.info("{}\n{}", generateToAddress.getCommandWithOptions(), generateToAddress.getOutputValueAsStringArray()); + } +} diff --git a/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java b/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java new file mode 100644 index 00000000000..15193feae88 --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java @@ -0,0 +1,371 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.config; + +import joptsimple.AbstractOptionSpec; +import joptsimple.ArgumentAcceptingOptionSpec; +import joptsimple.HelpFormatter; +import joptsimple.OptionException; +import joptsimple.OptionParser; +import joptsimple.OptionSet; +import joptsimple.OptionSpec; + +import java.nio.file.Paths; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Properties; + +import lombok.extern.slf4j.Slf4j; + +import static java.lang.String.format; +import static java.lang.System.getProperty; +import static java.lang.System.getenv; +import static java.util.Arrays.asList; +import static java.util.Arrays.stream; +import static joptsimple.internal.Strings.EMPTY; + +@Slf4j +public class ApiTestConfig { + + // Option name constants + static final String HELP = "help"; + static final String BASH_PATH = "bashPath"; + static final String BERKELEYDB_LIB_PATH = "berkeleyDbLibPath"; + static final String BITCOIN_PATH = "bitcoinPath"; + static final String BITCOIN_RPC_PORT = "bitcoinRpcPort"; + static final String BITCOIN_RPC_USER = "bitcoinRpcUser"; + static final String BITCOIN_RPC_PASSWORD = "bitcoinRpcPassword"; + static final String BITCOIN_REGTEST_HOST = "bitcoinRegtestHost"; + static final String CONFIG_FILE = "configFile"; + static final String ROOT_APP_DATA_DIR = "rootAppDataDir"; + static final String API_PASSWORD = "apiPassword"; + static final String RUN_SUBPROJECT_JARS = "runSubprojectJars"; + static final String RUN_ARB_NODE_AS_DESKTOP = "runArbNodeAsDesktop"; + static final String RUN_ALICE_NODE_AS_DESKTOP = "runAliceNodeAsDesktop"; + static final String RUN_BOB_NODE_AS_DESKTOP = "runBobNodeAsDesktop"; + static final String BISQ_APP_INIT_TIME = "bisqAppInitTime"; + static final String SKIP_TESTS = "skipTests"; + static final String SHUTDOWN_AFTER_TESTS = "shutdownAfterTests"; + static final String SUPPORTING_APPS = "supportingApps"; + + // Default values for certain options + static final String DEFAULT_CONFIG_FILE_NAME = "apitest.properties"; + + // Static fields that provide access to Config properties in locations where injecting + // a Config instance is not feasible. + public static String BASH_PATH_VALUE; + + public final File defaultConfigFile; + + // Options supported only at the command line, not within a config file. + public final boolean helpRequested; + public final File configFile; + + // Options supported at the command line and a config file. + public final File rootAppDataDir; + public final String bashPath; + public final String berkeleyDbLibPath; + public final String bitcoinPath; + public final String bitcoinRegtestHost; + public final int bitcoinRpcPort; + public final String bitcoinRpcUser; + public final String bitcoinRpcPassword; + // Daemon instances can use same gRPC password, but each needs a different apiPort. + public final String apiPassword; + public final boolean runSubprojectJars; + public final boolean runArbNodeAsDesktop; + public final boolean runAliceNodeAsDesktop; + public final boolean runBobNodeAsDesktop; + public final long bisqAppInitTime; + public final boolean skipTests; + public final boolean shutdownAfterTests; + public final List supportingApps; + + // Immutable system configurations set in the constructor. + public final String bitcoinDatadir; + public final String userDir; + public final boolean isRunningTest; + public final String rootProjectDir; + public final String baseBuildResourcesDir; + public final String baseSrcResourcesDir; + + // The parser that will be used to parse both cmd line and config file options + private final OptionParser parser = new OptionParser(); + + public ApiTestConfig(String... args) { + this.userDir = getProperty("user.dir"); + // If running a @Test, the current working directory is the :apitest subproject + // folder. If running ApiTestMain, the current working directory is the + // bisq root project folder. + this.isRunningTest = Paths.get(userDir).getFileName().toString().equals("apitest"); + this.rootProjectDir = isRunningTest + ? Paths.get(userDir).getParent().toFile().getAbsolutePath() + : Paths.get(userDir).toFile().getAbsolutePath(); + this.baseBuildResourcesDir = Paths.get(rootProjectDir, "apitest", "build", "resources", "main") + .toFile().getAbsolutePath(); + this.baseSrcResourcesDir = Paths.get(rootProjectDir, "apitest", "src", "main", "resources") + .toFile().getAbsolutePath(); + + this.defaultConfigFile = absoluteConfigFile(baseBuildResourcesDir, DEFAULT_CONFIG_FILE_NAME); + this.bitcoinDatadir = Paths.get(baseBuildResourcesDir, "Bitcoin-regtest").toFile().getAbsolutePath(); + + AbstractOptionSpec helpOpt = + parser.accepts(HELP, "Print this help text") + .forHelp(); + + ArgumentAcceptingOptionSpec configFileOpt = + parser.accepts(CONFIG_FILE, format("Specify configuration file. " + + "Relative paths will be prefixed by %s location.", userDir)) + .withRequiredArg() + .ofType(String.class) + .defaultsTo(DEFAULT_CONFIG_FILE_NAME); + + ArgumentAcceptingOptionSpec appDataDirOpt = + parser.accepts(ROOT_APP_DATA_DIR, "Application data directory") + .withRequiredArg() + .ofType(File.class) + .defaultsTo(new File(baseBuildResourcesDir)); + + ArgumentAcceptingOptionSpec bashPathOpt = + parser.accepts(BASH_PATH, "Bash path") + .withRequiredArg() + .ofType(String.class) + .defaultsTo( + (getenv("SHELL") == null || !getenv("SHELL").contains("bash")) + ? "/bin/bash" + : getenv("SHELL")); + + ArgumentAcceptingOptionSpec berkeleyDbLibPathOpt = + parser.accepts(BERKELEYDB_LIB_PATH, "Berkeley DB lib path") + .withRequiredArg() + .ofType(String.class).defaultsTo(EMPTY); + + ArgumentAcceptingOptionSpec bitcoinPathOpt = + parser.accepts(BITCOIN_PATH, "Bitcoin path") + .withRequiredArg() + .ofType(String.class).defaultsTo("/usr/local/bin"); + + ArgumentAcceptingOptionSpec bitcoinRegtestHostOpt = + parser.accepts(BITCOIN_REGTEST_HOST, "Bitcoin Core regtest host") + .withRequiredArg() + .ofType(String.class).defaultsTo("localhost"); + + ArgumentAcceptingOptionSpec bitcoinRpcPortOpt = + parser.accepts(BITCOIN_RPC_PORT, "Bitcoin Core rpc port (non-default)") + .withRequiredArg() + .ofType(Integer.class).defaultsTo(19443); + + ArgumentAcceptingOptionSpec bitcoinRpcUserOpt = + parser.accepts(BITCOIN_RPC_USER, "Bitcoin rpc user") + .withRequiredArg() + .ofType(String.class).defaultsTo("apitest"); + + ArgumentAcceptingOptionSpec bitcoinRpcPasswordOpt = + parser.accepts(BITCOIN_RPC_PASSWORD, "Bitcoin rpc password") + .withRequiredArg() + .ofType(String.class).defaultsTo("apitest"); + + ArgumentAcceptingOptionSpec apiPasswordOpt = + parser.accepts(API_PASSWORD, "gRPC API password") + .withRequiredArg() + .defaultsTo("xyz"); + + ArgumentAcceptingOptionSpec runSubprojectJarsOpt = + parser.accepts(RUN_SUBPROJECT_JARS, + "Run subproject build jars instead of full build jars") + .withRequiredArg() + .ofType(Boolean.class) + .defaultsTo(false); + + ArgumentAcceptingOptionSpec runArbNodeAsDesktopOpt = + parser.accepts(RUN_ARB_NODE_AS_DESKTOP, + "Run Arbitration node as desktop") + .withRequiredArg() + .ofType(Boolean.class) + .defaultsTo(false); // TODO how do I register mediator? + + ArgumentAcceptingOptionSpec runAliceNodeAsDesktopOpt = + parser.accepts(RUN_ALICE_NODE_AS_DESKTOP, + "Run Alice node as desktop") + .withRequiredArg() + .ofType(Boolean.class) + .defaultsTo(false); + + ArgumentAcceptingOptionSpec runBobNodeAsDesktopOpt = + parser.accepts(RUN_BOB_NODE_AS_DESKTOP, + "Run Bob node as desktop") + .withRequiredArg() + .ofType(Boolean.class) + .defaultsTo(false); + + ArgumentAcceptingOptionSpec bisqAppInitTimeOpt = + parser.accepts(BISQ_APP_INIT_TIME, + "Amount of time (ms) to wait on a Bisq instance's initialization") + .withRequiredArg() + .ofType(Long.class) + .defaultsTo(5000L); + + ArgumentAcceptingOptionSpec skipTestsOpt = + parser.accepts(SKIP_TESTS, + "Start apps, but skip tests") + .withRequiredArg() + .ofType(Boolean.class) + .defaultsTo(false); + + ArgumentAcceptingOptionSpec shutdownAfterTestsOpt = + parser.accepts(SHUTDOWN_AFTER_TESTS, + "Terminate all processes after tests") + .withRequiredArg() + .ofType(Boolean.class) + .defaultsTo(true); + + ArgumentAcceptingOptionSpec supportingAppsOpt = + parser.accepts(SUPPORTING_APPS, + "Comma delimited list of supporting apps (bitcoind,seednode,arbdaemon,...") + .withRequiredArg() + .ofType(String.class) + .defaultsTo("bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon"); + + try { + CompositeOptionSet options = new CompositeOptionSet(); + + // Parse command line options + OptionSet cliOpts = parser.parse(args); + options.addOptionSet(cliOpts); + + // Parse config file specified at the command line only if it was specified as + // an absolute path. Otherwise, the config file will be processed later below. + File configFile = null; + OptionSpec[] disallowedOpts = new OptionSpec[]{helpOpt, configFileOpt}; + final boolean cliHasConfigFileOpt = cliOpts.has(configFileOpt); + boolean configFileHasBeenProcessed = false; + if (cliHasConfigFileOpt) { + configFile = new File(cliOpts.valueOf(configFileOpt)); + if (configFile.isAbsolute()) { + Optional configFileOpts = parseOptionsFrom(configFile, disallowedOpts); + if (configFileOpts.isPresent()) { + options.addOptionSet(configFileOpts.get()); + configFileHasBeenProcessed = true; + } + } + } + + // If the config file has not yet been processed, either because a relative + // path was provided at the command line, or because no value was provided at + // the command line, attempt to process the file now, falling back to the + // default config file location if none was specified at the command line. + if (!configFileHasBeenProcessed) { + configFile = cliHasConfigFileOpt && !configFile.isAbsolute() ? + absoluteConfigFile(userDir, configFile.getPath()) : + defaultConfigFile; + Optional configFileOpts = parseOptionsFrom(configFile, disallowedOpts); + configFileOpts.ifPresent(options::addOptionSet); + } + + + // Assign all remaining properties, with command line options taking + // precedence over those provided in the config file (if any) + this.helpRequested = options.has(helpOpt); + this.configFile = configFile; + this.rootAppDataDir = options.valueOf(appDataDirOpt); + bashPath = options.valueOf(bashPathOpt); + this.berkeleyDbLibPath = options.valueOf(berkeleyDbLibPathOpt); + this.bitcoinPath = options.valueOf(bitcoinPathOpt); + this.bitcoinRegtestHost = options.valueOf(bitcoinRegtestHostOpt); + this.bitcoinRpcPort = options.valueOf(bitcoinRpcPortOpt); + this.bitcoinRpcUser = options.valueOf(bitcoinRpcUserOpt); + this.bitcoinRpcPassword = options.valueOf(bitcoinRpcPasswordOpt); + this.apiPassword = options.valueOf(apiPasswordOpt); + this.runSubprojectJars = options.valueOf(runSubprojectJarsOpt); + this.runArbNodeAsDesktop = options.valueOf(runArbNodeAsDesktopOpt); + this.runAliceNodeAsDesktop = options.valueOf(runAliceNodeAsDesktopOpt); + this.runBobNodeAsDesktop = options.valueOf(runBobNodeAsDesktopOpt); + this.bisqAppInitTime = options.valueOf(bisqAppInitTimeOpt); + this.skipTests = options.valueOf(skipTestsOpt); + this.shutdownAfterTests = options.valueOf(shutdownAfterTestsOpt); + this.supportingApps = asList(options.valueOf(supportingAppsOpt).split(",")); + + // Assign values to special-case static fields. + BASH_PATH_VALUE = bashPath; + + } catch (OptionException ex) { + throw new IllegalStateException(format("Problem parsing option '%s': %s", + ex.options().get(0), + ex.getCause() != null ? + ex.getCause().getMessage() : + ex.getMessage())); + } + } + + public boolean hasSupportingApp(String... supportingApp) { + return stream(supportingApp).anyMatch(this.supportingApps::contains); + } + + public void printHelp(OutputStream sink, HelpFormatter formatter) { + try { + parser.formatHelpWith(formatter); + parser.printHelpOn(sink); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private Optional parseOptionsFrom(File configFile, OptionSpec[] disallowedOpts) { + if (!configFile.exists() && !configFile.equals(absoluteConfigFile(userDir, DEFAULT_CONFIG_FILE_NAME))) + throw new IllegalStateException(format("The specified config file '%s' does not exist.", configFile)); + + Properties properties = getProperties(configFile); + List optionLines = new ArrayList<>(); + properties.forEach((k, v) -> { + optionLines.add("--" + k + "=" + v); // dashes expected by jopt parser below + }); + + OptionSet configFileOpts = parser.parse(optionLines.toArray(new String[0])); + for (OptionSpec disallowedOpt : disallowedOpts) + if (configFileOpts.has(disallowedOpt)) + throw new IllegalStateException( + format("The '%s' option is disallowed in config files", + disallowedOpt.options().get(0))); + + return Optional.of(configFileOpts); + } + + private Properties getProperties(File configFile) { + try { + Properties properties = new Properties(); + properties.load(new FileInputStream(configFile.getAbsolutePath())); + return properties; + } catch (IOException ex) { + throw new IllegalStateException( + format("Could not load properties from config file %s", + configFile.getAbsolutePath()), ex); + } + } + + private static File absoluteConfigFile(String parentDir, String relativeConfigFilePath) { + return new File(parentDir, relativeConfigFilePath); + } +} diff --git a/apitest/src/main/java/bisq/apitest/config/BisqAppConfig.java b/apitest/src/main/java/bisq/apitest/config/BisqAppConfig.java new file mode 100644 index 00000000000..662956462ed --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/config/BisqAppConfig.java @@ -0,0 +1,123 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.config; + +import bisq.seednode.SeedNodeMain; + +import bisq.desktop.app.BisqAppMain; + + + +import bisq.daemon.app.BisqDaemonMain; + +/** + Some non user configurable Bisq seednode, arb node, bob and alice daemon option values. + @see dev-setup.md + @see dao-setup.md + */ +@SuppressWarnings("unused") +public enum BisqAppConfig { + + seednode("bisq-BTC_REGTEST_Seed_2002", + "bisq-seednode", + "\"-XX:MaxRAM=2g -Dlogback.configurationFile=apitest/build/resources/main/logback.xml\"", + SeedNodeMain.class.getName(), + 2002, + 5120, + -1), + arbdaemon("bisq-BTC_REGTEST_Arb_dao", + "bisq-daemon", + "\"-XX:MaxRAM=2g -Dlogback.configurationFile=apitest/build/resources/main/logback.xml\"", + BisqDaemonMain.class.getName(), + 4444, + 5121, + 9997), + arbdesktop("bisq-BTC_REGTEST_Arb_dao", + "bisq-desktop", + "\"-XX:MaxRAM=3g -Dlogback.configurationFile=apitest/build/resources/main/logback.xml\"", + BisqAppMain.class.getName(), + 4444, + 5121, + -1), + alicedaemon("bisq-BTC_REGTEST_Alice_dao", + "bisq-daemon", + "\"-XX:MaxRAM=2g -Dlogback.configurationFile=apitest/build/resources/main/logback.xml\"", + BisqDaemonMain.class.getName(), + 7777, + 5122, + 9998), + alicedesktop("bisq-BTC_REGTEST_Alice_dao", + "bisq-desktop", + "\"-XX:MaxRAM=4g -Dlogback.configurationFile=apitest/build/resources/main/logback.xml\"", + BisqAppMain.class.getName(), + 7777, + 5122, + -1), + bobdaemon("bisq-BTC_REGTEST_Bob_dao", + "bisq-daemon", + "\"-XX:MaxRAM=2g -Dlogback.configurationFile=apitest/build/resources/main/logback.xml\"", + BisqDaemonMain.class.getName(), + 8888, + 5123, + 9999), + bobdesktop("bisq-BTC_REGTEST_Bob_dao", + "bisq-desktop", + "\"-XX:MaxRAM=4g -Dlogback.configurationFile=apitest/build/resources/main/logback.xml\"", + BisqAppMain.class.getName(), + 8888, + 5123, + -1); + + public final String appName; + public final String startupScript; + public final String javaOpts; + public final String mainClassName; + public final int nodePort; + public final int rpcBlockNotificationPort; + // Daemons can use a global gRPC password, but each needs a unique apiPort. + public final int apiPort; + + BisqAppConfig(String appName, + String startupScript, + String javaOpts, + String mainClassName, + int nodePort, + int rpcBlockNotificationPort, + int apiPort) { + this.appName = appName; + this.startupScript = startupScript; + this.javaOpts = javaOpts; + this.mainClassName = mainClassName; + this.nodePort = nodePort; + this.rpcBlockNotificationPort = rpcBlockNotificationPort; + this.apiPort = apiPort; + } + + @Override + public String toString() { + return "BisqAppConfig{" + "\n" + + " appName='" + appName + '\'' + "\n" + + ", startupScript='" + startupScript + '\'' + "\n" + + ", javaOpts='" + javaOpts + '\'' + "\n" + + ", mainClassName='" + mainClassName + '\'' + "\n" + + ", nodePort=" + nodePort + "\n" + + ", rpcBlockNotificationPort=" + rpcBlockNotificationPort + "\n" + + ", apiPort=" + apiPort + "\n" + + '}'; + } +} diff --git a/apitest/src/main/java/bisq/apitest/config/CompositeOptionSet.java b/apitest/src/main/java/bisq/apitest/config/CompositeOptionSet.java new file mode 100644 index 00000000000..341b1e01017 --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/config/CompositeOptionSet.java @@ -0,0 +1,61 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.config; + +import joptsimple.OptionSet; +import joptsimple.OptionSpec; + +import java.util.ArrayList; +import java.util.List; + +/** + * Composes multiple JOptSimple {@link OptionSet} instances such that calls to + * {@link #valueOf(OptionSpec)} and co will search all instances in the order they were + * added and return any value explicitly set, otherwise returning the default value for + * the given option or null if no default has been set. The API found here loosely + * emulates the {@link OptionSet} API without going through the unnecessary work of + * actually extending it. In practice, this class is used to compose options provided at + * the command line with those provided via config file, such that those provided at the + * command line take precedence over those provided in the config file. + */ +class CompositeOptionSet { + + private final List optionSets = new ArrayList<>(); + + public void addOptionSet(OptionSet optionSet) { + optionSets.add(optionSet); + } + + public boolean has(OptionSpec option) { + for (OptionSet optionSet : optionSets) + if (optionSet.has(option)) + return true; + + return false; + } + + public V valueOf(OptionSpec option) { + for (OptionSet optionSet : optionSets) + if (optionSet.has(option)) + return optionSet.valueOf(option); + + // None of the provided option sets specified the given option so fall back to + // the default value (if any) provided by the first specified OptionSet + return optionSets.get(0).valueOf(option); + } +} diff --git a/apitest/src/main/java/bisq/apitest/linux/AbstractLinuxProcess.java b/apitest/src/main/java/bisq/apitest/linux/AbstractLinuxProcess.java new file mode 100644 index 00000000000..4687477e956 --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/linux/AbstractLinuxProcess.java @@ -0,0 +1,131 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.linux; + +import java.nio.file.Paths; + +import java.io.File; +import java.io.IOException; + +import java.util.ArrayList; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.apitest.linux.BashCommand.isAlive; +import static java.lang.String.format; +import static joptsimple.internal.Strings.EMPTY; + + + +import bisq.apitest.config.ApiTestConfig; + +@Slf4j +abstract class AbstractLinuxProcess implements LinuxProcess { + + protected final String name; + protected final ApiTestConfig config; + + protected long pid; + + protected final List startupExceptions; + protected final List shutdownExceptions; + + public AbstractLinuxProcess(String name, ApiTestConfig config) { + this.name = name; + this.config = config; + this.startupExceptions = new ArrayList<>(); + this.shutdownExceptions = new ArrayList<>(); + } + + @Override + public String getName() { + return this.name; + } + + @Override + public boolean hasStartupExceptions() { + return !startupExceptions.isEmpty(); + } + + @Override + public boolean hasShutdownExceptions() { + return !shutdownExceptions.isEmpty(); + } + + @Override + public void logExceptions(List exceptions, org.slf4j.Logger log) { + StringBuilder errorBuilder = new StringBuilder(); + for (Throwable t : exceptions) { + log.error("", t); + errorBuilder.append(t.getMessage()).append("\n"); + } + } + + @Override + public List getStartupExceptions() { + return startupExceptions; + } + + @Override + public List getShutdownExceptions() { + return shutdownExceptions; + } + + @SuppressWarnings("unused") + public void verifyBitcoinPathsExist() { + verifyBitcoinPathsExist(false); + } + + public void verifyBitcoinPathsExist(boolean verbose) { + if (verbose) + log.info(format("Checking bitcoind env...%n" + + "\t%-20s%s%n\t%-20s%s%n\t%-20s%s%n\t%-20s%s", + "berkeleyDbLibPath", config.berkeleyDbLibPath, + "bitcoinPath", config.bitcoinPath, + "bitcoinDatadir", config.bitcoinDatadir, + "blocknotify", config.bitcoinDatadir + "/blocknotify")); + + if (!config.berkeleyDbLibPath.equals(EMPTY)) { + File berkeleyDbLibPath = new File(config.berkeleyDbLibPath); + if (!berkeleyDbLibPath.exists() || !berkeleyDbLibPath.canExecute()) + throw new IllegalStateException(berkeleyDbLibPath + " cannot be found or executed"); + } + + File bitcoindExecutable = Paths.get(config.bitcoinPath, "bitcoind").toFile(); + if (!bitcoindExecutable.exists() || !bitcoindExecutable.canExecute()) + throw new IllegalStateException(format("'%s' cannot be found or executed.%n" + + "A bitcoin-core v0.19.X installation is required, and" + + " the 'bitcoinPath' must be configured in 'apitest.properties'", + bitcoindExecutable.getAbsolutePath())); + + File bitcoindDatadir = new File(config.bitcoinDatadir); + if (!bitcoindDatadir.exists() || !bitcoindDatadir.canWrite()) + throw new IllegalStateException(bitcoindDatadir + " cannot be found or written to"); + + File blocknotify = new File(bitcoindDatadir, "blocknotify"); + if (!blocknotify.exists() || !blocknotify.canExecute()) + throw new IllegalStateException(blocknotify.getAbsolutePath() + " cannot be found or executed"); + } + + public void verifyBitcoindRunning() throws IOException, InterruptedException { + long bitcoindPid = BashCommand.getPid("bitcoind"); + if (bitcoindPid < 0 || !isAlive(bitcoindPid)) + throw new IllegalStateException("Bitcoind not running"); + } +} diff --git a/apitest/src/main/java/bisq/apitest/linux/BashCommand.java b/apitest/src/main/java/bisq/apitest/linux/BashCommand.java new file mode 100644 index 00000000000..f40d9b06c9d --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/linux/BashCommand.java @@ -0,0 +1,156 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.linux; + +import java.io.IOException; + +import java.util.ArrayList; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +import org.jetbrains.annotations.NotNull; + +import static bisq.apitest.config.ApiTestConfig.BASH_PATH_VALUE; +import static java.lang.management.ManagementFactory.getRuntimeMXBean; + +@Slf4j +public class BashCommand { + + private int exitStatus = -1; + private String output; + private String error; + + private final String command; + private final int numResponseLines; + + public BashCommand(String command) { + this(command, 0); + } + + public BashCommand(String command, int numResponseLines) { + this.command = command; + this.numResponseLines = numResponseLines; // only want the top N lines of output + } + + public BashCommand run() throws IOException, InterruptedException { + SystemCommandExecutor commandExecutor = new SystemCommandExecutor(tokenizeSystemCommand()); + exitStatus = commandExecutor.exec(); + processOutput(commandExecutor); + return this; + } + + public BashCommand runInBackground() throws IOException, InterruptedException { + SystemCommandExecutor commandExecutor = new SystemCommandExecutor(tokenizeSystemCommand()); + exitStatus = commandExecutor.exec(false); + processOutput(commandExecutor); + return this; + } + + private void processOutput(SystemCommandExecutor commandExecutor) { + // Get the error status and stderr from system command. + StringBuilder stderr = commandExecutor.getStandardErrorFromCommand(); + if (stderr.length() > 0) + error = stderr.toString(); + + if (exitStatus != 0) + return; + + // Format and cache the stdout from system command. + StringBuilder stdout = commandExecutor.getStandardOutputFromCommand(); + String[] rawLines = stdout.toString().split("\n"); + StringBuilder truncatedLines = new StringBuilder(); + int limit = numResponseLines > 0 ? Math.min(numResponseLines, rawLines.length) : rawLines.length; + for (int i = 0; i < limit; i++) { + String line = rawLines[i].length() >= 220 ? rawLines[i].substring(0, 220) + " ..." : rawLines[i]; + truncatedLines.append(line).append((i < limit - 1) ? "\n" : ""); + } + output = truncatedLines.toString(); + } + + public String getCommand() { + return this.command; + } + + public int getExitStatus() { + return this.exitStatus; + } + + // TODO return Optional + public String getOutput() { + return this.output; + } + + // TODO return Optional + public String getError() { + return this.error; + } + + @NotNull + private List tokenizeSystemCommand() { + return new ArrayList<>() {{ + add(BASH_PATH_VALUE); + add("-c"); + add(command); + }}; + } + + @SuppressWarnings("unused") + // Convenience method for getting system load info. + public static String printSystemLoadString(Exception tracingException) throws IOException, InterruptedException { + StackTraceElement[] stackTraceElement = tracingException.getStackTrace(); + StringBuilder stackTraceBuilder = new StringBuilder(tracingException.getMessage()).append("\n"); + int traceLimit = Math.min(stackTraceElement.length, 4); + for (int i = 0; i < traceLimit; i++) { + stackTraceBuilder.append(stackTraceElement[i]).append("\n"); + } + stackTraceBuilder.append("..."); + log.info(stackTraceBuilder.toString()); + BashCommand cmd = new BashCommand("ps -aux --sort -rss --headers", 2).run(); + return cmd.getOutput() + "\n" + + "System load: Memory (MB): " + getUsedMemoryInMB() + " / No. of threads: " + Thread.activeCount() + + " JVM uptime (ms): " + getRuntimeMXBean().getUptime(); + } + + public static long getUsedMemoryInMB() { + Runtime runtime = Runtime.getRuntime(); + long free = runtime.freeMemory() / 1024 / 1024; + long total = runtime.totalMemory() / 1024 / 1024; + return total - free; + } + + public static long getPid(String processName) throws IOException, InterruptedException { + String psCmd = "ps aux | pgrep " + processName + " | grep -v grep"; + String psCmdOutput = new BashCommand(psCmd).run().getOutput(); + if (psCmdOutput == null || psCmdOutput.isEmpty()) + return -1; + + return Long.parseLong(psCmdOutput); + } + + @SuppressWarnings("unused") + public static BashCommand grep(String processName) throws IOException, InterruptedException { + String c = "ps -aux | grep " + processName + " | grep -v grep"; + return new BashCommand(c).run(); + } + + public static boolean isAlive(long pid) throws IOException, InterruptedException { + String isAliveScript = "if ps -p " + pid + " > /dev/null; then echo true; else echo false; fi"; + return new BashCommand(isAliveScript).run().getOutput().equals("true"); + } +} diff --git a/apitest/src/main/java/bisq/apitest/linux/BisqApp.java b/apitest/src/main/java/bisq/apitest/linux/BisqApp.java new file mode 100644 index 00000000000..f449d0b98f1 --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/linux/BisqApp.java @@ -0,0 +1,263 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.linux; + +import java.nio.file.Paths; + +import java.io.File; +import java.io.IOException; + +import java.util.ArrayList; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.apitest.linux.BashCommand.isAlive; +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + + + +import bisq.apitest.config.ApiTestConfig; +import bisq.apitest.config.BisqAppConfig; +import bisq.daemon.app.BisqDaemonMain; + +/** + * Runs a regtest/dao Bisq application instance in the background. + */ +@Slf4j +public class BisqApp extends AbstractLinuxProcess implements LinuxProcess { + + private final BisqAppConfig bisqAppConfig; + private final String baseCurrencyNetwork; + private final String genesisTxId; + private final int genesisBlockHeight; + private final String seedNodes; + private final boolean daoActivated; + private final boolean fullDaoNode; + private final boolean useLocalhostForP2P; + public final boolean useDevPrivilegeKeys; + private final String findBisqPidScript; + + public BisqApp(BisqAppConfig bisqAppConfig, ApiTestConfig config) { + super(bisqAppConfig.appName, config); + this.bisqAppConfig = bisqAppConfig; + this.baseCurrencyNetwork = "BTC_REGTEST"; + this.genesisTxId = "30af0050040befd8af25068cc697e418e09c2d8ebd8d411d2240591b9ec203cf"; + this.genesisBlockHeight = 111; + this.seedNodes = "localhost:2002"; + this.daoActivated = true; + this.fullDaoNode = true; + this.useLocalhostForP2P = true; + this.useDevPrivilegeKeys = true; + this.findBisqPidScript = (config.isRunningTest ? "." : "./apitest") + + "/scripts/get-bisq-pid.sh"; + } + + @Override + public void start() { + try { + if (config.runSubprojectJars) + runJar(); // run subproject/build/lib/*.jar (not full build) + else + runStartupScript(); // run bisq-* script for end to end test (default) + } catch (Throwable t) { + startupExceptions.add(t); + } + } + + @Override + public long getPid() { + return this.pid; + } + + @Override + public void shutdown() { + try { + log.info("Shutting down {} ...", bisqAppConfig.appName); + if (!isAlive(pid)) { + this.shutdownExceptions.add(new IllegalStateException(format("%s already shut down", bisqAppConfig.appName))); + return; + } + + String killCmd = "kill -15 " + pid; + if (new BashCommand(killCmd).run().getExitStatus() != 0) { + this.shutdownExceptions.add(new IllegalStateException(format("Could not shut down %s", bisqAppConfig.appName))); + return; + } + + // Be lenient about the time it takes for a java app to shut down. + for (int i = 0; i < 5; i++) { + if (!isAlive(pid)) { + log.info("{} stopped", bisqAppConfig.appName); + break; + } + MILLISECONDS.sleep(2500); + } + + if (isAlive(pid)) { + this.shutdownExceptions.add(new IllegalStateException(format("%s shutdown did not work", bisqAppConfig.appName))); + return; + } + + } catch (Exception e) { + this.shutdownExceptions.add(new IllegalStateException(format("Error shutting down %s", bisqAppConfig.appName), e)); + } + } + + public void verifyAppNotRunning() throws IOException, InterruptedException { + long pid = findBisqAppPid(); + if (pid >= 0) + throw new IllegalStateException(format("%s %s already running with pid %d", + bisqAppConfig.mainClassName, bisqAppConfig.appName, pid)); + } + + public void verifyAppDataDirInstalled() { + // If we're running an Alice or Bob daemon, make sure the dao-setup directory + // are installed. + switch (bisqAppConfig) { + case alicedaemon: + case alicedesktop: + case bobdaemon: + case bobdesktop: + File bisqDataDir = new File(config.rootAppDataDir, bisqAppConfig.appName); + if (!bisqDataDir.exists()) + throw new IllegalStateException(format("Application dataDir %s/%s not found", + config.rootAppDataDir, bisqAppConfig.appName)); + break; + default: + break; + } + } + + // This is the non-default way of running a Bisq app (--runSubprojectJars=true). + // It runs a java cmd, and does not depend on a full build. Bisq jars are loaded + // from the :subproject/build/libs directories. + private void runJar() throws IOException, InterruptedException { + String java = getJavaExecutable().getAbsolutePath(); + String classpath = System.getProperty("java.class.path"); + String bisqCmd = getJavaOptsSpec() + + " " + java + " -cp " + classpath + + " " + bisqAppConfig.mainClassName + + " " + String.join(" ", getOptsList()) + + " &"; // run in background without nohup + runBashCommand(bisqCmd); + } + + // This is the default way of running a Bisq app (--runSubprojectJars=false). + // It runs a bisq-* startup script, and depends on a full build. Bisq jars + // are loaded from the root project's lib directory. + private void runStartupScript() throws IOException, InterruptedException { + String startupScriptPath = config.rootProjectDir + + "/" + bisqAppConfig.startupScript; + String bisqCmd = getJavaOptsSpec() + + " " + startupScriptPath + + " " + String.join(" ", getOptsList()) + + " &"; // run in background without nohup + runBashCommand(bisqCmd); + } + + private void runBashCommand(String bisqCmd) throws IOException, InterruptedException { + String cmdDescription = config.runSubprojectJars + ? "java -> " + bisqAppConfig.mainClassName + " -> " + bisqAppConfig.appName + : bisqAppConfig.startupScript + " -> " + bisqAppConfig.appName; + BashCommand bashCommand = new BashCommand(bisqCmd); + log.info("Starting {} ...\n$ {}", cmdDescription, bashCommand.getCommand()); + bashCommand.runInBackground(); + + if (bashCommand.getExitStatus() != 0) + throw new IllegalStateException(format("Error starting BisqApp%n%s%nError: %s", + bisqAppConfig.appName, + bashCommand.getError())); + + // Sometimes it takes a little extra time to find the linux process id. + // Wait up to two seconds before giving up and throwing an Exception. + for (int i = 0; i < 4; i++) { + pid = findBisqAppPid(); + if (pid != -1) + break; + + MILLISECONDS.sleep(500L); + } + if (!isAlive(pid)) + throw new IllegalStateException(format("Error finding pid for %s", this.name)); + + log.info("{} running with pid {}", cmdDescription, pid); + log.info("Log {}", config.rootAppDataDir + "/" + bisqAppConfig.appName + "/bisq.log"); + } + + private long findBisqAppPid() throws IOException, InterruptedException { + // Find the pid of the java process by grepping for the mainClassName and appName. + String findPidCmd = findBisqPidScript + " " + bisqAppConfig.mainClassName + " " + bisqAppConfig.appName; + String psCmdOutput = new BashCommand(findPidCmd).run().getOutput(); + return (psCmdOutput == null || psCmdOutput.isEmpty()) ? -1 : Long.parseLong(psCmdOutput); + } + + private String getJavaOptsSpec() { + return "export JAVA_OPTS=" + bisqAppConfig.javaOpts + "; "; + } + + private List getOptsList() { + return new ArrayList<>() {{ + add("--appName=" + bisqAppConfig.appName); + add("--appDataDir=" + config.rootAppDataDir.getAbsolutePath() + "/" + bisqAppConfig.appName); + add("--nodePort=" + bisqAppConfig.nodePort); + add("--rpcBlockNotificationPort=" + bisqAppConfig.rpcBlockNotificationPort); + add("--rpcUser=" + config.bitcoinRpcUser); + add("--rpcPassword=" + config.bitcoinRpcPassword); + add("--rpcPort=" + config.bitcoinRpcPort); + add("--daoActivated=" + daoActivated); + add("--fullDaoNode=" + fullDaoNode); + add("--seedNodes=" + seedNodes); + add("--baseCurrencyNetwork=" + baseCurrencyNetwork); + add("--useDevPrivilegeKeys=" + useDevPrivilegeKeys); + add("--useLocalhostForP2P=" + useLocalhostForP2P); + switch (bisqAppConfig) { + case seednode: + break; // no extra opts needed for seed node + case arbdaemon: + case arbdesktop: + case alicedaemon: + case alicedesktop: + case bobdaemon: + case bobdesktop: + add("--genesisBlockHeight=" + genesisBlockHeight); + add("--genesisTxId=" + genesisTxId); + if (bisqAppConfig.mainClassName.equals(BisqDaemonMain.class.getName())) { + add("--apiPassword=" + config.apiPassword); + add("--apiPort=" + bisqAppConfig.apiPort); + } + break; + default: + throw new IllegalStateException("Unknown BisqAppConfig " + bisqAppConfig.name()); + } + }}; + } + + private File getJavaExecutable() { + File javaHome = Paths.get(System.getProperty("java.home")).toFile(); + if (!javaHome.exists()) + throw new IllegalStateException(format("$JAVA_HOME not found, cannot run %s", bisqAppConfig.mainClassName)); + + File javaExecutable = Paths.get(javaHome.getAbsolutePath(), "bin", "java").toFile(); + if (javaExecutable.exists() || javaExecutable.canExecute()) + return javaExecutable; + else + throw new IllegalStateException("$JAVA_HOME/bin/java not found or executable"); + } +} diff --git a/apitest/src/main/java/bisq/apitest/linux/BitcoinCli.java b/apitest/src/main/java/bisq/apitest/linux/BitcoinCli.java new file mode 100644 index 00000000000..2367443b0c4 --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/linux/BitcoinCli.java @@ -0,0 +1,182 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.linux; + +import java.io.IOException; + +import lombok.extern.slf4j.Slf4j; + + + +import bisq.apitest.config.ApiTestConfig; + +@Slf4j +public class BitcoinCli extends AbstractLinuxProcess implements LinuxProcess { + + private final String command; + + private String commandWithOptions; + private String output; + private boolean error; + private String errorMessage; + + public BitcoinCli(ApiTestConfig config, String command) { + super("bitcoin-cli", config); + this.command = command; + this.error = false; + this.errorMessage = null; + } + + public BitcoinCli run() throws IOException, InterruptedException { + this.start(); + return this; + } + + public String getCommandWithOptions() { + return commandWithOptions; + } + + public String getOutput() { + if (isError()) + throw new IllegalStateException(output); + + // Some responses are not in json format, such as what is returned by + // 'getnewaddress'. The raw output string is the value. + + return output; + } + + public String[] getOutputValueAsStringArray() { + if (isError()) + throw new IllegalStateException(output); + + if (!output.startsWith("[") && !output.endsWith("]")) + throw new IllegalStateException(output + "\nis not a json array"); + + String[] lines = output.split("\n"); + String[] array = new String[lines.length - 2]; + for (int i = 1; i < lines.length - 1; i++) { + array[i - 1] = lines[i].replaceAll("[^a-zA-Z0-9.]", ""); + } + + return array; + } + + public String getOutputValueAsString(String key) { + if (isError()) + throw new IllegalStateException(output); + + // Some assumptions about bitcoin-cli json string parsing: + // Every multi valued, non-error bitcoin-cli response will be a json string. + // Every key/value in the json string will terminate with a newline. + // Most key/value lines in json strings have a ',' char in front of the newline. + // e.g., bitcoin-cli 'getwalletinfo' output: + // { + // "walletname": "", + // "walletversion": 159900, + // "balance": 527.49941568, + // "unconfirmed_balance": 0.00000000, + // "immature_balance": 5000.00058432, + // "txcount": 114, + // "keypoololdest": 1528018235, + // "keypoolsize": 1000, + // "keypoolsize_hd_internal": 1000, + // "paytxfee": 0.00000000, + // "hdseedid": "179b609a60c2769138844c3e36eb430fd758a9c6", + // "private_keys_enabled": true, + // "avoid_reuse": false, + // "scanning": false + // } + + int keyIdx = output.indexOf("\"" + key + "\":"); + int eolIdx = output.indexOf("\n", keyIdx); + String valueLine = output.substring(keyIdx, eolIdx); // "balance": 527.49941568, + String[] keyValue = valueLine.split(":"); + + // Remove all but alphanumeric chars and decimal points from the return value, + // including quotes around strings, and trailing commas. + // Adjustments will be necessary as we begin to work with more complex + // json values, such as arrays. + return keyValue[1].replaceAll("[^a-zA-Z0-9.]", ""); + } + + public boolean getOutputValueAsBoolean(String key) { + String valueStr = getOutputValueAsString(key); + return Boolean.parseBoolean(valueStr); + } + + + public int getOutputValueAsInt(String key) { + String valueStr = getOutputValueAsString(key); + return Integer.parseInt(valueStr); + } + + public double getOutputValueAsDouble(String key) { + String valueStr = getOutputValueAsString(key); + return Double.parseDouble(valueStr); + } + + public long getOutputValueAsLong(String key) { + String valueStr = getOutputValueAsString(key); + return Long.parseLong(valueStr); + } + + public boolean isError() { + return error; + } + + public String getErrorMessage() { + return errorMessage; + } + + @Override + public void start() throws InterruptedException, IOException { + verifyBitcoinPathsExist(false); + verifyBitcoindRunning(); + commandWithOptions = config.bitcoinPath + "/bitcoin-cli -regtest " + + " -rpcport=" + config.bitcoinRpcPort + + " -rpcuser=" + config.bitcoinRpcUser + + " -rpcpassword=" + config.bitcoinRpcPassword + + " " + command; + BashCommand bashCommand = new BashCommand(commandWithOptions).run(); + + error = bashCommand.getExitStatus() != 0; + if (error) { + errorMessage = bashCommand.getError(); + if (errorMessage == null || errorMessage.isEmpty()) + throw new IllegalStateException("bitcoin-cli returned an error without a message"); + + } else { + output = bashCommand.getOutput(); + } + } + + @Override + public long getPid() { + // We don't cache the pid. The bitcoin-cli will quickly return a + // response, including server error info if any. + throw new UnsupportedOperationException("getPid not supported"); + } + + @Override + public void shutdown() { + // We don't try to shutdown the bitcoin-cli. It will quickly return a + // response, including server error info if any. + throw new UnsupportedOperationException("shutdown not supported"); + } +} diff --git a/apitest/src/main/java/bisq/apitest/linux/BitcoinDaemon.java b/apitest/src/main/java/bisq/apitest/linux/BitcoinDaemon.java new file mode 100644 index 00000000000..cc2e4952005 --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/linux/BitcoinDaemon.java @@ -0,0 +1,117 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.linux; + +import java.io.IOException; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.apitest.linux.BashCommand.isAlive; +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static joptsimple.internal.Strings.EMPTY; + + + +import bisq.apitest.config.ApiTestConfig; + +@Slf4j +public class BitcoinDaemon extends AbstractLinuxProcess implements LinuxProcess { + + public BitcoinDaemon(ApiTestConfig config) { + super("bitcoind", config); + } + + @Override + public void start() throws InterruptedException, IOException { + + // If the bitcoind binary is dynamically linked to berkeley db libs, export the + // configured berkeley-db lib path. If statically linked, the berkeley db lib + // path will not be exported. + String berkeleyDbLibPathExport = config.berkeleyDbLibPath.equals(EMPTY) ? EMPTY + : "export LD_LIBRARY_PATH=" + config.berkeleyDbLibPath + "; "; + + String bitcoindCmd = berkeleyDbLibPathExport + + config.bitcoinPath + "/bitcoind" + + " -datadir=" + config.bitcoinDatadir + + " -daemon" + + " -regtest=1" + + " -server=1" + + " -txindex=1" + + " -peerbloomfilters=1" + + " -debug=net" + + " -fallbackfee=0.0002" + + " -rpcport=" + config.bitcoinRpcPort + + " -rpcuser=" + config.bitcoinRpcUser + + " -rpcpassword=" + config.bitcoinRpcPassword + + " -blocknotify=" + config.bitcoinDatadir + "/blocknotify"; + + BashCommand cmd = new BashCommand(bitcoindCmd).run(); + log.info("Starting ...\n$ {}", cmd.getCommand()); + + if (cmd.getExitStatus() != 0) { + startupExceptions.add(new IllegalStateException( + format("Error starting bitcoind%nstatus: %d%nerror msg: %s", + cmd.getExitStatus(), cmd.getError()))); + return; + } + + pid = BashCommand.getPid("bitcoind"); + if (!isAlive(pid)) + throw new IllegalStateException("Error starting regtest bitcoind daemon:\n" + cmd.getCommand()); + + log.info("Running with pid {}", pid); + log.info("Log {}", config.bitcoinDatadir + "/regtest/debug.log"); + } + + @Override + public long getPid() { + return this.pid; + } + + @Override + public void shutdown() { + try { + log.info("Shutting down bitcoind daemon..."); + + if (!isAlive(pid)) { + this.shutdownExceptions.add(new IllegalStateException("Bitcoind already shut down.")); + return; + } + + if (new BashCommand("kill -15 " + pid).run().getExitStatus() != 0) { + this.shutdownExceptions.add(new IllegalStateException("Could not shut down bitcoind; probably already stopped.")); + return; + } + + MILLISECONDS.sleep(2500); // allow it time to shutdown + + if (isAlive(pid)) { + this.shutdownExceptions.add(new IllegalStateException( + format("Could not kill bitcoind process with pid %d.", pid))); + return; + } + + log.info("Stopped"); + } catch (InterruptedException ignored) { + // empty + } catch (IOException e) { + this.shutdownExceptions.add(new IllegalStateException("Error shutting down bitcoind.", e)); + } + } +} diff --git a/apitest/src/main/java/bisq/apitest/linux/LinuxProcess.java b/apitest/src/main/java/bisq/apitest/linux/LinuxProcess.java new file mode 100644 index 00000000000..fff25c2976c --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/linux/LinuxProcess.java @@ -0,0 +1,42 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.linux; + +import java.io.IOException; + +import java.util.List; + +public interface LinuxProcess { + void start() throws InterruptedException, IOException; + + String getName(); + + long getPid(); + + boolean hasStartupExceptions(); + + boolean hasShutdownExceptions(); + + void logExceptions(List exceptions, org.slf4j.Logger log); + + List getStartupExceptions(); + + List getShutdownExceptions(); + + void shutdown(); +} diff --git a/apitest/src/main/java/bisq/apitest/linux/SystemCommandExecutor.java b/apitest/src/main/java/bisq/apitest/linux/SystemCommandExecutor.java new file mode 100644 index 00000000000..28c0fde235e --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/linux/SystemCommandExecutor.java @@ -0,0 +1,121 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.linux; + +import java.io.IOException; +import java.io.InputStream; + +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +/** + * This class can be used to execute a system command from a Java application. + * See the documentation for the public methods of this class for more + * information. + * + * Documentation for this class is available at this URL: + * + * http://devdaily.com/java/java-processbuilder-process-system-exec + * + * Copyright 2010 alvin j. alexander, devdaily.com. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser Public License for more details. + * You should have received a copy of the GNU Lesser Public License + * along with this program. If not, see . + * + * Please ee the following page for the LGPL license: + * http://www.gnu.org/licenses/lgpl.txt + * + */ +@Slf4j +class SystemCommandExecutor { + private final List cmdOptions; + private ThreadedStreamHandler inputStreamHandler; + private ThreadedStreamHandler errorStreamHandler; + + public SystemCommandExecutor(final List cmdOptions) { + if (log.isDebugEnabled()) + log.debug("cmd options {}", cmdOptions.toString()); + + if (cmdOptions.isEmpty()) + throw new IllegalStateException("No command params specified."); + + if (cmdOptions.contains("sudo")) + throw new IllegalStateException("'sudo' commands are prohibited."); + + this.cmdOptions = cmdOptions; + } + + // Execute a system command and return its status code (0 or 1). + // The system command's output (stderr or stdout) can be accessed from accessors. + public int exec() throws IOException, InterruptedException { + return exec(true); + } + + // Execute a system command and return its status code (0 or 1). + // The system command's output (stderr or stdout) can be accessed from accessors + // if the waitOnErrStream flag is true, else the method will not wait on (join) + // the error stream handler thread. + public int exec(boolean waitOnErrStream) throws IOException, InterruptedException { + Process process = new ProcessBuilder(cmdOptions).start(); + + // I'm currently doing these on a separate line here in case i need to set them to null + // to get the threads to stop. + // see http://java.sun.com/j2se/1.5.0/docs/guide/misc/threadPrimitiveDeprecation.html + InputStream inputStream = process.getInputStream(); + InputStream errorStream = process.getErrorStream(); + + // These need to run as java threads to get the standard output and error from the command. + // the inputstream handler gets a reference to our stdOutput in case we need to write + // something to it. + inputStreamHandler = new ThreadedStreamHandler(inputStream); + errorStreamHandler = new ThreadedStreamHandler(errorStream); + + inputStreamHandler.start(); + errorStreamHandler.start(); + + int exitStatus = process.waitFor(); + + inputStreamHandler.interrupt(); + errorStreamHandler.interrupt(); + + inputStreamHandler.join(); + if (waitOnErrStream) + errorStreamHandler.join(); + + return exitStatus; + } + + // Get the standard error from an executed system command. + public StringBuilder getStandardErrorFromCommand() { + return errorStreamHandler.getOutputBuffer(); + } + + // Get the standard output from an executed system command. + public StringBuilder getStandardOutputFromCommand() { + return inputStreamHandler.getOutputBuffer(); + } +} diff --git a/apitest/src/main/java/bisq/apitest/linux/ThreadedStreamHandler.java b/apitest/src/main/java/bisq/apitest/linux/ThreadedStreamHandler.java new file mode 100644 index 00000000000..540b1361009 --- /dev/null +++ b/apitest/src/main/java/bisq/apitest/linux/ThreadedStreamHandler.java @@ -0,0 +1,91 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.linux; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; + +import lombok.extern.slf4j.Slf4j; + +/** + * This class is intended to be used with the SystemCommandExecutor + * class to let users execute system commands from Java applications. + * + * This class is based on work that was shared in a JavaWorld article + * named "When System.exec() won't". That article is available at this + * url: + * + * http://www.javaworld.com/javaworld/jw-12-2000/jw-1229-traps.html + * + * Documentation for this class is available at this URL: + * + * http://devdaily.com/java/java-processbuilder-process-system-exec + * + * + * Copyright 2010 alvin j. alexander, devdaily.com. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser Public License for more details. + * You should have received a copy of the GNU Lesser Public License + * along with this program. If not, see . + * + * Please ee the following page for the LGPL license: + * http://www.gnu.org/licenses/lgpl.txt + * + */ +@Slf4j +class ThreadedStreamHandler extends Thread { + final InputStream inputStream; + final StringBuilder outputBuffer = new StringBuilder(); + + ThreadedStreamHandler(InputStream inputStream) { + this.inputStream = inputStream; + } + + public void run() { + try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + while ((line = bufferedReader.readLine()) != null) + outputBuffer.append(line).append("\n"); + + } catch (Throwable t) { + t.printStackTrace(); + } + } + + @SuppressWarnings("unused") + private void doSleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException ignored) { + // empty + } + } + + public StringBuilder getOutputBuffer() { + return outputBuffer; + } +} + diff --git a/apitest/src/main/resources/apitest.properties b/apitest/src/main/resources/apitest.properties new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apitest/src/main/resources/blocknotify b/apitest/src/main/resources/blocknotify new file mode 100755 index 00000000000..210d23f17c6 --- /dev/null +++ b/apitest/src/main/resources/blocknotify @@ -0,0 +1,20 @@ +#!/bin/bash + +# Regtest ports start with 512* + +# To avoid pesky bitcoind io errors, do not specify ports Bisq is not listening to. + +# SeedNode listens on port 5120 +echo $1 | nc -w 1 127.0.0.1 5120 + +# Arb Node listens on port 5121 +echo $1 | nc -w 1 127.0.0.1 5121 + +# Alice Node listens on port 5122 +echo $1 | nc -w 1 127.0.0.1 5122 + +# Bob Node listens on port 5123 +echo $1 | nc -w 1 127.0.0.1 5123 + +# Some other node listens on port 5124, etc. +# echo $1 | nc -w 1 127.0.0.1 5124 diff --git a/apitest/src/main/resources/logback.xml b/apitest/src/main/resources/logback.xml new file mode 100644 index 00000000000..9c5d0974bff --- /dev/null +++ b/apitest/src/main/resources/logback.xml @@ -0,0 +1,21 @@ + + + + + + %highlight(%d{MMM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{30}: %msg %xEx%n) + + + + + + + + + + diff --git a/apitest/src/test/java/bisq/apitest/ApiTestCase.java b/apitest/src/test/java/bisq/apitest/ApiTestCase.java new file mode 100644 index 00000000000..286e7f8c206 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/ApiTestCase.java @@ -0,0 +1,94 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest; + +import java.io.IOException; + +import java.util.concurrent.ExecutionException; + +import static bisq.apitest.config.BisqAppConfig.alicedaemon; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + + + +import bisq.apitest.config.ApiTestConfig; +import bisq.apitest.method.BitcoinCliHelper; + +/** + * Base class for all test types: 'method', 'scenario' and 'e2e'. + *

+ * During scaffold setup, various combinations of bitcoind and bisq instances + * can be started in the background before test cases are run. Currently, this test + * harness supports only the "Bisq DAO development environment running against a + * local Bitcoin regtest network" as described in + * dev-setup.md + * and dao-setup.md. + *

+ * Those documents contain information about the configurations used by this test harness: + * bitcoin-core's bitcoin.conf and blocknotify values, bisq instance options, the DAO genesis + * transaction id, initial BSQ and BTC balances for Bob & Alice accounts, and default + * PerfectMoney dummy payment accounts (USD) for Bob and Alice. + *

+ * During a build, the + * dao-setup.zip + * file is downloaded and extracted if necessary. In each test case's @BeforeClass + * method, the DAO setup files are re-installed into the run time's data directories + * (each test case runs on a refreshed DAO/regtest environment setup). + *

+ * Initial Alice balances & accounts: 10.0 BTC, 1000000.00 BSQ, USD PerfectMoney dummy + *

+ * Initial Bob balances & accounts: 10.0 BTC, 1500000.00 BSQ, USD PerfectMoney dummy + */ +public class ApiTestCase { + + // The gRPC service stubs are used by method & scenario tests, but not e2e tests. + protected static GrpcStubs grpcStubs; + + protected static Scaffold scaffold; + protected static ApiTestConfig config; + protected static BitcoinCliHelper bitcoinCli; + + public static void setUpScaffold(String supportingApps) + throws InterruptedException, ExecutionException, IOException { + // The supportingApps argument is a comma delimited string of supporting app + // names, e.g. "bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon" + scaffold = new Scaffold(supportingApps).setUp(); + config = scaffold.config; + bitcoinCli = new BitcoinCliHelper((config)); + grpcStubs = new GrpcStubs(alicedaemon, config).init(); + } + + public static void setUpScaffold() + throws InterruptedException, ExecutionException, IOException { + scaffold = new Scaffold(new String[]{}).setUp(); + config = scaffold.config; + grpcStubs = new GrpcStubs(alicedaemon, config).init(); + } + + public static void tearDownScaffold() { + scaffold.tearDown(); + } + + protected void sleep(long ms) { + try { + MILLISECONDS.sleep(ms); + } catch (InterruptedException ignored) { + // empty + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/GrpcStubs.java b/apitest/src/test/java/bisq/apitest/GrpcStubs.java new file mode 100644 index 00000000000..6279c61489f --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/GrpcStubs.java @@ -0,0 +1,109 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest; + +import bisq.proto.grpc.GetVersionGrpc; +import bisq.proto.grpc.OffersGrpc; +import bisq.proto.grpc.PaymentAccountsGrpc; +import bisq.proto.grpc.WalletsGrpc; + +import io.grpc.CallCredentials; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; + +import java.util.concurrent.Executor; + +import static io.grpc.Metadata.ASCII_STRING_MARSHALLER; +import static io.grpc.Status.UNAUTHENTICATED; +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.SECONDS; + + + +import bisq.apitest.config.ApiTestConfig; +import bisq.apitest.config.BisqAppConfig; + +public class GrpcStubs { + + public final CallCredentials credentials; + public final String host; + public final int port; + + public GetVersionGrpc.GetVersionBlockingStub versionService; + public OffersGrpc.OffersBlockingStub offersService; + public PaymentAccountsGrpc.PaymentAccountsBlockingStub paymentAccountsService; + public WalletsGrpc.WalletsBlockingStub walletsService; + + public GrpcStubs(BisqAppConfig bisqAppConfig, ApiTestConfig config) { + this.credentials = new PasswordCallCredentials(config.apiPassword); + this.host = "localhost"; + this.port = bisqAppConfig.apiPort; + } + + public GrpcStubs init() { + var channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + channel.shutdown().awaitTermination(1, SECONDS); + } catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + })); + + this.versionService = GetVersionGrpc.newBlockingStub(channel).withCallCredentials(credentials); + this.offersService = OffersGrpc.newBlockingStub(channel).withCallCredentials(credentials); + this.paymentAccountsService = PaymentAccountsGrpc.newBlockingStub(channel).withCallCredentials(credentials); + this.walletsService = WalletsGrpc.newBlockingStub(channel).withCallCredentials(credentials); + + return this; + } + + static class PasswordCallCredentials extends CallCredentials { + + public static final String PASSWORD_KEY = "password"; + private final String passwordValue; + + public PasswordCallCredentials(String passwordValue) { + if (passwordValue == null) + throw new IllegalArgumentException(format("'%s' value must not be null", PASSWORD_KEY)); + this.passwordValue = passwordValue; + } + + @Override + public void applyRequestMetadata(RequestInfo requestInfo, + Executor appExecutor, + MetadataApplier metadataApplier) { + appExecutor.execute(() -> { + try { + var headers = new Metadata(); + var passwordKey = Metadata.Key.of(PASSWORD_KEY, ASCII_STRING_MARSHALLER); + headers.put(passwordKey, passwordValue); + metadataApplier.apply(headers); + } catch (Throwable ex) { + metadataApplier.fail(UNAUTHENTICATED.withCause(ex)); + } + }); + } + + @Override + public void thisUsesUnstableApi() { + // An experimental api. A noop but never called; tries to make it clearer to + // implementors that they may break in the future. + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/JUnitHelper.java b/apitest/src/test/java/bisq/apitest/JUnitHelper.java new file mode 100644 index 00000000000..8ea80ad0d5e --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/JUnitHelper.java @@ -0,0 +1,58 @@ +package bisq.apitest; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.runner.Description; +import org.junit.runner.JUnitCore; +import org.junit.runner.Result; +import org.junit.runner.notification.Failure; +import org.junit.runner.notification.RunListener; + +import static java.lang.String.format; + +@Slf4j +public class JUnitHelper { + + private static boolean allPass; + + public static void runTests(Class... testClasses) { + JUnitCore jUnitCore = new JUnitCore(); + jUnitCore.addListener(new RunListener() { + public void testStarted(Description description) { + log.info("{}", description); + } + + public void testIgnored(Description description) { + log.info("Ignored {}", description); + } + + public void testFailure(Failure failure) { + log.error("Failed {}", failure.getTrace()); + } + }); + Result result = jUnitCore.run(testClasses); + printTestResults(result); + } + + public static boolean allTestsPassed() { + return allPass; + } + + private static void printTestResults(Result result) { + log.info("Total tests: {}, Failed: {}, Ignored: {}", + result.getRunCount(), + result.getFailureCount(), + result.getIgnoreCount()); + + if (result.wasSuccessful()) { + log.info("All {} tests passed", result.getRunCount()); + allPass = true; + } else if (result.getFailureCount() > 0) { + log.error("{} test(s) failed", result.getFailureCount()); + result.getFailures().iterator().forEachRemaining(f -> log.error(format("%s.%s()%n\t%s", + f.getDescription().getTestClass().getName(), + f.getDescription().getMethodName(), + f.getTrace()))); + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/BitcoinCliHelper.java b/apitest/src/test/java/bisq/apitest/method/BitcoinCliHelper.java new file mode 100644 index 00000000000..6e5b52080f3 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/BitcoinCliHelper.java @@ -0,0 +1,92 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.method; + +import java.io.IOException; + +import static java.lang.String.format; +import static org.junit.jupiter.api.Assertions.fail; + + + +import bisq.apitest.config.ApiTestConfig; +import bisq.apitest.linux.BitcoinCli; + +public final class BitcoinCliHelper { + + private final ApiTestConfig config; + + public BitcoinCliHelper(ApiTestConfig config) { + this.config = config; + } + + // Convenience methods for making bitcoin-cli calls. + + public String getNewBtcAddress() { + try { + BitcoinCli newAddress = new BitcoinCli(config, "getnewaddress").run(); + + if (newAddress.isError()) + fail(format("Could not generate new bitcoin address:%n%s", newAddress.getErrorMessage())); + + return newAddress.getOutput(); + } catch (IOException | InterruptedException ex) { + fail(ex); + return null; + } + } + + public String[] generateToAddress(int blocks, String address) { + try { + String generateToAddressCmd = format("generatetoaddress %d \"%s\"", blocks, address); + BitcoinCli generateToAddress = new BitcoinCli(config, generateToAddressCmd).run(); + + if (generateToAddress.isError()) + fail(format("Could not generate bitcoin block(s):%n%s", generateToAddress.getErrorMessage())); + + return generateToAddress.getOutputValueAsStringArray(); + } catch (IOException | InterruptedException ex) { + fail(ex); + return null; + } + } + + public void generateBlocks(int blocks) { + generateToAddress(blocks, getNewBtcAddress()); + } + + public String sendToAddress(String address, String amount) { + // sendtoaddress "address" amount \ + // ( "comment" "comment_to" subtractfeefromamount \ + // replaceable conf_target "estimate_mode" ) + // returns a transaction id + try { + String sendToAddressCmd = format("sendtoaddress \"%s\" %s \"\" \"\" false", + address, amount); + BitcoinCli sendToAddress = new BitcoinCli(config, sendToAddressCmd).run(); + + if (sendToAddress.isError()) + fail(format("Could not send BTC to address:%n%s", sendToAddress.getErrorMessage())); + + return sendToAddress.getOutput(); + } catch (IOException | InterruptedException ex) { + fail(ex); + return null; + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/GetBalanceTest.java b/apitest/src/test/java/bisq/apitest/method/GetBalanceTest.java new file mode 100644 index 00000000000..2cf4e8ae1cd --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/GetBalanceTest.java @@ -0,0 +1,68 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.method; + +import bisq.proto.grpc.GetBalanceRequest; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation; + + +@Slf4j +@TestMethodOrder(OrderAnnotation.class) +public class GetBalanceTest extends MethodTest { + + @BeforeAll + public static void setUp() { + try { + setUpScaffold("bitcoind,seednode,alicedaemon"); + + // Have to generate 1 regtest block for alice's wallet to show 10 BTC balance. + bitcoinCli.generateBlocks(1); + + // Give the alicedaemon time to parse the new block. + MILLISECONDS.sleep(1500); + } catch (Exception ex) { + fail(ex); + } + } + + @Test + @Order(1) + public void testGetBalance() { + // All tests depend on the DAO / regtest environment, and Alice's wallet is + // initialized with 10 BTC during the scaffolding setup. + var balance = grpcStubs.walletsService.getBalance(GetBalanceRequest.newBuilder().build()).getBalance(); + assertEquals(1000000000, balance); + } + + @AfterAll + public static void tearDown() { + tearDownScaffold(); + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/GetVersionTest.java b/apitest/src/test/java/bisq/apitest/method/GetVersionTest.java new file mode 100644 index 00000000000..22413cf9d3c --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/GetVersionTest.java @@ -0,0 +1,61 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.method; + +import bisq.proto.grpc.GetVersionRequest; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import static bisq.apitest.config.BisqAppConfig.alicedaemon; +import static bisq.common.app.Version.VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation; + + +@Slf4j +@TestMethodOrder(OrderAnnotation.class) +public class GetVersionTest extends MethodTest { + + @BeforeAll + public static void setUp() { + try { + setUpScaffold(alicedaemon.name()); + } catch (Exception ex) { + fail(ex); + } + } + + @Test + @Order(1) + public void testGetVersion() { + var version = grpcStubs.versionService.getVersion(GetVersionRequest.newBuilder().build()).getVersion(); + assertEquals(VERSION, version); + } + + @AfterAll + public static void tearDown() { + tearDownScaffold(); + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/MethodTest.java b/apitest/src/test/java/bisq/apitest/method/MethodTest.java new file mode 100644 index 00000000000..694aa6806e3 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/MethodTest.java @@ -0,0 +1,88 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.method; + +import bisq.proto.grpc.GetBalanceRequest; +import bisq.proto.grpc.GetFundingAddressesRequest; +import bisq.proto.grpc.LockWalletRequest; +import bisq.proto.grpc.RemoveWalletPasswordRequest; +import bisq.proto.grpc.SetWalletPasswordRequest; +import bisq.proto.grpc.UnlockWalletRequest; + + + +import bisq.apitest.ApiTestCase; + +public class MethodTest extends ApiTestCase { + + // Convenience methods for building gRPC request objects + + protected final GetBalanceRequest createBalanceRequest() { + return GetBalanceRequest.newBuilder().build(); + } + + protected final SetWalletPasswordRequest createSetWalletPasswordRequest(String password) { + return SetWalletPasswordRequest.newBuilder().setPassword(password).build(); + } + + protected final SetWalletPasswordRequest createSetWalletPasswordRequest(String oldPassword, String newPassword) { + return SetWalletPasswordRequest.newBuilder().setPassword(oldPassword).setNewPassword(newPassword).build(); + } + + protected final RemoveWalletPasswordRequest createRemoveWalletPasswordRequest(String password) { + return RemoveWalletPasswordRequest.newBuilder().setPassword(password).build(); + } + + protected final UnlockWalletRequest createUnlockWalletRequest(String password, long timeout) { + return UnlockWalletRequest.newBuilder().setPassword(password).setTimeout(timeout).build(); + } + + protected final LockWalletRequest createLockWalletRequest() { + return LockWalletRequest.newBuilder().build(); + } + + protected final GetFundingAddressesRequest createGetFundingAddressesRequest() { + return GetFundingAddressesRequest.newBuilder().build(); + } + + // Convenience methods for calling frequently used & thoroughly tested gRPC services. + + protected final long getBalance() { + return grpcStubs.walletsService.getBalance(createBalanceRequest()).getBalance(); + } + + protected final void unlockWallet(String password, long timeout) { + //noinspection ResultOfMethodCallIgnored + grpcStubs.walletsService.unlockWallet(createUnlockWalletRequest(password, timeout)); + } + + protected final void lockWallet() { + //noinspection ResultOfMethodCallIgnored + grpcStubs.walletsService.lockWallet(createLockWalletRequest()); + } + + protected final String getUnusedBtcAddress() { + return grpcStubs.walletsService.getFundingAddresses(createGetFundingAddressesRequest()) + .getAddressBalanceInfoList() + .stream() + .filter(a -> a.getBalance() == 0 && a.getNumConfirmations() == 0) + .findFirst() + .get() + .getAddress(); + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/WalletProtectionTest.java b/apitest/src/test/java/bisq/apitest/method/WalletProtectionTest.java new file mode 100644 index 00000000000..450fb58e010 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/WalletProtectionTest.java @@ -0,0 +1,135 @@ +package bisq.apitest.method; + +import io.grpc.StatusRuntimeException; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import static bisq.apitest.config.BisqAppConfig.alicedaemon; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation; + +@SuppressWarnings("ResultOfMethodCallIgnored") +@Slf4j +@TestMethodOrder(OrderAnnotation.class) +public class WalletProtectionTest extends MethodTest { + + @BeforeAll + public static void setUp() { + try { + setUpScaffold(alicedaemon.name()); + MILLISECONDS.sleep(2000); + } catch (Exception ex) { + fail(ex); + } + } + + @Test + @Order(1) + public void testSetWalletPassword() { + var request = createSetWalletPasswordRequest("first-password"); + grpcStubs.walletsService.setWalletPassword(request); + } + + @Test + @Order(2) + public void testGetBalanceOnEncryptedWalletShouldThrowException() { + Throwable exception = assertThrows(StatusRuntimeException.class, this::getBalance); + assertEquals("UNKNOWN: wallet is locked", exception.getMessage()); + } + + @Test + @Order(3) + public void testUnlockWalletFor4Seconds() { + var request = createUnlockWalletRequest("first-password", 4); + grpcStubs.walletsService.unlockWallet(request); + getBalance(); // should not throw 'wallet locked' exception + + sleep(4500); // let unlock timeout expire + Throwable exception = assertThrows(StatusRuntimeException.class, this::getBalance); + assertEquals("UNKNOWN: wallet is locked", exception.getMessage()); + } + + @Test + @Order(4) + public void testGetBalanceAfterUnlockTimeExpiryShouldThrowException() { + var request = createUnlockWalletRequest("first-password", 3); + grpcStubs.walletsService.unlockWallet(request); + sleep(4000); // let unlock timeout expire + Throwable exception = assertThrows(StatusRuntimeException.class, this::getBalance); + assertEquals("UNKNOWN: wallet is locked", exception.getMessage()); + } + + @Test + @Order(5) + public void testLockWalletBeforeUnlockTimeoutExpiry() { + unlockWallet("first-password", 60); + var request = createLockWalletRequest(); + grpcStubs.walletsService.lockWallet(request); + + Throwable exception = assertThrows(StatusRuntimeException.class, this::getBalance); + assertEquals("UNKNOWN: wallet is locked", exception.getMessage()); + } + + @Test + @Order(6) + public void testLockWalletWhenWalletAlreadyLockedShouldThrowException() { + var request = createLockWalletRequest(); + + Throwable exception = assertThrows(StatusRuntimeException.class, () -> + grpcStubs.walletsService.lockWallet(request)); + assertEquals("UNKNOWN: wallet is already locked", exception.getMessage()); + } + + @Test + @Order(7) + public void testUnlockWalletTimeoutOverride() { + unlockWallet("first-password", 2); + sleep(500); // override unlock timeout after 0.5s + unlockWallet("first-password", 6); + sleep(5000); + getBalance(); // getbalance 5s after resetting unlock timeout to 6s + } + + @Test + @Order(8) + public void testSetNewWalletPassword() { + var request = createSetWalletPasswordRequest("first-password", "second-password"); + grpcStubs.walletsService.setWalletPassword(request); + + unlockWallet("second-password", 2); + getBalance(); + sleep(2500); // allow time for wallet save + } + + @Test + @Order(9) + public void testSetNewWalletPasswordWithIncorrectNewPasswordShouldThrowException() { + var request = createSetWalletPasswordRequest("bad old password", "irrelevant"); + + Throwable exception = assertThrows(StatusRuntimeException.class, () -> + grpcStubs.walletsService.setWalletPassword(request)); + assertEquals("UNKNOWN: incorrect old password", exception.getMessage()); + } + + @Test + @Order(10) + public void testRemoveNewWalletPassword() { + var request = createRemoveWalletPasswordRequest("second-password"); + grpcStubs.walletsService.removeWalletPassword(request); + getBalance(); // should not throw 'wallet locked' exception + } + + @AfterAll + public static void tearDown() { + tearDownScaffold(); + } +} diff --git a/apitest/src/test/java/bisq/apitest/scenario/FundWalletScenarioTest.java b/apitest/src/test/java/bisq/apitest/scenario/FundWalletScenarioTest.java new file mode 100644 index 00000000000..e95e310eb58 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/FundWalletScenarioTest.java @@ -0,0 +1,68 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.scenario; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class FundWalletScenarioTest extends ScenarioTest { + + @BeforeAll + public static void setUp() { + try { + setUpScaffold("bitcoind,seednode,alicedaemon"); + bitcoinCli.generateBlocks(1); + MILLISECONDS.sleep(1500); + } catch (Exception ex) { + fail(ex); + } + } + + @Test + @Order(1) + public void testFundWallet() { + long balance = getBalance(); // bisq wallet was initialized with 10 btc + assertEquals(1000000000, balance); + + String unusedAddress = getUnusedBtcAddress(); + bitcoinCli.sendToAddress(unusedAddress, "2.5"); + + bitcoinCli.generateBlocks(1); + sleep(1500); + + balance = getBalance(); + assertEquals(1250000000L, balance); // new balance is 12.5 btc + } + + @AfterAll + public static void tearDown() { + tearDownScaffold(); + } +} diff --git a/apitest/src/test/java/bisq/apitest/scenario/ScenarioTest.java b/apitest/src/test/java/bisq/apitest/scenario/ScenarioTest.java new file mode 100644 index 00000000000..9750b2ed9d6 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/ScenarioTest.java @@ -0,0 +1,28 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.scenario; + +import lombok.extern.slf4j.Slf4j; + + + +import bisq.apitest.method.MethodTest; + +@Slf4j +public class ScenarioTest extends MethodTest { +} diff --git a/build.gradle b/build.gradle index 8e83abb1830..406c99e327a 100644 --- a/build.gradle +++ b/build.gradle @@ -98,7 +98,8 @@ configure([project(':cli'), project(':relay'), project(':seednode'), project(':statsnode'), - project(':pricenode')]) { + project(':pricenode'), + project(':apitest')]) { apply plugin: 'application' @@ -143,6 +144,16 @@ configure([project(':cli'), 'DEFAULT_JVM_OPTS=""', 'DEFAULT_JVM_OPTS="-XX:MaxRAM=4g"') } + if (applicationName == 'apitest') { + // Pass the logback config file as a system property to avoid chatty + // logback startup due to multiple logback.xml files in the classpath + // (:daemon & :cli). + def script = file("${rootProject.projectDir}/bisq-$applicationName") + script.text = script.text.replace( + 'DEFAULT_JVM_OPTS=""', 'DEFAULT_JVM_OPTS="' + + '-Dlogback.configurationFile=apitest/build/resources/main/logback.xml"') + } + if (osdetector.os != 'windows') delete fileTree(dir: rootProject.projectDir, include: 'bisq-*.bat') else @@ -551,3 +562,77 @@ configure(project(':daemon')) { annotationProcessor "org.projectlombok:lombok:$lombokVersion" } } + +configure(project(':apitest')) { + mainClassName = 'bisq.apitest.ApiTestMain' + + // The external dao-setup.gradle file contains tasks to install and clean dao-setup + // files downloaded from + // https://github.com/bisq-network/bisq/raw/master/docs/dao-setup.zip + // These tasks are not run by the default build, but they can can be run during + // full or partial builds, or by themselves. + // To run the regular clean + build + test (non api), and install dao-setup files: + // ./gradlew clean build :apitest:installDaoSetup + // To install or re-install dao-setup file only: + // ./gradlew :apitest:installDaoSetup -x test + // To clean installed dao-setup files: + // ./gradlew :apitest:cleanDaoSetup -x test + apply from: 'dao-setup.gradle' + + // We have to disable the :apitest 'test' task by default because we do not want + // to interfere with normal builds. To run JUnit tests in this subproject: + // Run a normal build and install dao-setup files first, then run: + // 'gradle :apitest:test -DrunApiTests=true' + test.enabled = System.getProperty("runApiTests") == "true" + + sourceSets { + main { + resources { + exclude 'dao-setup' + exclude 'dao-setup.zip' + } + } + } + + test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } + } + + dependencies { + compile project(':proto') + compile project(':common') + compile project(':seednode') + compile project(':desktop') + compile project(':daemon') + compile project(':cli') + implementation "net.sf.jopt-simple:jopt-simple:$joptVersion" + implementation "com.google.guava:guava:$guavaVersion" + implementation "com.google.protobuf:protobuf-java:$protobufVersion" + implementation("io.grpc:grpc-protobuf:$grpcVersion") { + exclude(module: 'guava') + exclude(module: 'animal-sniffer-annotations') + } + implementation("io.grpc:grpc-stub:$grpcVersion") { + exclude(module: 'guava') + exclude(module: 'animal-sniffer-annotations') + } + implementation "org.slf4j:slf4j-api:$slf4jVersion" + implementation "ch.qos.logback:logback-core:$logbackVersion" + implementation "ch.qos.logback:logback-classic:$logbackVersion" + + compileOnly "org.projectlombok:lombok:$lombokVersion" + compileOnly "javax.annotation:javax.annotation-api:$javaxAnnotationVersion" + annotationProcessor "org.projectlombok:lombok:$lombokVersion" + + testCompile "org.junit.jupiter:junit-jupiter-api:5.6.2" + testCompile "org.junit.jupiter:junit-jupiter-params:5.6.2" + testAnnotationProcessor "org.projectlombok:lombok:$lombokVersion" + testCompileOnly "org.projectlombok:lombok:$lombokVersion" + testRuntime "javax.annotation:javax.annotation-api:$javaxAnnotationVersion" + testRuntime("org.junit.jupiter:junit-jupiter-engine:5.6.2") + } +} + diff --git a/cli/src/main/java/bisq/cli/CurrencyFormat.java b/cli/src/main/java/bisq/cli/CurrencyFormat.java index 859dc4e8e8f..527ab8ca612 100644 --- a/cli/src/main/java/bisq/cli/CurrencyFormat.java +++ b/cli/src/main/java/bisq/cli/CurrencyFormat.java @@ -16,7 +16,7 @@ class CurrencyFormat { static final DecimalFormat BTC_FORMAT = new DecimalFormat("###,##0.00000000"); @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled") - static final String formatSatoshis(long sats) { + static String formatSatoshis(long sats) { return BTC_FORMAT.format(BigDecimal.valueOf(sats).divide(SATOSHI_DIVISOR)); } diff --git a/cli/test.sh b/cli/test.sh index 54af77c717b..9878d93fd02 100755 --- a/cli/test.sh +++ b/cli/test.sh @@ -48,14 +48,14 @@ run ./bisq-cli --password="xyz" getversion [ "$status" -eq 0 ] echo "actual output: $output" >&2 - [ "$output" = "1.3.4" ] + [ "$output" = "1.3.5" ] } @test "test getversion" { run ./bisq-cli --password=xyz getversion [ "$status" -eq 0 ] echo "actual output: $output" >&2 - [ "$output" = "1.3.4" ] + [ "$output" = "1.3.5" ] } @test "test setwalletpassword \"a b c\"" { diff --git a/settings.gradle b/settings.gradle index cdfa7c7126a..45ca5993367 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,5 +11,6 @@ include 'pricenode' include 'relay' include 'seednode' include 'statsnode' +include 'apitest' rootProject.name = 'bisq'